Merge main into master and unify realtime + project-context test matrix

This commit is contained in:
zenchantlive 2026-02-11 21:06:38 -08:00
commit b4cb09a6cc
13 changed files with 806 additions and 6 deletions

View file

@ -0,0 +1,109 @@
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 { DELETE, GET, POST } from '../../src/app/api/projects/route';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-api-'));
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 });
}
}
async function readJson(response: Response): Promise<unknown> {
return response.json();
}
test('GET /api/projects returns empty list initially', async () => {
await withTempUserProfile(async () => {
const response = await GET();
assert.equal(response.status, 200);
const body = (await readJson(response)) as { projects: unknown[] };
assert.deepEqual(body.projects, []);
});
});
test('POST /api/projects validates payload and path', async () => {
await withTempUserProfile(async () => {
const missing = await POST(new Request('http://localhost/api/projects', { method: 'POST', body: '{}' }));
assert.equal(missing.status, 400);
const missingBody = (await readJson(missing)) as { error: string };
assert.match(missingBody.error, /path/i);
const invalidPath = await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: '/tmp/project' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(invalidPath.status, 400);
});
});
test('POST deduplicates and GET returns normalized path', async () => {
await withTempUserProfile(async () => {
const first = await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: 'c:/Users/Zenchant/codex/beadboard/' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(first.status, 201);
const dup = await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: 'C:\\users\\zenchant\\codex\\beadboard' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(dup.status, 200);
const list = await GET();
const body = (await readJson(list)) as { projects: Array<{ path: string }> };
assert.deepEqual(body.projects, [{ path: 'C:/Users/Zenchant/codex/beadboard' }]);
});
});
test('DELETE /api/projects removes by normalized path', async () => {
await withTempUserProfile(async () => {
await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: 'D:/Repos/One' }),
headers: { 'content-type': 'application/json' },
}),
);
const removed = await DELETE(
new Request('http://localhost/api/projects', {
method: 'DELETE',
body: JSON.stringify({ path: 'd:\\repos\\one\\' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(removed.status, 200);
const list = await GET();
const body = (await readJson(list)) as { projects: unknown[] };
assert.deepEqual(body.projects, []);
});
});

View file

@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildProjectContext } from '../../src/lib/project-context';
test('buildProjectContext derives normalized project identity', () => {
const project = buildProjectContext('C:/Repo/Project');
assert.equal(project.root, 'C:\\Repo\\Project');
assert.equal(project.key, 'c:\\repo\\project');
assert.equal(project.displayPath, 'C:/Repo/Project');
assert.equal(project.name, 'Project');
assert.equal(project.source, 'local');
assert.equal(project.addedAt, null);
});

View file

@ -5,7 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { readIssuesFromDisk, resolveIssuesJsonlPath, resolveIssuesJsonlPathCandidates } from '../../src/lib/read-issues';
import { sameWindowsPath } from '../../src/lib/pathing';
import { canonicalizeWindowsPath, sameWindowsPath, toDisplayPath, windowsPathKey } from '../../src/lib/pathing';
test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => {
const resolved = resolveIssuesJsonlPath('C:/Repo/Project');
@ -38,6 +38,12 @@ test('readIssuesFromDisk parses JSONL issues from disk', async () => {
assert.equal(issues.length, 1);
assert.equal(issues[0].id, 'bb-1');
assert.equal(issues[0].priority, 0);
assert.equal(issues[0].project.root, canonicalizeWindowsPath(root));
assert.equal(issues[0].project.key, windowsPathKey(root));
assert.equal(issues[0].project.displayPath, toDisplayPath(root));
assert.equal(issues[0].project.name, path.basename(canonicalizeWindowsPath(root)));
assert.equal(issues[0].project.source, 'local');
assert.equal(issues[0].project.addedAt, null);
});
test('readIssuesFromDisk returns empty list when issues file does not exist', async () => {

View file

@ -0,0 +1,86 @@
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<void>): Promise<void> {
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);
});
});

68
tests/lib/scanner.test.ts Normal file
View file

@ -0,0 +1,68 @@
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 } from '../../src/lib/registry';
import { scanForProjects, resolveScanRoots } from '../../src/lib/scanner';
import { canonicalizeWindowsPath, sameWindowsPath, windowsPathKey } from '../../src/lib/pathing';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scan-'));
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('resolveScanRoots includes profile and registry roots by default', async () => {
await withTempUserProfile(async (userProfile) => {
const registryRoot = path.join(userProfile, 'Registered');
await fs.mkdir(registryRoot, { recursive: true });
await addProject(registryRoot);
const roots = await resolveScanRoots();
assert.equal(roots.some((root) => sameWindowsPath(root, userProfile)), true);
assert.equal(roots.some((root) => sameWindowsPath(root, registryRoot)), true);
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), false);
});
});
test('resolveScanRoots includes full-drive roots only when requested', async () => {
await withTempUserProfile(async () => {
const roots = await resolveScanRoots({ mode: 'full-drive' });
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), true);
});
});
test('scanForProjects respects depth limits and ignore list', async () => {
await withTempUserProfile(async (userProfile) => {
const projectRoot = path.join(userProfile, 'ProjectA');
await fs.mkdir(path.join(projectRoot, '.beads'), { recursive: true });
const ignoredRoot = path.join(userProfile, 'node_modules', 'Ignored');
await fs.mkdir(path.join(ignoredRoot, '.beads'), { recursive: true });
const deepRoot = path.join(userProfile, 'Deep', 'Level1', 'Level2', 'ProjectDeep');
await fs.mkdir(path.join(deepRoot, '.beads'), { recursive: true });
const result = await scanForProjects({ maxDepth: 1 });
const keys = result.projects.map((project) => project.key);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(projectRoot))), true);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(ignoredRoot))), false);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(deepRoot))), false);
});
});