OpenWriterControle/packages/cli/src/lib/bundled.test.ts
Darren Hinde d2a633d7dc
feat(cli): Bun-first CLI migration with 7 commands and comprehensive test suite (#259)
* feat(cli): migrate packages/cli to Bun-first with full test suite

- Replace fs-extra with Bun.file/Bun.write/node:fs/promises across all 9 source files
- Fix --help fast-path so all 8 subcommands are visible
- Replace tsup/tsx/vitest with bun build/bun run/bun:test
- Replace __dirname with import.meta.dir; use JSON import assertion for version
- Fix hardcoded OAC_VERSION in add.ts; add hashesMatch import to status.ts
- Replace computeFileHash with Bun.file().bytes() in sha256.ts
- Rename checkNodeVersion → checkBunVersion; parallelise doctor checks
- Fix TypeScript void-inference errors in update.ts, list.ts, status.ts
  by replacing .catch() with let/try-catch where result is used downstream
- Add ManifestError named error class; remove unsafe type casts
- Add 43 bun:test unit tests (sha256, manifest, installer, version) — 0 failures

* fix(cli): resolve critical bugs and standards violations found in review

Critical bug fixes:
- ide-detect.ts: replace Bun.file(dir).exists() with stat().isDirectory()
  for all directory checks — Bun.file().exists() always returns false for
  directories, breaking Cursor/Windsurf/OpenCode/Claude detection entirely
- bundled.ts: add registry.json exclusion anchor to findPackageRoot() to
  prevent monorepo root from matching before the CLI package root

Standards cleanup (§4.1, §5.1, §6.1, §15.3, §21.1):
- status.ts: parallelise findModifiedFiles + detectIdes with Promise.all
- apply.ts: fix duplicate warn/limit messages; remove else after return in
  reportWarnings; remove redundant mkdir before Bun.write
- version.ts: remove unnecessary (pkgJson as {version?:string}) cast
- manifest.ts: remove redundant mkdir before Bun.write; remove as unknown cast
- installer.ts: remove redundant mkdir calls before Bun.write throughout
- add.ts: add comment explaining why node:fs/promises rm is used

Tests (43 → 142, +99 new tests across 4 new files):
- ide-detect.test.ts: 26 tests covering all 4 IDEs, both claude indicators,
  detectIdes parallel, isIdePresent — directly validates the directory fix
- bundled.test.ts: 27 tests for classifyBundledFile, findPackageRoot,
  listBundledFiles, getBundledFilePath, bundledFileExists
- config.test.ts: 25 tests for readConfig/writeConfig round-trips,
  createDefaultConfig, mergeConfig, isYoloMode, isAutoBackup
- installer-update.test.ts: 14 tests covering all 5 updateFiles decision
  branches (install/update/skip/yolo/dry-run) plus isProjectRoot
- sha256.test.ts: +4 tests for empty file, large file, binary content

* fix(cli): fix P0 bugs — global flags, .git detection, package root resolution

- Remove duplicate --dry-run/--yolo/--verbose from parent program; Commander.js
  global option stealing caused all safety flags to be silently dropped in every
  subcommand action callback
- Fix isProjectRoot() to use stat() for .git detection; Bun.file().exists()
  returns false for directories, breaking oac init in standard git repos
- Add OAC_PACKAGE_ROOT env var override to getPackageRoot() for dev/monorepo mode;
  registry.json heuristic excluded the repo root causing oac init/add/update to
  throw when run from source
- Fix build script: remove --banner flag that caused double shebang in dist/index.js
- Rewrite bin/oac.js to invoke bun instead of node (dist is bun-only target)
- Refactor installer.ts let accumulators to const using Promise.all + reduce
- Add MVP planning docs (00-MVP-PLAN.md, master synthesis, project breakdown)

* fix(install.sh): handle both singular and plural component type formats

The get_registry_key() function was always adding 's' to the type,
causing 'contexts' to become 'contextss' which doesn't exist in registry.json.

Now handles:
- Singular forms: context → contexts, agent → agents, skill → skills
- Plural forms: contexts → contexts (unchanged), agents → agents
- Config stays singular
- Fallback for any type ending in 's'

Fixes #257
2026-03-24 21:54:03 +00:00

334 lines
14 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
classifyBundledFile,
findPackageRoot,
getBundledFilePath,
listBundledFiles,
bundledFileExists,
type BundledFileType,
} from './bundled.js';
// ── classifyBundledFile ───────────────────────────────────────────────────────
// Pure function — no I/O, no setup needed.
describe('classifyBundledFile', () => {
// ✅ Positive: agent prefix
test('returns "agent" for .opencode/agent/ paths', () => {
// Arrange
const path = '.opencode/agent/core/openagent.md';
// Act
const result: BundledFileType = classifyBundledFile(path);
// Assert
expect(result).toBe('agent');
});
// ✅ Positive: agent prefix — nested deeply
test('returns "agent" for deeply nested agent paths', () => {
expect(classifyBundledFile('.opencode/agent/sub/dir/file.md')).toBe('agent');
});
// ✅ Positive: context prefix
test('returns "context" for .opencode/context/ paths', () => {
expect(classifyBundledFile('.opencode/context/standards.md')).toBe('context');
});
// ✅ Positive: context prefix — nested
test('returns "context" for nested context paths', () => {
expect(classifyBundledFile('.opencode/context/sub/file.md')).toBe('context');
});
// ✅ Positive: skill prefix
test('returns "skill" for .opencode/skills/ paths', () => {
expect(classifyBundledFile('.opencode/skills/my-skill.md')).toBe('skill');
});
// ✅ Positive: skill prefix — nested
test('returns "skill" for nested skills paths', () => {
expect(classifyBundledFile('.opencode/skills/category/skill.md')).toBe('skill');
});
// ✅ Positive: config fallback — arbitrary path
test('returns "config" for unrecognised paths', () => {
expect(classifyBundledFile('some/other/file.json')).toBe('config');
});
// ✅ Positive: config fallback — root-level file
test('returns "config" for a root-level file', () => {
expect(classifyBundledFile('README.md')).toBe('config');
});
// ❌ Negative: path that starts with .opencode/ but not a known subdir
test('returns "config" for .opencode/ paths with unknown subdir', () => {
expect(classifyBundledFile('.opencode/unknown/file.md')).toBe('config');
});
// ❌ Negative: partial prefix match should NOT classify as agent
test('returns "config" for path that only partially matches agent prefix', () => {
// ".opencode/agentX/" is NOT ".opencode/agent/"
expect(classifyBundledFile('.opencode/agentX/file.md')).toBe('config');
});
// ❌ Negative: partial prefix match should NOT classify as context
test('returns "config" for path that only partially matches context prefix', () => {
expect(classifyBundledFile('.opencode/contexts/file.md')).toBe('config');
});
// ❌ Negative: empty string
test('returns "config" for an empty string', () => {
expect(classifyBundledFile('')).toBe('config');
});
});
// ── getBundledFilePath ────────────────────────────────────────────────────────
// Pure function — no I/O.
describe('getBundledFilePath', () => {
// ✅ Positive: joins packageRoot and relativePath
test('joins packageRoot and relativePath correctly', () => {
// Arrange
const packageRoot = '/usr/local/lib/oac';
const relativePath = '.opencode/agent/core/openagent.md';
// Act
const result = getBundledFilePath(packageRoot, relativePath);
// Assert
expect(result).toBe('/usr/local/lib/oac/.opencode/agent/core/openagent.md');
});
// ✅ Positive: works with nested relative paths
test('handles nested relative paths', () => {
const result = getBundledFilePath('/root', '.opencode/skills/sub/skill.md');
expect(result).toBe('/root/.opencode/skills/sub/skill.md');
});
// ❌ Negative: empty relative path returns just the packageRoot
test('returns packageRoot when relativePath is empty', () => {
const result = getBundledFilePath('/root', '');
expect(result).toBe('/root');
});
});
// ── findPackageRoot ───────────────────────────────────────────────────────────
describe('findPackageRoot', () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'oac-bundled-test-'));
});
afterAll(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ✅ Positive: finds a directory that has both .opencode/ and package.json
test('returns the directory that has both .opencode/ and package.json', async () => {
// Arrange — create a fake package root
const fakeRoot = join(tmpDir, 'fake-pkg');
await mkdir(join(fakeRoot, '.opencode'), { recursive: true });
await writeFile(join(fakeRoot, 'package.json'), '{}', 'utf8');
// Also create a subdirectory to start the walk from
const startDir = join(fakeRoot, 'dist', 'lib');
await mkdir(startDir, { recursive: true });
// Act
const result = findPackageRoot(startDir);
// Assert
expect(result).toBe(fakeRoot);
});
// ✅ Positive: finds root when starting exactly at the package root
test('returns the start directory itself when it is the package root', async () => {
// Arrange
const fakeRoot = join(tmpDir, 'exact-root');
await mkdir(join(fakeRoot, '.opencode'), { recursive: true });
await writeFile(join(fakeRoot, 'package.json'), '{}', 'utf8');
// Act
const result = findPackageRoot(fakeRoot);
// Assert
expect(result).toBe(fakeRoot);
});
// ❌ Negative: throws when no package root is found (isolated tmp dir with no markers)
test('throws an error when no package root is found walking to filesystem root', async () => {
// Arrange — a directory with neither .opencode/ nor package.json
const isolated = join(tmpDir, 'isolated-no-markers');
await mkdir(isolated, { recursive: true });
// Act & Assert — we cannot actually walk to the real filesystem root in a
// test (it would find the monorepo's package.json), so we test the error
// message shape by checking that a directory missing .opencode throws when
// the walk terminates. We use a path that IS the filesystem root equivalent
// by mocking: instead, we verify the thrown error message format by calling
// with a path that has package.json but no .opencode, and one that has
// .opencode but no package.json — neither should match, but the walk will
// eventually reach the real monorepo root. So we test the error path by
// verifying the function throws when given a path that cannot possibly
// resolve (we use the OS tmpdir itself, which has no .opencode).
//
// The safest approach: create a temp dir tree that is self-contained and
// has no .opencode anywhere. We can't prevent the walk from going above
// tmpdir, so we test the error message by checking it contains the
// expected substring when we know it will throw.
//
// NOTE: In CI / a clean environment this will throw because there is no
// .opencode above the tmpdir. In a monorepo dev environment the walk may
// find the repo root. We therefore test the error *shape* by directly
// calling with a path that we know will fail: the filesystem root '/'.
expect(() => findPackageRoot('/')).toThrow(
'getPackageRoot: could not find a directory with ".opencode/" and "package.json"',
);
});
// ❌ Negative: error message includes the start directory
test('error message includes the starting directory', () => {
// Arrange & Act & Assert
let thrownMessage = '';
try {
findPackageRoot('/');
} catch (err) {
thrownMessage = err instanceof Error ? err.message : String(err);
}
expect(thrownMessage).toContain('"/"');
});
// ❌ Negative: directory with only package.json (no .opencode) does not match
test('does not match a directory that has package.json but no .opencode', async () => {
// Arrange — a directory with only package.json, no .opencode
const noOpencode = join(tmpDir, 'no-opencode');
await mkdir(noOpencode, { recursive: true });
await writeFile(join(noOpencode, 'package.json'), '{}', 'utf8');
// Start from a child — the walk will pass through noOpencode (no match)
// and continue upward until it finds the monorepo root or throws.
// We just verify it does NOT return noOpencode.
let result: string | undefined;
try {
result = findPackageRoot(noOpencode);
} catch {
result = undefined;
}
// If it found something, it must NOT be noOpencode (which lacks .opencode)
if (result !== undefined) {
expect(result).not.toBe(noOpencode);
}
// Either it threw (correct) or found a higher-level root (also acceptable)
expect(true).toBe(true); // test passes either way — the key is it didn't return noOpencode
});
});
// ── listBundledFiles ──────────────────────────────────────────────────────────
describe('listBundledFiles', () => {
let packageRoot: string;
beforeAll(async () => {
packageRoot = await mkdtemp(join(tmpdir(), 'oac-list-bundled-'));
// Create a fake package structure with files in all three subdirs
await mkdir(join(packageRoot, '.opencode', 'agent', 'core'), { recursive: true });
await mkdir(join(packageRoot, '.opencode', 'context'), { recursive: true });
await mkdir(join(packageRoot, '.opencode', 'skills', 'sub'), { recursive: true });
await writeFile(join(packageRoot, '.opencode', 'agent', 'core', 'openagent.md'), '# Agent', 'utf8');
await writeFile(join(packageRoot, '.opencode', 'agent', 'helper.md'), '# Helper', 'utf8');
await writeFile(join(packageRoot, '.opencode', 'context', 'standards.md'), '# Standards', 'utf8');
await writeFile(join(packageRoot, '.opencode', 'skills', 'sub', 'skill.md'), '# Skill', 'utf8');
});
afterAll(async () => {
await rm(packageRoot, { recursive: true, force: true });
});
// ✅ Positive: returns relative paths for all files in all three subdirs
test('returns relative paths for all bundled files', async () => {
// Act
const files = await listBundledFiles(packageRoot);
// Assert — all four files should be present
expect(files).toContain('.opencode/agent/core/openagent.md');
expect(files).toContain('.opencode/agent/helper.md');
expect(files).toContain('.opencode/context/standards.md');
expect(files).toContain('.opencode/skills/sub/skill.md');
expect(files).toHaveLength(4);
});
// ✅ Positive: paths are relative (not absolute)
test('returns relative paths, not absolute paths', async () => {
const files = await listBundledFiles(packageRoot);
for (const f of files) {
expect(f.startsWith('/')).toBe(false);
}
});
// ✅ Positive: paths start with .opencode/
test('all returned paths start with .opencode/', async () => {
const files = await listBundledFiles(packageRoot);
for (const f of files) {
expect(f.startsWith('.opencode/')).toBe(true);
}
});
// ❌ Negative: missing subdirectories are silently skipped
test('silently skips subdirectories that do not exist', async () => {
// Arrange — a package root with only the agent subdir
const sparseRoot = await mkdtemp(join(tmpdir(), 'oac-sparse-pkg-'));
try {
await mkdir(join(sparseRoot, '.opencode', 'agent'), { recursive: true });
await writeFile(join(sparseRoot, '.opencode', 'agent', 'only.md'), '# Only', 'utf8');
// Act — context/ and skills/ don't exist
const files = await listBundledFiles(sparseRoot);
// Assert — only the agent file, no errors
expect(files).toHaveLength(1);
expect(files[0]).toBe('.opencode/agent/only.md');
} finally {
await rm(sparseRoot, { recursive: true, force: true });
}
});
// ❌ Negative: empty package root returns empty array
test('returns empty array when no bundled subdirs exist', async () => {
// Arrange — completely empty package root
const emptyRoot = await mkdtemp(join(tmpdir(), 'oac-empty-pkg-'));
try {
const files = await listBundledFiles(emptyRoot);
expect(files).toHaveLength(0);
} finally {
await rm(emptyRoot, { recursive: true, force: true });
}
});
});
// ── bundledFileExists ─────────────────────────────────────────────────────────
describe('bundledFileExists', () => {
let packageRoot: string;
beforeAll(async () => {
packageRoot = await mkdtemp(join(tmpdir(), 'oac-exists-test-'));
await mkdir(join(packageRoot, '.opencode', 'agent'), { recursive: true });
await writeFile(join(packageRoot, '.opencode', 'agent', 'present.md'), '# Present', 'utf8');
});
afterAll(async () => {
await rm(packageRoot, { recursive: true, force: true });
});
// ✅ Positive: returns true for a file that exists
test('returns true when the bundled file exists', async () => {
const exists = await bundledFileExists(packageRoot, '.opencode/agent/present.md');
expect(exists).toBe(true);
});
// ❌ Negative: returns false for a file that does not exist
test('returns false when the bundled file does not exist', async () => {
const exists = await bundledFileExists(packageRoot, '.opencode/agent/missing.md');
expect(exists).toBe(false);
});
});