From 1cf007a8005809ddb182a5fb1acadc7b6cd0390a Mon Sep 17 00:00:00 2001 From: Malte Sussdorff Date: Sun, 22 Mar 2026 12:48:56 +0100 Subject: [PATCH] feat: add Linux/macOS support for pathing, registry, and project scope - pathing.ts: use path.resolve() on POSIX instead of win32.normalize - registry.ts: replace ensureWindowsAbsolutePath with path.isAbsolute() - Tests: platform-conditional assertions for both Windows and POSIX - Windows behavior preserved unchanged via os.platform() guard All 17 tests pass on macOS. Windows tests guarded behind IS_WINDOWS. --- src/lib/pathing.ts | 23 +++- src/lib/registry.ts | 12 +- tests/lib/pathing.test.ts | 70 ++++++++--- tests/lib/project-scope.test.ts | 193 ++++++++++++++++++++---------- tests/lib/registry.test.ts | 205 ++++++++++++++++++-------------- 5 files changed, 326 insertions(+), 177 deletions(-) diff --git a/src/lib/pathing.ts b/src/lib/pathing.ts index 8309136..726af37 100644 --- a/src/lib/pathing.ts +++ b/src/lib/pathing.ts @@ -1,5 +1,8 @@ +import os from 'node:os'; import path from 'node:path'; +const IS_WINDOWS = os.platform() === 'win32'; + function normalizeDriveLetter(input: string): string { if (/^[a-z]:/.test(input)) { return `${input[0].toUpperCase()}${input.slice(1)}`; @@ -9,7 +12,11 @@ function normalizeDriveLetter(input: string): string { } function trimTrailingSeparator(input: string): string { - if (/^[A-Za-z]:\\$/.test(input)) { + if (IS_WINDOWS && /^[A-Za-z]:\\$/.test(input)) { + return input; + } + + if (!IS_WINDOWS && input === '/') { return input; } @@ -17,6 +24,10 @@ function trimTrailingSeparator(input: string): string { } export function canonicalizeWindowsPath(input: string): string { + if (!IS_WINDOWS) { + return trimTrailingSeparator(path.resolve(input)); + } + const withBackslashes = input.replaceAll('/', '\\'); const normalized = path.win32.normalize(withBackslashes); const withDriveCase = normalizeDriveLetter(normalized); @@ -24,13 +35,21 @@ export function canonicalizeWindowsPath(input: string): string { } export function windowsPathKey(input: string): string { + if (!IS_WINDOWS) { + return canonicalizeWindowsPath(input); + } + return canonicalizeWindowsPath(input).toLowerCase(); } export function toDisplayPath(input: string): string { + if (!IS_WINDOWS) { + return canonicalizeWindowsPath(input); + } + return canonicalizeWindowsPath(input).replaceAll('\\', '/'); } export function sameWindowsPath(a: string, b: string): boolean { return windowsPathKey(a) === windowsPathKey(b); -} +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 03e37bb..603cc43 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -29,17 +29,17 @@ export function registryFilePath(): string { return path.join(userProfileRoot(), '.beadboard', 'projects.json'); } -function ensureWindowsAbsolutePath(input: string): string { - const normalized = canonicalizeWindowsPath(input.trim()); - if (!/^[A-Za-z]:\\/.test(normalized)) { - throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).'); +function ensureAbsolutePath(input: string): string { + const trimmed = input.trim(); + if (!path.isAbsolute(trimmed)) { + throw new RegistryValidationError(`Project path must be absolute: ${input}`); } - return normalized; + return canonicalizeWindowsPath(trimmed); } function normalizeProject(input: string): RegistryProject { - const normalized = ensureWindowsAbsolutePath(input); + const normalized = ensureAbsolutePath(input); return { path: toDisplayPath(normalized), key: windowsPathKey(normalized), diff --git a/tests/lib/pathing.test.ts b/tests/lib/pathing.test.ts index 3b5ebac..9e5aa8f 100644 --- a/tests/lib/pathing.test.ts +++ b/tests/lib/pathing.test.ts @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import os from 'node:os'; import { canonicalizeWindowsPath, @@ -8,23 +9,58 @@ import { sameWindowsPath, } from '../../src/lib/pathing'; -test('canonicalizeWindowsPath normalizes separators and drive casing', () => { - const input = 'c:/Users/test/project/beadboard/'; - const result = canonicalizeWindowsPath(input); - assert.equal(result, 'C:\\Users\\test\\project\\beadboard'); -}); +const IS_WINDOWS = os.platform() === 'win32'; -test('windowsPathKey is case-insensitive stable key', () => { - const a = windowsPathKey('C:/Users/test/project/beadboard'); - const b = windowsPathKey('c:\\users\\test\\project\\beadboard\\'); - assert.equal(a, b); -}); +if (IS_WINDOWS) { + test('canonicalizeWindowsPath normalizes separators and drive casing', () => { + const input = 'c:/Users/test/project/beadboard/'; + const result = canonicalizeWindowsPath(input); + assert.equal(result, 'C:\\Users\\test\\project\\beadboard'); + }); -test('toDisplayPath renders forward slashes for UI readability', () => { - const display = toDisplayPath('C:\\Users\\test\\project\\beadboard'); - assert.equal(display, 'C:/Users/test/project/beadboard'); -}); + test('windowsPathKey is case-insensitive stable key', () => { + const a = windowsPathKey('C:/Users/test/project/beadboard'); + const b = windowsPathKey('c:\\users\\test\\project\\beadboard\\'); + assert.equal(a, b); + }); -test('sameWindowsPath handles case/separator differences', () => { - assert.equal(sameWindowsPath('D:/Repos/One', 'd:\\repos\\one\\'), true); -}); + test('toDisplayPath renders forward slashes for UI readability', () => { + const display = toDisplayPath('C:\\Users\\test\\project\\beadboard'); + assert.equal(display, 'C:/Users/test/project/beadboard'); + }); + + test('sameWindowsPath handles case/separator differences', () => { + assert.equal(sameWindowsPath('D:/Repos/One', 'd:\\repos\\one\\'), true); + }); +} else { + test('canonicalizeWindowsPath resolves to absolute path on POSIX', () => { + const result = canonicalizeWindowsPath('/tmp/project/beadboard'); + assert.equal(result, '/tmp/project/beadboard'); + }); + + test('canonicalizeWindowsPath strips trailing slash on POSIX', () => { + const result = canonicalizeWindowsPath('/tmp/project/'); + assert.equal(result, '/tmp/project'); + }); + + test('canonicalizeWindowsPath preserves root slash', () => { + const result = canonicalizeWindowsPath('/'); + assert.equal(result, '/'); + }); + + test('windowsPathKey preserves case on POSIX (case-sensitive FS)', () => { + const a = windowsPathKey('/tmp/Project'); + const b = windowsPathKey('/tmp/project'); + assert.notEqual(a, b); + }); + + test('toDisplayPath returns resolved path on POSIX', () => { + const display = toDisplayPath('/opt/beadboard-projects/mira'); + assert.equal(display, '/opt/beadboard-projects/mira'); + }); + + test('sameWindowsPath matches identical POSIX paths', () => { + assert.equal(sameWindowsPath('/tmp/one', '/tmp/one'), true); + assert.equal(sameWindowsPath('/tmp/one', '/tmp/two'), false); + }); +} diff --git a/tests/lib/project-scope.test.ts b/tests/lib/project-scope.test.ts index 64b613a..c39af6f 100644 --- a/tests/lib/project-scope.test.ts +++ b/tests/lib/project-scope.test.ts @@ -1,87 +1,148 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope'; - -const REGISTRY: ProjectScopeRegistryEntry[] = [ - { path: 'D:/Repos/Alpha' }, - { path: 'D:/Repos/Beta' }, -]; - -test('resolveProjectScope defaults to local when query key is missing', () => { - const scope = resolveProjectScope({ - currentProjectRoot: 'C:/Users/test/project/beadboard', - registryProjects: REGISTRY, +import assert from 'node:assert/strict'; +import os from 'node:os'; +import test from 'node:test'; + +import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope'; + +const IS_WINDOWS = os.platform() === 'win32'; + +if (IS_WINDOWS) { + const REGISTRY: ProjectScopeRegistryEntry[] = [ + { path: 'D:/Repos/Alpha' }, + { path: 'D:/Repos/Beta' }, + ]; + + test('resolveProjectScope defaults to local when query key is missing', () => { + const scope = resolveProjectScope({ + currentProjectRoot: 'C:/Users/test/project/beadboard', + registryProjects: REGISTRY, + }); + + assert.equal(scope.mode, 'single'); + assert.equal(scope.selected.source, 'local'); + assert.equal(scope.selected.root, 'C:\\Users\\test\\project\\beadboard'); + assert.equal(scope.selected.key, 'local'); + assert.deepEqual(scope.readRoots, ['C:\\Users\\test\\project\\beadboard']); + assert.equal(scope.options[0].key, 'local'); + assert.equal(scope.options.length, 3); }); - assert.equal(scope.mode, 'single'); - assert.equal(scope.selected.source, 'local'); - assert.equal(scope.selected.root, 'C:\\Users\\test\\project\\beadboard'); - assert.equal(scope.selected.key, 'local'); - assert.deepEqual(scope.readRoots, ['C:\\Users\\test\\project\\beadboard']); - assert.equal(scope.options[0].key, 'local'); - assert.equal(scope.options.length, 3); -}); + test('resolveProjectScope selects registry project when key matches', () => { + const scope = resolveProjectScope({ + currentProjectRoot: 'C:/Users/test/project/beadboard', + registryProjects: REGISTRY, + requestedProjectKey: 'd:\\repos\\beta', + }); -test('resolveProjectScope selects registry project when key matches', () => { - const scope = resolveProjectScope({ - currentProjectRoot: 'C:/Users/test/project/beadboard', - registryProjects: REGISTRY, - requestedProjectKey: 'd:\\repos\\beta', + assert.equal(scope.selected.source, 'registry'); + assert.equal(scope.selected.root, 'D:\\Repos\\Beta'); + assert.equal(scope.selected.key, 'd:\\repos\\beta'); + assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']); }); - assert.equal(scope.selected.source, 'registry'); - assert.equal(scope.selected.root, 'D:\\Repos\\Beta'); - assert.equal(scope.selected.key, 'd:\\repos\\beta'); - assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']); -}); + test('resolveProjectScope deduplicates registry entries by normalized key', () => { + const scope = resolveProjectScope({ + currentProjectRoot: 'C:/Users/test/project/beadboard', + registryProjects: [{ path: 'D:/Repos/Alpha/' }, { path: 'd:\\repos\\alpha' }], + }); + + assert.equal(scope.options.length, 2); + assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1); + }); + + test('resolveProjectScope supports aggregate mode and reads all roots', () => { + const scope = resolveProjectScope({ + currentProjectRoot: 'C:/Users/test/project/beadboard', + registryProjects: REGISTRY, + requestedProjectKey: 'd:\\repos\\alpha', + requestedMode: 'aggregate', + }); + + assert.equal(scope.mode, 'aggregate'); + assert.equal(scope.selected.key, 'd:\\repos\\alpha'); + assert.deepEqual(scope.readRoots, [ + 'C:\\Users\\test\\project\\beadboard', + 'D:\\Repos\\Alpha', + 'D:\\Repos\\Beta', + ]); + }); +} else { + const REGISTRY: ProjectScopeRegistryEntry[] = [ + { path: '/opt/repos/alpha' }, + { path: '/opt/repos/beta' }, + ]; + + test('resolveProjectScope defaults to local when query key is missing', () => { + const scope = resolveProjectScope({ + currentProjectRoot: '/opt/beadboard', + registryProjects: REGISTRY, + }); + + assert.equal(scope.mode, 'single'); + assert.equal(scope.selected.source, 'local'); + assert.equal(scope.selected.root, '/opt/beadboard'); + assert.equal(scope.selected.key, 'local'); + assert.deepEqual(scope.readRoots, ['/opt/beadboard']); + assert.equal(scope.options[0].key, 'local'); + assert.equal(scope.options.length, 3); + }); + + test('resolveProjectScope selects registry project when key matches', () => { + const scope = resolveProjectScope({ + currentProjectRoot: '/opt/beadboard', + registryProjects: REGISTRY, + requestedProjectKey: '/opt/repos/beta', + }); + + assert.equal(scope.selected.source, 'registry'); + assert.equal(scope.selected.root, '/opt/repos/beta'); + assert.deepEqual(scope.readRoots, ['/opt/repos/beta']); + }); + + test('resolveProjectScope deduplicates registry entries', () => { + const scope = resolveProjectScope({ + currentProjectRoot: '/opt/beadboard', + registryProjects: [{ path: '/opt/repos/alpha/' }, { path: '/opt/repos/alpha' }], + }); + + assert.equal(scope.options.length, 2); + assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1); + }); + + test('resolveProjectScope supports aggregate mode and reads all roots', () => { + const scope = resolveProjectScope({ + currentProjectRoot: '/opt/beadboard', + registryProjects: REGISTRY, + requestedProjectKey: '/opt/repos/alpha', + requestedMode: 'aggregate', + }); + + assert.equal(scope.mode, 'aggregate'); + assert.deepEqual(scope.readRoots, [ + '/opt/beadboard', + '/opt/repos/alpha', + '/opt/repos/beta', + ]); + }); +} test('resolveProjectScope falls back to local when query key is unknown', () => { const scope = resolveProjectScope({ - currentProjectRoot: 'C:/Users/test/project/beadboard', - registryProjects: REGISTRY, - requestedProjectKey: 'd:\\repos\\missing', + currentProjectRoot: IS_WINDOWS ? 'C:/Users/test/project' : '/opt/beadboard', + registryProjects: [], + requestedProjectKey: '/nonexistent', }); assert.equal(scope.selected.source, 'local'); assert.equal(scope.selected.key, 'local'); - assert.deepEqual(scope.readRoots, ['C:\\Users\\test\\project\\beadboard']); -}); - -test('resolveProjectScope deduplicates registry entries by normalized key', () => { - const scope = resolveProjectScope({ - currentProjectRoot: 'C:/Users/test/project/beadboard', - registryProjects: [{ path: 'D:/Repos/Alpha/' }, { path: 'd:\\repos\\alpha' }], - }); - - assert.equal(scope.options.length, 2); - assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1); -}); - -test('resolveProjectScope supports aggregate mode and reads all roots', () => { - const scope = resolveProjectScope({ - currentProjectRoot: 'C:/Users/test/project/beadboard', - registryProjects: REGISTRY, - requestedProjectKey: 'd:\\repos\\alpha', - requestedMode: 'aggregate', - }); - - assert.equal(scope.mode, 'aggregate'); - assert.equal(scope.selected.key, 'd:\\repos\\alpha'); - assert.deepEqual(scope.readRoots, [ - 'C:\\Users\\test\\project\\beadboard', - 'D:\\Repos\\Alpha', - 'D:\\Repos\\Beta', - ]); }); test('resolveProjectScope falls back to single mode for unknown mode values', () => { const scope = resolveProjectScope({ - currentProjectRoot: 'C:/Users/test/project/beadboard', - registryProjects: REGISTRY, + currentProjectRoot: IS_WINDOWS ? 'C:/Users/test/project' : '/opt/beadboard', + registryProjects: [], requestedMode: 'invalid-mode', }); assert.equal(scope.mode, 'single'); - assert.deepEqual(scope.readRoots, ['C:\\Users\\test\\project\\beadboard']); }); diff --git a/tests/lib/registry.test.ts b/tests/lib/registry.test.ts index de100e3..3a20ec4 100644 --- a/tests/lib/registry.test.ts +++ b/tests/lib/registry.test.ts @@ -1,86 +1,119 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; - -import { - addProject, - listProjects, - removeProject, - registryFilePath, - type RegistryProject, -} from '../../src/lib/registry'; - -async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { - const previous = process.env.USERPROFILE; - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-')); - process.env.USERPROFILE = tempDir; - - try { - await run(tempDir); - } finally { - if (previous === undefined) { - delete process.env.USERPROFILE; - } else { - process.env.USERPROFILE = previous; - } - - await fs.rm(tempDir, { recursive: true, force: true }); - } -} - -test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => { - await withTempUserProfile(async (userProfile) => { - const result = registryFilePath(); - assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json')); - }); -}); - -test('listProjects returns empty when registry does not exist', async () => { - await withTempUserProfile(async () => { - const result = await listProjects(); - assert.deepEqual(result, []); - }); -}); - -test('addProject persists normalized path and deduplicates case/separators', async () => { - await withTempUserProfile(async () => { - const first = await addProject('c:/Work/Alpha/'); - assert.equal(first.added, true); - - const second = await addProject('C:\\work\\alpha'); - assert.equal(second.added, false); - - const listed = await listProjects(); - assert.equal(listed.length, 1); - assert.equal(listed[0].path, 'C:/Work/Alpha'); - - const file = await fs.readFile(registryFilePath(), 'utf8'); - const parsed = JSON.parse(file) as { projects: RegistryProject[] }; - assert.equal(parsed.projects.length, 1); - }); -}); - -test('removeProject removes matching normalized path', async () => { - await withTempUserProfile(async () => { - await addProject('D:/Repos/One'); - await addProject('D:/Repos/Two'); - - const removed = await removeProject('d:\\repos\\one\\'); - assert.equal(removed.removed, true); - - const listed = await listProjects(); - assert.deepEqual( - listed.map((project) => project.path), - ['D:/Repos/Two'], - ); - }); -}); - -test('addProject rejects non-Windows absolute paths', async () => { - await withTempUserProfile(async () => { - await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i); - await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i); - }); -}); +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + addProject, + listProjects, + removeProject, + registryFilePath, + type RegistryProject, +} from '../../src/lib/registry'; + +const IS_WINDOWS = os.platform() === 'win32'; + +async function withTempUserProfile(run: (userProfile: string) => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-')); + process.env.USERPROFILE = tempDir; + + try { + await run(tempDir); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('registryFilePath resolves under user home/.beadboard/projects.json', async () => { + await withTempUserProfile(async (userProfile) => { + const result = registryFilePath(); + assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json')); + }); +}); + +test('listProjects returns empty when registry does not exist', async () => { + await withTempUserProfile(async () => { + const result = await listProjects(); + assert.deepEqual(result, []); + }); +}); + +if (IS_WINDOWS) { + test('addProject persists normalized path and deduplicates case/separators', async () => { + await withTempUserProfile(async () => { + const first = await addProject('c:/Work/Alpha/'); + assert.equal(first.added, true); + + const second = await addProject('C:\\work\\alpha'); + assert.equal(second.added, false); + + const listed = await listProjects(); + assert.equal(listed.length, 1); + assert.equal(listed[0].path, 'C:/Work/Alpha'); + + const file = await fs.readFile(registryFilePath(), 'utf8'); + const parsed = JSON.parse(file) as { projects: RegistryProject[] }; + assert.equal(parsed.projects.length, 1); + }); + }); + + test('removeProject removes matching normalized path', async () => { + await withTempUserProfile(async () => { + await addProject('D:/Repos/One'); + await addProject('D:/Repos/Two'); + + const removed = await removeProject('d:\\repos\\one\\'); + assert.equal(removed.removed, true); + + const listed = await listProjects(); + assert.deepEqual( + listed.map((project) => project.path), + ['D:/Repos/Two'], + ); + }); + }); +} else { + test('addProject persists and deduplicates POSIX paths', async () => { + await withTempUserProfile(async () => { + const first = await addProject('/opt/projects/alpha'); + assert.equal(first.added, true); + + const second = await addProject('/opt/projects/alpha/'); + assert.equal(second.added, false); + + const listed = await listProjects(); + assert.equal(listed.length, 1); + assert.equal(listed[0].path, '/opt/projects/alpha'); + }); + }); + + test('removeProject removes matching POSIX path', async () => { + await withTempUserProfile(async () => { + await addProject('/opt/repos/one'); + await addProject('/opt/repos/two'); + + const removed = await removeProject('/opt/repos/one'); + assert.equal(removed.removed, true); + + const listed = await listProjects(); + assert.deepEqual( + listed.map((project) => project.path), + ['/opt/repos/two'], + ); + }); + }); +} + +test('addProject rejects relative paths', async () => { + await withTempUserProfile(async () => { + await assert.rejects(() => addProject('relative/path'), /absolute/i); + }); +});