|
|
@@ -0,0 +1,558 @@
|
|
|
+/**
|
|
|
+ * Tests for updateFiles() and isProjectRoot() in installer.ts.
|
|
|
+ *
|
|
|
+ * updateFiles() is the core OAC update algorithm:
|
|
|
+ * - File in manifest + hash matches disk → update (overwrite with new bundle version)
|
|
|
+ * - File in manifest + hash differs → skip (user modified it)
|
|
|
+ * - File in manifest + hash differs + yolo → backup + overwrite
|
|
|
+ * - File NOT in manifest → install as new
|
|
|
+ * - File in manifest but NOT in bundle → remove from manifest, leave disk copy
|
|
|
+ *
|
|
|
+ * All tests use real temp directories (no network, no external deps).
|
|
|
+ */
|
|
|
+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 { updateFiles, isProjectRoot } from './installer.js';
|
|
|
+import { writeManifest, createEmptyManifest, addFileToManifest } from './manifest.js';
|
|
|
+import { computeFileHash } from './sha256.js';
|
|
|
+import type { InstallOptions } from './installer.js';
|
|
|
+import type { FileEntry } from './manifest.js';
|
|
|
+
|
|
|
+// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+const makeOptions = (
|
|
|
+ projectRoot: string,
|
|
|
+ packageRoot: string,
|
|
|
+ overrides: Partial<InstallOptions> = {},
|
|
|
+): InstallOptions => ({
|
|
|
+ projectRoot,
|
|
|
+ packageRoot,
|
|
|
+ dryRun: false,
|
|
|
+ yolo: false,
|
|
|
+ verbose: false,
|
|
|
+ ...overrides,
|
|
|
+});
|
|
|
+
|
|
|
+const makeEntry = (sha256: string, overrides: Partial<FileEntry> = {}): FileEntry => ({
|
|
|
+ sha256,
|
|
|
+ type: 'agent',
|
|
|
+ source: 'bundled',
|
|
|
+ installedAt: new Date().toISOString(),
|
|
|
+ ...overrides,
|
|
|
+});
|
|
|
+
|
|
|
+/**
|
|
|
+ * Creates a minimal fake package root with a single bundled file.
|
|
|
+ * Returns the relative path used for the bundled file.
|
|
|
+ */
|
|
|
+async function setupPackageRoot(
|
|
|
+ packageRoot: string,
|
|
|
+ relativePath: string,
|
|
|
+ content: string,
|
|
|
+): Promise<void> {
|
|
|
+ const absPath = join(packageRoot, relativePath);
|
|
|
+ await mkdir(join(absPath, '..'), { recursive: true });
|
|
|
+ await writeFile(absPath, content, 'utf8');
|
|
|
+}
|
|
|
+
|
|
|
+// ── updateFiles — install new file (not in manifest) ─────────────────────────
|
|
|
+
|
|
|
+describe('updateFiles — install new file (not in manifest)', () => {
|
|
|
+ let projectRoot: string;
|
|
|
+ let packageRoot: string;
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), 'oac-update-new-'));
|
|
|
+ packageRoot = await mkdtemp(join(tmpdir(), 'oac-update-new-pkg-'));
|
|
|
+
|
|
|
+ // Bundled file exists in the package
|
|
|
+ await setupPackageRoot(packageRoot, '.opencode/agent/new-agent.md', '# New Agent');
|
|
|
+
|
|
|
+ // No manifest written — file is brand new
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
+ await rm(packageRoot, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: file not in manifest → installed
|
|
|
+ test('installs a file that is not in the manifest', async () => {
|
|
|
+ // Arrange
|
|
|
+ const opts = makeOptions(projectRoot, packageRoot);
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const { result, updatedManifest } = await updateFiles(opts);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result.installed).toContain('.opencode/agent/new-agent.md');
|
|
|
+ expect(result.errors).toHaveLength(0);
|
|
|
+ expect(result.skipped).toHaveLength(0);
|
|
|
+
|
|
|
+ // File should exist on disk
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/new-agent.md');
|
|
|
+ expect(await Bun.file(destPath).exists()).toBe(true);
|
|
|
+ expect(await Bun.file(destPath).text()).toBe('# New Agent');
|
|
|
+
|
|
|
+ // Manifest should track the file
|
|
|
+ expect(updatedManifest.files['.opencode/agent/new-agent.md']).toBeDefined();
|
|
|
+ expect(updatedManifest.files['.opencode/agent/new-agent.md']?.sha256).toHaveLength(64);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ── updateFiles — update untouched file (hash matches manifest) ───────────────
|
|
|
+
|
|
|
+describe('updateFiles — update untouched file (hash matches manifest)', () => {
|
|
|
+ let projectRoot: string;
|
|
|
+ let packageRoot: string;
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), 'oac-update-untouched-'));
|
|
|
+ packageRoot = await mkdtemp(join(tmpdir(), 'oac-update-untouched-pkg-'));
|
|
|
+
|
|
|
+ // The "old" bundled content that was previously installed
|
|
|
+ const oldContent = '# Old Agent Content';
|
|
|
+ // The "new" bundled content (what the package now ships)
|
|
|
+ const newContent = '# New Agent Content';
|
|
|
+
|
|
|
+ // Write the OLD content to the project (simulating a previous install)
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/agent.md');
|
|
|
+ await mkdir(join(destPath, '..'), { recursive: true });
|
|
|
+ await writeFile(destPath, oldContent, 'utf8');
|
|
|
+
|
|
|
+ // Compute the hash of the old content (what the manifest recorded)
|
|
|
+ const oldHash = await computeFileHash(destPath);
|
|
|
+
|
|
|
+ // Write a manifest that records the old hash (user hasn't touched the file)
|
|
|
+ let manifest = createEmptyManifest('1.0.0');
|
|
|
+ manifest = addFileToManifest(manifest, '.opencode/agent/agent.md', makeEntry(oldHash));
|
|
|
+ await writeManifest(projectRoot, manifest);
|
|
|
+
|
|
|
+ // Now update the bundled file to the NEW content
|
|
|
+ await setupPackageRoot(packageRoot, '.opencode/agent/agent.md', newContent);
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
+ await rm(packageRoot, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: untouched file → updated with new bundle content
|
|
|
+ test('updates a file whose disk hash matches the manifest (user did not modify it)', async () => {
|
|
|
+ // Arrange
|
|
|
+ const opts = makeOptions(projectRoot, packageRoot);
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const { result, updatedManifest } = await updateFiles(opts);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result.updated).toContain('.opencode/agent/agent.md');
|
|
|
+ expect(result.skipped).toHaveLength(0);
|
|
|
+ expect(result.errors).toHaveLength(0);
|
|
|
+
|
|
|
+ // Disk should now have the new content
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/agent.md');
|
|
|
+ expect(await Bun.file(destPath).text()).toBe('# New Agent Content');
|
|
|
+
|
|
|
+ // Manifest hash should be updated to the new file's hash
|
|
|
+ const newHash = await computeFileHash(destPath);
|
|
|
+ expect(updatedManifest.files['.opencode/agent/agent.md']?.sha256).toBe(newHash);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ── updateFiles — skip user-modified file (no --yolo) ────────────────────────
|
|
|
+
|
|
|
+describe('updateFiles — skip user-modified file (no --yolo)', () => {
|
|
|
+ let projectRoot: string;
|
|
|
+ let packageRoot: string;
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), 'oac-update-skip-'));
|
|
|
+ packageRoot = await mkdtemp(join(tmpdir(), 'oac-update-skip-pkg-'));
|
|
|
+
|
|
|
+ // Write a file to disk with content that differs from what the manifest recorded
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/modified.md');
|
|
|
+ await mkdir(join(destPath, '..'), { recursive: true });
|
|
|
+ await writeFile(destPath, '# User Modified Content', 'utf8');
|
|
|
+
|
|
|
+ // Manifest records a DIFFERENT hash (the original installed hash)
|
|
|
+ const fakeOriginalHash = 'a'.repeat(64); // clearly different from actual file
|
|
|
+ let manifest = createEmptyManifest('1.0.0');
|
|
|
+ manifest = addFileToManifest(manifest, '.opencode/agent/modified.md', makeEntry(fakeOriginalHash));
|
|
|
+ await writeManifest(projectRoot, manifest);
|
|
|
+
|
|
|
+ // Bundle has a new version of the file
|
|
|
+ await setupPackageRoot(packageRoot, '.opencode/agent/modified.md', '# Bundle New Content');
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
+ await rm(packageRoot, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: user-modified file → skipped (not overwritten)
|
|
|
+ test('skips a file whose disk hash differs from the manifest (user modified it)', async () => {
|
|
|
+ // Arrange
|
|
|
+ const opts = makeOptions(projectRoot, packageRoot, { yolo: false });
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const { result } = await updateFiles(opts);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result.skipped).toContain('.opencode/agent/modified.md');
|
|
|
+ expect(result.updated).toHaveLength(0);
|
|
|
+ expect(result.errors).toHaveLength(0);
|
|
|
+
|
|
|
+ // Disk content must be unchanged
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/modified.md');
|
|
|
+ expect(await Bun.file(destPath).text()).toBe('# User Modified Content');
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ── updateFiles — yolo: backup + overwrite user-modified file ─────────────────
|
|
|
+
|
|
|
+describe('updateFiles — yolo overwrite of user-modified file', () => {
|
|
|
+ let projectRoot: string;
|
|
|
+ let packageRoot: string;
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), 'oac-update-yolo-'));
|
|
|
+ packageRoot = await mkdtemp(join(tmpdir(), 'oac-update-yolo-pkg-'));
|
|
|
+
|
|
|
+ // Write user-modified content to disk
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/yolo-file.md');
|
|
|
+ await mkdir(join(destPath, '..'), { recursive: true });
|
|
|
+ await writeFile(destPath, '# User Modified', 'utf8');
|
|
|
+
|
|
|
+ // Manifest records a different hash
|
|
|
+ const fakeHash = 'b'.repeat(64);
|
|
|
+ let manifest = createEmptyManifest('1.0.0');
|
|
|
+ manifest = addFileToManifest(manifest, '.opencode/agent/yolo-file.md', makeEntry(fakeHash));
|
|
|
+ await writeManifest(projectRoot, manifest);
|
|
|
+
|
|
|
+ // Bundle has new content
|
|
|
+ await setupPackageRoot(packageRoot, '.opencode/agent/yolo-file.md', '# Bundle Overwrite');
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
+ await rm(packageRoot, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: yolo mode → file is backed up and overwritten
|
|
|
+ test('backs up and overwrites a user-modified file in yolo mode', async () => {
|
|
|
+ // Arrange
|
|
|
+ const opts = makeOptions(projectRoot, packageRoot, { yolo: true });
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const { result } = await updateFiles(opts);
|
|
|
+
|
|
|
+ // Assert — file should be in updated (overwritten) and backed_up
|
|
|
+ expect(result.updated).toContain('.opencode/agent/yolo-file.md');
|
|
|
+ expect(result.backed_up).toHaveLength(1);
|
|
|
+ expect(result.skipped).toHaveLength(0);
|
|
|
+ expect(result.errors).toHaveLength(0);
|
|
|
+
|
|
|
+ // Disk should now have the bundle content
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/yolo-file.md');
|
|
|
+ expect(await Bun.file(destPath).text()).toBe('# Bundle Overwrite');
|
|
|
+
|
|
|
+ // Backup should exist and contain the original user content
|
|
|
+ const backupPath = result.backed_up[0]!;
|
|
|
+ expect(await Bun.file(backupPath).exists()).toBe(true);
|
|
|
+ expect(await Bun.file(backupPath).text()).toBe('# User Modified');
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: backup path is inside .oac/backups/
|
|
|
+ test('backup path is inside .oac/backups/', async () => {
|
|
|
+ // The previous test already ran updateFiles; we check the backup path shape.
|
|
|
+ // Re-run with a fresh setup to get a clean result.
|
|
|
+ const pr2 = await mkdtemp(join(tmpdir(), 'oac-yolo-path-'));
|
|
|
+ const pkgr2 = await mkdtemp(join(tmpdir(), 'oac-yolo-path-pkg-'));
|
|
|
+ try {
|
|
|
+ const destPath = join(pr2, '.opencode/agent/path-check.md');
|
|
|
+ await mkdir(join(destPath, '..'), { recursive: true });
|
|
|
+ await writeFile(destPath, '# Modified', 'utf8');
|
|
|
+ const fakeHash = 'c'.repeat(64);
|
|
|
+ let manifest = createEmptyManifest('1.0.0');
|
|
|
+ manifest = addFileToManifest(manifest, '.opencode/agent/path-check.md', makeEntry(fakeHash));
|
|
|
+ await writeManifest(pr2, manifest);
|
|
|
+ await setupPackageRoot(pkgr2, '.opencode/agent/path-check.md', '# New');
|
|
|
+
|
|
|
+ const opts = makeOptions(pr2, pkgr2, { yolo: true });
|
|
|
+ const { result } = await updateFiles(opts);
|
|
|
+
|
|
|
+ expect(result.backed_up[0]).toContain('.oac/backups/');
|
|
|
+ } finally {
|
|
|
+ await rm(pr2, { recursive: true, force: true });
|
|
|
+ await rm(pkgr2, { recursive: true, force: true });
|
|
|
+ }
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ── updateFiles — dry-run mode ────────────────────────────────────────────────
|
|
|
+
|
|
|
+describe('updateFiles — dry-run mode', () => {
|
|
|
+ let projectRoot: string;
|
|
|
+ let packageRoot: string;
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), 'oac-update-dryrun-'));
|
|
|
+ packageRoot = await mkdtemp(join(tmpdir(), 'oac-update-dryrun-pkg-'));
|
|
|
+
|
|
|
+ // Bundle has a file; no manifest, no existing disk file
|
|
|
+ await setupPackageRoot(packageRoot, '.opencode/agent/dry-agent.md', '# Dry Agent');
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
+ await rm(packageRoot, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: dry-run reports installed but does not write to disk
|
|
|
+ test('dry-run: reports installed files without writing to disk', async () => {
|
|
|
+ // Arrange
|
|
|
+ const opts = makeOptions(projectRoot, packageRoot, { dryRun: true });
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const { result } = await updateFiles(opts);
|
|
|
+
|
|
|
+ // Assert — result says installed
|
|
|
+ expect(result.installed).toContain('.opencode/agent/dry-agent.md');
|
|
|
+ expect(result.errors).toHaveLength(0);
|
|
|
+
|
|
|
+ // But the file must NOT exist on disk
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/dry-agent.md');
|
|
|
+ expect(await Bun.file(destPath).exists()).toBe(false);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ── updateFiles — remove from manifest (file no longer in bundle) ─────────────
|
|
|
+
|
|
|
+describe('updateFiles — remove from manifest when file no longer in bundle', () => {
|
|
|
+ let projectRoot: string;
|
|
|
+ let packageRoot: string;
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), 'oac-update-remove-'));
|
|
|
+ packageRoot = await mkdtemp(join(tmpdir(), 'oac-update-remove-pkg-'));
|
|
|
+
|
|
|
+ // Manifest tracks a file that is no longer in the bundle
|
|
|
+ const oldHash = 'd'.repeat(64);
|
|
|
+ let manifest = createEmptyManifest('1.0.0');
|
|
|
+ manifest = addFileToManifest(manifest, '.opencode/agent/removed.md', makeEntry(oldHash));
|
|
|
+ await writeManifest(projectRoot, manifest);
|
|
|
+
|
|
|
+ // Write the file to disk (user's copy)
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/removed.md');
|
|
|
+ await mkdir(join(destPath, '..'), { recursive: true });
|
|
|
+ await writeFile(destPath, '# Old File', 'utf8');
|
|
|
+
|
|
|
+ // Bundle does NOT contain this file (packageRoot has no .opencode/agent/removed.md)
|
|
|
+ // But we need at least one bundled file so listBundledFiles returns something
|
|
|
+ await setupPackageRoot(packageRoot, '.opencode/agent/current.md', '# Current');
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
+ await rm(packageRoot, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: file removed from bundle → removed from manifest, disk copy untouched
|
|
|
+ test('removes a file from the manifest when it is no longer in the bundle', async () => {
|
|
|
+ // Arrange
|
|
|
+ const opts = makeOptions(projectRoot, packageRoot);
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const { result, updatedManifest } = await updateFiles(opts);
|
|
|
+
|
|
|
+ // Assert — removed_from_manifest should contain the old file
|
|
|
+ expect(result.removed_from_manifest).toContain('.opencode/agent/removed.md');
|
|
|
+ expect(result.errors).toHaveLength(0);
|
|
|
+
|
|
|
+ // Manifest should no longer track the removed file
|
|
|
+ expect(updatedManifest.files['.opencode/agent/removed.md']).toBeUndefined();
|
|
|
+
|
|
|
+ // Disk copy should still exist (we leave user's copy alone)
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/removed.md');
|
|
|
+ expect(await Bun.file(destPath).exists()).toBe(true);
|
|
|
+ expect(await Bun.file(destPath).text()).toBe('# Old File');
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ── updateFiles — file deleted from disk (in manifest, not on disk) ───────────
|
|
|
+
|
|
|
+describe('updateFiles — reinstall file deleted from disk', () => {
|
|
|
+ let projectRoot: string;
|
|
|
+ let packageRoot: string;
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), 'oac-update-deleted-'));
|
|
|
+ packageRoot = await mkdtemp(join(tmpdir(), 'oac-update-deleted-pkg-'));
|
|
|
+
|
|
|
+ // Manifest tracks the file, but the file was deleted from disk
|
|
|
+ const fakeHash = 'e'.repeat(64);
|
|
|
+ let manifest = createEmptyManifest('1.0.0');
|
|
|
+ manifest = addFileToManifest(manifest, '.opencode/agent/deleted.md', makeEntry(fakeHash));
|
|
|
+ await writeManifest(projectRoot, manifest);
|
|
|
+
|
|
|
+ // File does NOT exist on disk (user deleted it)
|
|
|
+ // Bundle has the file
|
|
|
+ await setupPackageRoot(packageRoot, '.opencode/agent/deleted.md', '# Reinstalled');
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
+ await rm(packageRoot, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: file in manifest but deleted from disk → reinstalled
|
|
|
+ test('reinstalls a file that was deleted from disk (treated as new install)', async () => {
|
|
|
+ // Arrange
|
|
|
+ const opts = makeOptions(projectRoot, packageRoot);
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const { result } = await updateFiles(opts);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result.installed).toContain('.opencode/agent/deleted.md');
|
|
|
+ expect(result.skipped).toHaveLength(0);
|
|
|
+ expect(result.errors).toHaveLength(0);
|
|
|
+
|
|
|
+ // File should now exist on disk
|
|
|
+ const destPath = join(projectRoot, '.opencode/agent/deleted.md');
|
|
|
+ expect(await Bun.file(destPath).exists()).toBe(true);
|
|
|
+ expect(await Bun.file(destPath).text()).toBe('# Reinstalled');
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ── updateFiles — no manifest (first run) ────────────────────────────────────
|
|
|
+
|
|
|
+describe('updateFiles — no manifest (first run)', () => {
|
|
|
+ let projectRoot: string;
|
|
|
+ let packageRoot: string;
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ projectRoot = await mkdtemp(join(tmpdir(), 'oac-update-nomanifest-'));
|
|
|
+ packageRoot = await mkdtemp(join(tmpdir(), 'oac-update-nomanifest-pkg-'));
|
|
|
+
|
|
|
+ // Bundle has two files; no manifest exists
|
|
|
+ await setupPackageRoot(packageRoot, '.opencode/agent/a.md', '# Agent A');
|
|
|
+ await setupPackageRoot(packageRoot, '.opencode/context/b.md', '# Context B');
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(projectRoot, { recursive: true, force: true });
|
|
|
+ await rm(packageRoot, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: no manifest → all bundled files installed as new
|
|
|
+ test('installs all bundled files when no manifest exists', async () => {
|
|
|
+ // Arrange
|
|
|
+ const opts = makeOptions(projectRoot, packageRoot);
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const { result, updatedManifest } = await updateFiles(opts);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result.installed).toContain('.opencode/agent/a.md');
|
|
|
+ expect(result.installed).toContain('.opencode/context/b.md');
|
|
|
+ expect(result.installed).toHaveLength(2);
|
|
|
+ expect(result.errors).toHaveLength(0);
|
|
|
+
|
|
|
+ // Both files should be tracked in the manifest
|
|
|
+ expect(updatedManifest.files['.opencode/agent/a.md']).toBeDefined();
|
|
|
+ expect(updatedManifest.files['.opencode/context/b.md']).toBeDefined();
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ── isProjectRoot ─────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+describe('isProjectRoot', () => {
|
|
|
+ let tmpDir: string;
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ tmpDir = await mkdtemp(join(tmpdir(), 'oac-projroot-test-'));
|
|
|
+ });
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await rm(tmpDir, { recursive: true, force: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: directory with package.json is a project root
|
|
|
+ test('returns true for a directory containing package.json', async () => {
|
|
|
+ // Arrange
|
|
|
+ const dir = join(tmpDir, 'has-pkg-json');
|
|
|
+ await mkdir(dir, { recursive: true });
|
|
|
+ await writeFile(join(dir, 'package.json'), '{}', 'utf8');
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const result = await isProjectRoot(dir);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: directory with .git is a project root
|
|
|
+ test('returns true for a directory containing .git', async () => {
|
|
|
+ // Arrange
|
|
|
+ const dir = join(tmpDir, 'has-git');
|
|
|
+ await mkdir(dir, { recursive: true });
|
|
|
+ // Bun.file().exists() checks for files; .git is typically a directory.
|
|
|
+ // The implementation uses Bun.file(path.join(dir, '.git')).exists()
|
|
|
+ // which returns false for directories in Bun. Let's write a .git file
|
|
|
+ // to simulate the check (as the implementation uses Bun.file().exists()).
|
|
|
+ await writeFile(join(dir, '.git'), 'gitdir: ../.git', 'utf8');
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const result = await isProjectRoot(dir);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ // ✅ Positive: directory with both package.json and .git is a project root
|
|
|
+ test('returns true for a directory containing both package.json and .git', async () => {
|
|
|
+ // Arrange
|
|
|
+ const dir = join(tmpDir, 'has-both');
|
|
|
+ await mkdir(dir, { recursive: true });
|
|
|
+ await writeFile(join(dir, 'package.json'), '{}', 'utf8');
|
|
|
+ await writeFile(join(dir, '.git'), 'gitdir: ../.git', 'utf8');
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const result = await isProjectRoot(dir);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ // ❌ Negative: empty directory is not a project root
|
|
|
+ test('returns false for an empty directory', async () => {
|
|
|
+ // Arrange
|
|
|
+ const dir = join(tmpDir, 'empty-dir');
|
|
|
+ await mkdir(dir, { recursive: true });
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const result = await isProjectRoot(dir);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ // ❌ Negative: directory with unrelated files is not a project root
|
|
|
+ test('returns false for a directory with only unrelated files', async () => {
|
|
|
+ // Arrange
|
|
|
+ const dir = join(tmpDir, 'unrelated');
|
|
|
+ await mkdir(dir, { recursive: true });
|
|
|
+ await writeFile(join(dir, 'README.md'), '# Hello', 'utf8');
|
|
|
+ await writeFile(join(dir, 'notes.txt'), 'some notes', 'utf8');
|
|
|
+
|
|
|
+ // Act
|
|
|
+ const result = await isProjectRoot(dir);
|
|
|
+
|
|
|
+ // Assert
|
|
|
+ expect(result).toBe(false);
|
|
|
+ });
|
|
|
+});
|