mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge main into kanban-scaling
Resolves merge conflicts while preserving: - Kanban scaling improvements (window sizing, bounce prevention, debouncing) - Main's sidebar refactoring into hooks - Main's openInEditor functionality for VS Code integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ npm install @automaker/dependency-resolver
|
||||
## Exports
|
||||
|
||||
### Resolve Dependencies
|
||||
|
||||
Order features based on dependencies and priorities.
|
||||
|
||||
```typescript
|
||||
@@ -26,22 +27,22 @@ const features: Feature[] = [
|
||||
id: 'database',
|
||||
category: 'backend',
|
||||
description: 'Setup database',
|
||||
priority: 1
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
id: 'auth',
|
||||
category: 'backend',
|
||||
description: 'Add authentication',
|
||||
dependencies: ['database'],
|
||||
priority: 2
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
category: 'backend',
|
||||
description: 'Create API endpoints',
|
||||
dependencies: ['auth'],
|
||||
priority: 3
|
||||
}
|
||||
priority: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
@@ -56,6 +57,7 @@ if (result.hasCycle) {
|
||||
```
|
||||
|
||||
### Check Dependencies Satisfied
|
||||
|
||||
Check if a feature's dependencies are satisfied.
|
||||
|
||||
```typescript
|
||||
@@ -76,6 +78,7 @@ if (areDependenciesSatisfied(authFeature, allFeatures)) {
|
||||
```
|
||||
|
||||
### Get Blocking Dependencies
|
||||
|
||||
Get list of incomplete dependencies blocking a feature.
|
||||
|
||||
```typescript
|
||||
@@ -96,7 +99,7 @@ if (blocking.length > 0) {
|
||||
import {
|
||||
resolveDependencies,
|
||||
areDependenciesSatisfied,
|
||||
getBlockingDependencies
|
||||
getBlockingDependencies,
|
||||
} from '@automaker/dependency-resolver';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
@@ -130,6 +133,7 @@ async function executeFeatures(features: Feature[]) {
|
||||
## Algorithm
|
||||
|
||||
### Topological Sort (Kahn's Algorithm)
|
||||
|
||||
1. Calculate in-degree for each feature (number of dependencies)
|
||||
2. Start with features that have no dependencies (in-degree = 0)
|
||||
3. Process features in priority order
|
||||
@@ -137,11 +141,13 @@ async function executeFeatures(features: Feature[]) {
|
||||
5. Repeat until all features processed or cycle detected
|
||||
|
||||
### Priority Handling
|
||||
|
||||
- Features with lower priority numbers execute first
|
||||
- When multiple features have same in-degree, priority determines order
|
||||
- Features without explicit priority default to lowest priority
|
||||
|
||||
### Cycle Detection
|
||||
|
||||
- Detects circular dependencies
|
||||
- Returns affected features in cycle
|
||||
- Prevents infinite loops in execution
|
||||
@@ -149,23 +155,27 @@ async function executeFeatures(features: Feature[]) {
|
||||
## Return Types
|
||||
|
||||
### DependencyResolutionResult
|
||||
|
||||
```typescript
|
||||
interface DependencyResolutionResult {
|
||||
orderedFeatures: Feature[]; // Features in execution order
|
||||
hasCycle: boolean; // True if circular dependency detected
|
||||
cyclicFeatures: string[]; // Feature IDs involved in cycle
|
||||
orderedFeatures: Feature[]; // Features in execution order
|
||||
hasCycle: boolean; // True if circular dependency detected
|
||||
cyclicFeatures: string[]; // Feature IDs involved in cycle
|
||||
}
|
||||
```
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Missing Dependencies
|
||||
|
||||
Features with dependencies on non-existent features are treated as if the dependency is satisfied (allows flexibility).
|
||||
|
||||
### Self-Dependencies
|
||||
|
||||
Features depending on themselves are detected as cycles.
|
||||
|
||||
### Empty Dependencies Array
|
||||
|
||||
Treated same as no dependencies - feature is ready immediately.
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": ["automaker", "dependency", "resolver"],
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"dependency",
|
||||
"resolver"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
export interface DependencyResolutionResult {
|
||||
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||
circularDependencies: string[][]; // Groups of IDs forming cycles
|
||||
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
|
||||
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +27,7 @@ export interface DependencyResolutionResult {
|
||||
* @returns Resolution result with ordered features and dependency metadata
|
||||
*/
|
||||
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
|
||||
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
|
||||
const featureMap = new Map<string, Feature>(features.map((f) => [f.id, f]));
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
|
||||
const missingDependencies = new Map<string, string[]>();
|
||||
@@ -71,8 +71,7 @@ export function resolveDependencies(features: Feature[]): DependencyResolutionRe
|
||||
const orderedFeatures: Feature[] = [];
|
||||
|
||||
// Helper to sort features by priority (lower number = higher priority)
|
||||
const sortByPriority = (a: Feature, b: Feature) =>
|
||||
(a.priority ?? 2) - (b.priority ?? 2);
|
||||
const sortByPriority = (a: Feature, b: Feature) => (a.priority ?? 2) - (b.priority ?? 2);
|
||||
|
||||
// Start with features that have no dependencies (in-degree 0)
|
||||
for (const [id, degree] of inDegree) {
|
||||
@@ -109,11 +108,11 @@ export function resolveDependencies(features: Feature[]): DependencyResolutionRe
|
||||
|
||||
// Detect circular dependencies (features not in output = part of cycle)
|
||||
const circularDependencies: string[][] = [];
|
||||
const processedIds = new Set(orderedFeatures.map(f => f.id));
|
||||
const processedIds = new Set(orderedFeatures.map((f) => f.id));
|
||||
|
||||
if (orderedFeatures.length < features.length) {
|
||||
// Find cycles using DFS
|
||||
const remaining = features.filter(f => !processedIds.has(f.id));
|
||||
const remaining = features.filter((f) => !processedIds.has(f.id));
|
||||
const cycles = detectCycles(remaining, featureMap);
|
||||
circularDependencies.push(...cycles);
|
||||
|
||||
@@ -125,7 +124,7 @@ export function resolveDependencies(features: Feature[]): DependencyResolutionRe
|
||||
orderedFeatures,
|
||||
circularDependencies,
|
||||
missingDependencies,
|
||||
blockedFeatures
|
||||
blockedFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,10 +135,7 @@ export function resolveDependencies(features: Feature[]): DependencyResolutionRe
|
||||
* @param featureMap - Map of all features by ID
|
||||
* @returns Array of cycles, where each cycle is an array of feature IDs
|
||||
*/
|
||||
function detectCycles(
|
||||
features: Feature[],
|
||||
featureMap: Map<string, Feature>
|
||||
): string[][] {
|
||||
function detectCycles(features: Feature[], featureMap: Map<string, Feature>): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
@@ -185,16 +181,13 @@ function detectCycles(
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns true if all dependencies are satisfied, false otherwise
|
||||
*/
|
||||
export function areDependenciesSatisfied(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): boolean {
|
||||
export function areDependenciesSatisfied(feature: Feature, allFeatures: Feature[]): boolean {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return true; // No dependencies = always ready
|
||||
}
|
||||
|
||||
return feature.dependencies.every((depId: string) => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
const dep = allFeatures.find((f) => f.id === depId);
|
||||
return dep && (dep.status === 'completed' || dep.status === 'verified');
|
||||
});
|
||||
}
|
||||
@@ -206,16 +199,13 @@ export function areDependenciesSatisfied(
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns Array of feature IDs that are blocking this feature
|
||||
*/
|
||||
export function getBlockingDependencies(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): string[] {
|
||||
export function getBlockingDependencies(feature: Feature, allFeatures: Feature[]): string[] {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feature.dependencies.filter((depId: string) => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
const dep = allFeatures.find((f) => f.id === depId);
|
||||
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolveDependencies,
|
||||
areDependenciesSatisfied,
|
||||
getBlockingDependencies,
|
||||
} from "../src/resolver";
|
||||
import type { Feature } from "@automaker/types";
|
||||
} from '../src/resolver';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
// Helper to create test features
|
||||
function createFeature(
|
||||
@@ -17,22 +17,18 @@ function createFeature(
|
||||
): Feature {
|
||||
return {
|
||||
id,
|
||||
category: "test",
|
||||
category: 'test',
|
||||
description: `Feature ${id}`,
|
||||
dependencies: options.dependencies,
|
||||
status: options.status || "pending",
|
||||
status: options.status || 'pending',
|
||||
priority: options.priority,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolver.ts", () => {
|
||||
describe("resolveDependencies", () => {
|
||||
it("should handle features with no dependencies", () => {
|
||||
const features = [
|
||||
createFeature("A"),
|
||||
createFeature("B"),
|
||||
createFeature("C"),
|
||||
];
|
||||
describe('resolver.ts', () => {
|
||||
describe('resolveDependencies', () => {
|
||||
it('should handle features with no dependencies', () => {
|
||||
const features = [createFeature('A'), createFeature('B'), createFeature('C')];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
@@ -42,174 +38,169 @@ describe("resolver.ts", () => {
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should order features with linear dependencies", () => {
|
||||
it('should order features with linear dependencies', () => {
|
||||
const features = [
|
||||
createFeature("C", { dependencies: ["B"] }),
|
||||
createFeature("A"),
|
||||
createFeature("B", { dependencies: ["A"] }),
|
||||
createFeature('C', { dependencies: ['B'] }),
|
||||
createFeature('A'),
|
||||
createFeature('B', { dependencies: ['A'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
const ids = result.orderedFeatures.map(f => f.id);
|
||||
expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("B"));
|
||||
expect(ids.indexOf("B")).toBeLessThan(ids.indexOf("C"));
|
||||
const ids = result.orderedFeatures.map((f) => f.id);
|
||||
expect(ids.indexOf('A')).toBeLessThan(ids.indexOf('B'));
|
||||
expect(ids.indexOf('B')).toBeLessThan(ids.indexOf('C'));
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it("should respect priority within same dependency level", () => {
|
||||
it('should respect priority within same dependency level', () => {
|
||||
const features = [
|
||||
createFeature("Low", { priority: 3 }),
|
||||
createFeature("High", { priority: 1 }),
|
||||
createFeature("Medium", { priority: 2 }),
|
||||
createFeature('Low', { priority: 3 }),
|
||||
createFeature('High', { priority: 1 }),
|
||||
createFeature('Medium', { priority: 2 }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
const ids = result.orderedFeatures.map(f => f.id);
|
||||
expect(ids).toEqual(["High", "Medium", "Low"]);
|
||||
const ids = result.orderedFeatures.map((f) => f.id);
|
||||
expect(ids).toEqual(['High', 'Medium', 'Low']);
|
||||
});
|
||||
|
||||
it("should use default priority 2 when not specified", () => {
|
||||
it('should use default priority 2 when not specified', () => {
|
||||
const features = [
|
||||
createFeature("NoPriority"),
|
||||
createFeature("HighPriority", { priority: 1 }),
|
||||
createFeature("LowPriority", { priority: 3 }),
|
||||
createFeature('NoPriority'),
|
||||
createFeature('HighPriority', { priority: 1 }),
|
||||
createFeature('LowPriority', { priority: 3 }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
const ids = result.orderedFeatures.map(f => f.id);
|
||||
expect(ids.indexOf("HighPriority")).toBeLessThan(ids.indexOf("NoPriority"));
|
||||
expect(ids.indexOf("NoPriority")).toBeLessThan(ids.indexOf("LowPriority"));
|
||||
const ids = result.orderedFeatures.map((f) => f.id);
|
||||
expect(ids.indexOf('HighPriority')).toBeLessThan(ids.indexOf('NoPriority'));
|
||||
expect(ids.indexOf('NoPriority')).toBeLessThan(ids.indexOf('LowPriority'));
|
||||
});
|
||||
|
||||
it("should respect dependencies over priority", () => {
|
||||
it('should respect dependencies over priority', () => {
|
||||
const features = [
|
||||
createFeature("B", { dependencies: ["A"], priority: 1 }), // High priority but depends on A
|
||||
createFeature("A", { priority: 3 }), // Low priority but no dependencies
|
||||
createFeature('B', { dependencies: ['A'], priority: 1 }), // High priority but depends on A
|
||||
createFeature('A', { priority: 3 }), // Low priority but no dependencies
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
const ids = result.orderedFeatures.map(f => f.id);
|
||||
expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("B"));
|
||||
const ids = result.orderedFeatures.map((f) => f.id);
|
||||
expect(ids.indexOf('A')).toBeLessThan(ids.indexOf('B'));
|
||||
});
|
||||
|
||||
it("should detect circular dependencies (simple cycle)", () => {
|
||||
it('should detect circular dependencies (simple cycle)', () => {
|
||||
const features = [
|
||||
createFeature("A", { dependencies: ["B"] }),
|
||||
createFeature("B", { dependencies: ["A"] }),
|
||||
createFeature('A', { dependencies: ['B'] }),
|
||||
createFeature('B', { dependencies: ['A'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.circularDependencies).toHaveLength(1);
|
||||
expect(result.circularDependencies[0]).toContain("A");
|
||||
expect(result.circularDependencies[0]).toContain("B");
|
||||
expect(result.circularDependencies[0]).toContain('A');
|
||||
expect(result.circularDependencies[0]).toContain('B');
|
||||
expect(result.orderedFeatures).toHaveLength(2); // All features still included
|
||||
});
|
||||
|
||||
it("should detect circular dependencies (3-way cycle)", () => {
|
||||
it('should detect circular dependencies (3-way cycle)', () => {
|
||||
const features = [
|
||||
createFeature("A", { dependencies: ["C"] }),
|
||||
createFeature("B", { dependencies: ["A"] }),
|
||||
createFeature("C", { dependencies: ["B"] }),
|
||||
createFeature('A', { dependencies: ['C'] }),
|
||||
createFeature('B', { dependencies: ['A'] }),
|
||||
createFeature('C', { dependencies: ['B'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.circularDependencies.length).toBeGreaterThan(0);
|
||||
const allCycleIds = result.circularDependencies.flat();
|
||||
expect(allCycleIds).toContain("A");
|
||||
expect(allCycleIds).toContain("B");
|
||||
expect(allCycleIds).toContain("C");
|
||||
expect(allCycleIds).toContain('A');
|
||||
expect(allCycleIds).toContain('B');
|
||||
expect(allCycleIds).toContain('C');
|
||||
});
|
||||
|
||||
it("should detect missing dependencies", () => {
|
||||
it('should detect missing dependencies', () => {
|
||||
const features = [createFeature('A', { dependencies: ['NonExistent'] }), createFeature('B')];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.missingDependencies.has('A')).toBe(true);
|
||||
expect(result.missingDependencies.get('A')).toContain('NonExistent');
|
||||
});
|
||||
|
||||
it('should detect blocked features (incomplete dependencies)', () => {
|
||||
const features = [
|
||||
createFeature("A", { dependencies: ["NonExistent"] }),
|
||||
createFeature("B"),
|
||||
createFeature('A', { status: 'pending' }),
|
||||
createFeature('B', { dependencies: ['A'], status: 'pending' }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.missingDependencies.has("A")).toBe(true);
|
||||
expect(result.missingDependencies.get("A")).toContain("NonExistent");
|
||||
expect(result.blockedFeatures.has('B')).toBe(true);
|
||||
expect(result.blockedFeatures.get('B')).toContain('A');
|
||||
});
|
||||
|
||||
it("should detect blocked features (incomplete dependencies)", () => {
|
||||
it('should not mark features as blocked if dependencies are completed', () => {
|
||||
const features = [
|
||||
createFeature("A", { status: "pending" }),
|
||||
createFeature("B", { dependencies: ["A"], status: "pending" }),
|
||||
createFeature('A', { status: 'completed' }),
|
||||
createFeature('B', { dependencies: ['A'], status: 'pending' }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.blockedFeatures.has("B")).toBe(true);
|
||||
expect(result.blockedFeatures.get("B")).toContain("A");
|
||||
expect(result.blockedFeatures.has('B')).toBe(false);
|
||||
});
|
||||
|
||||
it("should not mark features as blocked if dependencies are completed", () => {
|
||||
it('should not mark features as blocked if dependencies are verified', () => {
|
||||
const features = [
|
||||
createFeature("A", { status: "completed" }),
|
||||
createFeature("B", { dependencies: ["A"], status: "pending" }),
|
||||
createFeature('A', { status: 'verified' }),
|
||||
createFeature('B', { dependencies: ['A'], status: 'pending' }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.blockedFeatures.has("B")).toBe(false);
|
||||
expect(result.blockedFeatures.has('B')).toBe(false);
|
||||
});
|
||||
|
||||
it("should not mark features as blocked if dependencies are verified", () => {
|
||||
it('should handle complex dependency graph', () => {
|
||||
const features = [
|
||||
createFeature("A", { status: "verified" }),
|
||||
createFeature("B", { dependencies: ["A"], status: "pending" }),
|
||||
createFeature('E', { dependencies: ['C', 'D'] }),
|
||||
createFeature('D', { dependencies: ['B'] }),
|
||||
createFeature('C', { dependencies: ['A', 'B'] }),
|
||||
createFeature('B'),
|
||||
createFeature('A'),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.blockedFeatures.has("B")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle complex dependency graph", () => {
|
||||
const features = [
|
||||
createFeature("E", { dependencies: ["C", "D"] }),
|
||||
createFeature("D", { dependencies: ["B"] }),
|
||||
createFeature("C", { dependencies: ["A", "B"] }),
|
||||
createFeature("B"),
|
||||
createFeature("A"),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
const ids = result.orderedFeatures.map(f => f.id);
|
||||
const ids = result.orderedFeatures.map((f) => f.id);
|
||||
|
||||
// A and B have no dependencies - can be first or second
|
||||
expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("C"));
|
||||
expect(ids.indexOf("B")).toBeLessThan(ids.indexOf("C"));
|
||||
expect(ids.indexOf("B")).toBeLessThan(ids.indexOf("D"));
|
||||
expect(ids.indexOf('A')).toBeLessThan(ids.indexOf('C'));
|
||||
expect(ids.indexOf('B')).toBeLessThan(ids.indexOf('C'));
|
||||
expect(ids.indexOf('B')).toBeLessThan(ids.indexOf('D'));
|
||||
|
||||
// C depends on A and B
|
||||
expect(ids.indexOf("C")).toBeLessThan(ids.indexOf("E"));
|
||||
expect(ids.indexOf('C')).toBeLessThan(ids.indexOf('E'));
|
||||
|
||||
// D depends on B
|
||||
expect(ids.indexOf("D")).toBeLessThan(ids.indexOf("E"));
|
||||
expect(ids.indexOf('D')).toBeLessThan(ids.indexOf('E'));
|
||||
|
||||
expect(result.circularDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple missing dependencies", () => {
|
||||
const features = [
|
||||
createFeature("A", { dependencies: ["X", "Y", "Z"] }),
|
||||
];
|
||||
it('should handle multiple missing dependencies', () => {
|
||||
const features = [createFeature('A', { dependencies: ['X', 'Y', 'Z'] })];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.missingDependencies.get("A")).toEqual(["X", "Y", "Z"]);
|
||||
expect(result.missingDependencies.get('A')).toEqual(['X', 'Y', 'Z']);
|
||||
});
|
||||
|
||||
it("should handle empty feature list", () => {
|
||||
it('should handle empty feature list', () => {
|
||||
const result = resolveDependencies([]);
|
||||
|
||||
expect(result.orderedFeatures).toEqual([]);
|
||||
@@ -218,143 +209,143 @@ describe("resolver.ts", () => {
|
||||
expect(result.blockedFeatures.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle features with both missing and existing dependencies", () => {
|
||||
it('should handle features with both missing and existing dependencies', () => {
|
||||
const features = [
|
||||
createFeature("A"),
|
||||
createFeature("B", { dependencies: ["A", "NonExistent"] }),
|
||||
createFeature('A'),
|
||||
createFeature('B', { dependencies: ['A', 'NonExistent'] }),
|
||||
];
|
||||
|
||||
const result = resolveDependencies(features);
|
||||
|
||||
expect(result.missingDependencies.get("B")).toContain("NonExistent");
|
||||
const ids = result.orderedFeatures.map(f => f.id);
|
||||
expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("B"));
|
||||
expect(result.missingDependencies.get('B')).toContain('NonExistent');
|
||||
const ids = result.orderedFeatures.map((f) => f.id);
|
||||
expect(ids.indexOf('A')).toBeLessThan(ids.indexOf('B'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("areDependenciesSatisfied", () => {
|
||||
it("should return true for feature with no dependencies", () => {
|
||||
const feature = createFeature("A");
|
||||
describe('areDependenciesSatisfied', () => {
|
||||
it('should return true for feature with no dependencies', () => {
|
||||
const feature = createFeature('A');
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for feature with empty dependencies array", () => {
|
||||
const feature = createFeature("A", { dependencies: [] });
|
||||
it('should return true for feature with empty dependencies array', () => {
|
||||
const feature = createFeature('A', { dependencies: [] });
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when all dependencies are completed", () => {
|
||||
const dep = createFeature("Dep", { status: "completed" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep"] });
|
||||
it('should return true when all dependencies are completed', () => {
|
||||
const dep = createFeature('Dep', { status: 'completed' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep'] });
|
||||
const allFeatures = [dep, feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when all dependencies are verified", () => {
|
||||
const dep = createFeature("Dep", { status: "verified" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep"] });
|
||||
it('should return true when all dependencies are verified', () => {
|
||||
const dep = createFeature('Dep', { status: 'verified' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep'] });
|
||||
const allFeatures = [dep, feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when any dependency is pending", () => {
|
||||
const dep = createFeature("Dep", { status: "pending" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep"] });
|
||||
it('should return false when any dependency is pending', () => {
|
||||
const dep = createFeature('Dep', { status: 'pending' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep'] });
|
||||
const allFeatures = [dep, feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when any dependency is running", () => {
|
||||
const dep = createFeature("Dep", { status: "running" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep"] });
|
||||
it('should return false when any dependency is running', () => {
|
||||
const dep = createFeature('Dep', { status: 'running' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep'] });
|
||||
const allFeatures = [dep, feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when dependency is missing", () => {
|
||||
const feature = createFeature("A", { dependencies: ["NonExistent"] });
|
||||
it('should return false when dependency is missing', () => {
|
||||
const feature = createFeature('A', { dependencies: ['NonExistent'] });
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false);
|
||||
});
|
||||
|
||||
it("should check all dependencies", () => {
|
||||
const dep1 = createFeature("Dep1", { status: "completed" });
|
||||
const dep2 = createFeature("Dep2", { status: "pending" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep1", "Dep2"] });
|
||||
it('should check all dependencies', () => {
|
||||
const dep1 = createFeature('Dep1', { status: 'completed' });
|
||||
const dep2 = createFeature('Dep2', { status: 'pending' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep1', 'Dep2'] });
|
||||
const allFeatures = [dep1, dep2, feature];
|
||||
|
||||
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBlockingDependencies", () => {
|
||||
it("should return empty array for feature with no dependencies", () => {
|
||||
const feature = createFeature("A");
|
||||
describe('getBlockingDependencies', () => {
|
||||
it('should return empty array for feature with no dependencies', () => {
|
||||
const feature = createFeature('A');
|
||||
const allFeatures = [feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when all dependencies are completed", () => {
|
||||
const dep = createFeature("Dep", { status: "completed" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep"] });
|
||||
it('should return empty array when all dependencies are completed', () => {
|
||||
const dep = createFeature('Dep', { status: 'completed' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep'] });
|
||||
const allFeatures = [dep, feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when all dependencies are verified", () => {
|
||||
const dep = createFeature("Dep", { status: "verified" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep"] });
|
||||
it('should return empty array when all dependencies are verified', () => {
|
||||
const dep = createFeature('Dep', { status: 'verified' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep'] });
|
||||
const allFeatures = [dep, feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return pending dependencies", () => {
|
||||
const dep = createFeature("Dep", { status: "pending" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep"] });
|
||||
it('should return pending dependencies', () => {
|
||||
const dep = createFeature('Dep', { status: 'pending' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep'] });
|
||||
const allFeatures = [dep, feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual(["Dep"]);
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual(['Dep']);
|
||||
});
|
||||
|
||||
it("should return running dependencies", () => {
|
||||
const dep = createFeature("Dep", { status: "running" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep"] });
|
||||
it('should return running dependencies', () => {
|
||||
const dep = createFeature('Dep', { status: 'running' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep'] });
|
||||
const allFeatures = [dep, feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual(["Dep"]);
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual(['Dep']);
|
||||
});
|
||||
|
||||
it("should return failed dependencies", () => {
|
||||
const dep = createFeature("Dep", { status: "failed" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep"] });
|
||||
it('should return failed dependencies', () => {
|
||||
const dep = createFeature('Dep', { status: 'failed' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep'] });
|
||||
const allFeatures = [dep, feature];
|
||||
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual(["Dep"]);
|
||||
expect(getBlockingDependencies(feature, allFeatures)).toEqual(['Dep']);
|
||||
});
|
||||
|
||||
it("should return all incomplete dependencies", () => {
|
||||
const dep1 = createFeature("Dep1", { status: "pending" });
|
||||
const dep2 = createFeature("Dep2", { status: "completed" });
|
||||
const dep3 = createFeature("Dep3", { status: "running" });
|
||||
const feature = createFeature("A", { dependencies: ["Dep1", "Dep2", "Dep3"] });
|
||||
it('should return all incomplete dependencies', () => {
|
||||
const dep1 = createFeature('Dep1', { status: 'pending' });
|
||||
const dep2 = createFeature('Dep2', { status: 'completed' });
|
||||
const dep3 = createFeature('Dep3', { status: 'running' });
|
||||
const feature = createFeature('A', { dependencies: ['Dep1', 'Dep2', 'Dep3'] });
|
||||
const allFeatures = [dep1, dep2, dep3, feature];
|
||||
|
||||
const blocking = getBlockingDependencies(feature, allFeatures);
|
||||
expect(blocking).toContain("Dep1");
|
||||
expect(blocking).toContain("Dep3");
|
||||
expect(blocking).not.toContain("Dep2");
|
||||
expect(blocking).toContain('Dep1');
|
||||
expect(blocking).toContain('Dep3');
|
||||
expect(blocking).not.toContain('Dep2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.d.ts", "src/index.ts"],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', 'src/index.ts'],
|
||||
thresholds: {
|
||||
lines: 90,
|
||||
functions: 100,
|
||||
|
||||
@@ -15,6 +15,7 @@ npm install @automaker/git-utils
|
||||
## Exports
|
||||
|
||||
### Repository Detection
|
||||
|
||||
Check if a path is a git repository.
|
||||
|
||||
```typescript
|
||||
@@ -27,6 +28,7 @@ if (isRepo) {
|
||||
```
|
||||
|
||||
### Status Parsing
|
||||
|
||||
Parse git status output into structured data.
|
||||
|
||||
```typescript
|
||||
@@ -36,7 +38,7 @@ import type { FileStatus } from '@automaker/git-utils';
|
||||
const statusOutput = await execAsync('git status --porcelain');
|
||||
const files: FileStatus[] = parseGitStatus(statusOutput.stdout);
|
||||
|
||||
files.forEach(file => {
|
||||
files.forEach((file) => {
|
||||
console.log(`${file.statusText}: ${file.path}`);
|
||||
// Example: "Modified: src/index.ts"
|
||||
// Example: "Untracked: new-file.ts"
|
||||
@@ -44,58 +46,56 @@ files.forEach(file => {
|
||||
```
|
||||
|
||||
### Diff Generation
|
||||
|
||||
Generate diffs including untracked files.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
generateSyntheticDiffForNewFile,
|
||||
appendUntrackedFileDiffs,
|
||||
getGitRepositoryDiffs
|
||||
getGitRepositoryDiffs,
|
||||
} from '@automaker/git-utils';
|
||||
|
||||
// Generate diff for single untracked file
|
||||
const diff = await generateSyntheticDiffForNewFile(
|
||||
'/project/path',
|
||||
'src/new-file.ts'
|
||||
);
|
||||
const diff = await generateSyntheticDiffForNewFile('/project/path', 'src/new-file.ts');
|
||||
|
||||
// Get complete repository diffs (tracked + untracked)
|
||||
const result = await getGitRepositoryDiffs('/project/path');
|
||||
console.log(result.diff); // Combined diff string
|
||||
console.log(result.files); // Array of FileStatus
|
||||
console.log(result.hasChanges); // Boolean
|
||||
console.log(result.diff); // Combined diff string
|
||||
console.log(result.files); // Array of FileStatus
|
||||
console.log(result.hasChanges); // Boolean
|
||||
```
|
||||
|
||||
### Non-Git Directory Support
|
||||
|
||||
Handle non-git directories by treating all files as new.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
listAllFilesInDirectory,
|
||||
generateDiffsForNonGitDirectory
|
||||
} from '@automaker/git-utils';
|
||||
import { listAllFilesInDirectory, generateDiffsForNonGitDirectory } from '@automaker/git-utils';
|
||||
|
||||
// List all files (excluding build artifacts)
|
||||
const files = await listAllFilesInDirectory('/project/path');
|
||||
|
||||
// Generate diffs for non-git directory
|
||||
const result = await generateDiffsForNonGitDirectory('/project/path');
|
||||
console.log(result.diff); // Synthetic diffs for all files
|
||||
console.log(result.files); // All files as "New" status
|
||||
console.log(result.diff); // Synthetic diffs for all files
|
||||
console.log(result.files); // All files as "New" status
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### FileStatus
|
||||
|
||||
```typescript
|
||||
interface FileStatus {
|
||||
status: string; // Git status code (M/A/D/R/C/U/?/!)
|
||||
path: string; // File path relative to repo root
|
||||
statusText: string; // Human-readable status
|
||||
status: string; // Git status code (M/A/D/R/C/U/?/!)
|
||||
path: string; // File path relative to repo root
|
||||
statusText: string; // Human-readable status
|
||||
}
|
||||
```
|
||||
|
||||
### Status Codes
|
||||
|
||||
- `M` - Modified
|
||||
- `A` - Added
|
||||
- `D` - Deleted
|
||||
@@ -107,6 +107,7 @@ interface FileStatus {
|
||||
- ` ` - Unmodified
|
||||
|
||||
### Status Text Examples
|
||||
|
||||
- `"Modified"` - File has changes
|
||||
- `"Added"` - New file in staging
|
||||
- `"Deleted"` - File removed
|
||||
@@ -117,11 +118,7 @@ interface FileStatus {
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
isGitRepo,
|
||||
getGitRepositoryDiffs,
|
||||
parseGitStatus
|
||||
} from '@automaker/git-utils';
|
||||
import { isGitRepo, getGitRepositoryDiffs, parseGitStatus } from '@automaker/git-utils';
|
||||
|
||||
async function getProjectChanges(projectPath: string) {
|
||||
const isRepo = await isGitRepo(projectPath);
|
||||
@@ -140,15 +137,18 @@ async function getProjectChanges(projectPath: string) {
|
||||
console.log(`Found ${result.files.length} changed files:\n`);
|
||||
|
||||
// Group by status
|
||||
const byStatus = result.files.reduce((acc, file) => {
|
||||
acc[file.statusText] = acc[file.statusText] || [];
|
||||
acc[file.statusText].push(file.path);
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
const byStatus = result.files.reduce(
|
||||
(acc, file) => {
|
||||
acc[file.statusText] = acc[file.statusText] || [];
|
||||
acc[file.statusText].push(file.path);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string[]>
|
||||
);
|
||||
|
||||
Object.entries(byStatus).forEach(([status, paths]) => {
|
||||
console.log(`${status}:`);
|
||||
paths.forEach(path => console.log(` - ${path}`));
|
||||
paths.forEach((path) => console.log(` - ${path}`));
|
||||
});
|
||||
|
||||
return result.diff;
|
||||
@@ -158,9 +158,11 @@ async function getProjectChanges(projectPath: string) {
|
||||
## Features
|
||||
|
||||
### Binary File Detection
|
||||
|
||||
Automatically detects binary files by extension and generates appropriate diff markers.
|
||||
|
||||
**Supported binary extensions:**
|
||||
|
||||
- Images: `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, etc.
|
||||
- Documents: `.pdf`, `.doc`, `.docx`, etc.
|
||||
- Archives: `.zip`, `.tar`, `.gz`, etc.
|
||||
@@ -168,10 +170,13 @@ Automatically detects binary files by extension and generates appropriate diff m
|
||||
- Fonts: `.ttf`, `.otf`, `.woff`, etc.
|
||||
|
||||
### Large File Handling
|
||||
|
||||
Files larger than 1MB show size information instead of full content.
|
||||
|
||||
### Synthetic Diff Format
|
||||
|
||||
Generates unified diff format for untracked files:
|
||||
|
||||
```diff
|
||||
diff --git a/new-file.ts b/new-file.ts
|
||||
new file mode 100644
|
||||
@@ -185,7 +190,9 @@ index 0000000..0000000
|
||||
```
|
||||
|
||||
### Directory Filtering
|
||||
|
||||
When scanning non-git directories, automatically excludes:
|
||||
|
||||
- `node_modules`, `.git`, `.automaker`
|
||||
- Build outputs: `dist`, `build`, `out`, `tmp`, `.tmp`
|
||||
- Framework caches: `.next`, `.nuxt`, `.cache`, `coverage`
|
||||
@@ -198,12 +205,14 @@ Git operations can fail for various reasons. This package provides graceful erro
|
||||
### Common Error Scenarios
|
||||
|
||||
**1. Repository Not Found**
|
||||
|
||||
```typescript
|
||||
const isRepo = await isGitRepo('/path/does/not/exist');
|
||||
// Returns: false (no exception thrown)
|
||||
```
|
||||
|
||||
**2. Not a Git Repository**
|
||||
|
||||
```typescript
|
||||
const result = await getGitRepositoryDiffs('/not/a/git/repo');
|
||||
// Fallback behavior: treats all files as "new"
|
||||
@@ -211,6 +220,7 @@ const result = await getGitRepositoryDiffs('/not/a/git/repo');
|
||||
```
|
||||
|
||||
**3. Git Command Failures**
|
||||
|
||||
```typescript
|
||||
// Permission errors, corrupted repos, or git not installed
|
||||
try {
|
||||
@@ -223,6 +233,7 @@ try {
|
||||
```
|
||||
|
||||
**4. File Read Errors**
|
||||
|
||||
```typescript
|
||||
// When generating synthetic diffs for inaccessible files
|
||||
const diff = await generateSyntheticDiffForNewFile('/path', 'locked-file.txt');
|
||||
@@ -233,6 +244,7 @@ const diff = await generateSyntheticDiffForNewFile('/path', 'locked-file.txt');
|
||||
### Best Practices
|
||||
|
||||
1. **Check repository status first**:
|
||||
|
||||
```typescript
|
||||
const isRepo = await isGitRepo(path);
|
||||
if (!isRepo) {
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": ["automaker", "git", "utils"],
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"git",
|
||||
"utils"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,17 +4,10 @@
|
||||
*/
|
||||
|
||||
// Export types and constants
|
||||
export {
|
||||
BINARY_EXTENSIONS,
|
||||
GIT_STATUS_MAP,
|
||||
type FileStatus,
|
||||
} from './types.js';
|
||||
export { BINARY_EXTENSIONS, GIT_STATUS_MAP, type FileStatus } from './types.js';
|
||||
|
||||
// Export status utilities
|
||||
export {
|
||||
isGitRepo,
|
||||
parseGitStatus,
|
||||
} from './status.js';
|
||||
export { isGitRepo, parseGitStatus } from './status.js';
|
||||
|
||||
// Export diff utilities
|
||||
export {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Git status parsing utilities
|
||||
*/
|
||||
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { GIT_STATUS_MAP, type FileStatus } from './types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -14,30 +14,35 @@ const execAsync = promisify(exec);
|
||||
*/
|
||||
function getStatusText(indexStatus: string, workTreeStatus: string): string {
|
||||
// Untracked files
|
||||
if (indexStatus === "?" && workTreeStatus === "?") {
|
||||
return "Untracked";
|
||||
if (indexStatus === '?' && workTreeStatus === '?') {
|
||||
return 'Untracked';
|
||||
}
|
||||
|
||||
// Ignored files
|
||||
if (indexStatus === "!" && workTreeStatus === "!") {
|
||||
return "Ignored";
|
||||
if (indexStatus === '!' && workTreeStatus === '!') {
|
||||
return 'Ignored';
|
||||
}
|
||||
|
||||
// Prioritize staging area status, then working tree
|
||||
const primaryStatus = indexStatus !== " " && indexStatus !== "?" ? indexStatus : workTreeStatus;
|
||||
const primaryStatus = indexStatus !== ' ' && indexStatus !== '?' ? indexStatus : workTreeStatus;
|
||||
|
||||
// Handle combined statuses
|
||||
if (indexStatus !== " " && indexStatus !== "?" && workTreeStatus !== " " && workTreeStatus !== "?") {
|
||||
if (
|
||||
indexStatus !== ' ' &&
|
||||
indexStatus !== '?' &&
|
||||
workTreeStatus !== ' ' &&
|
||||
workTreeStatus !== '?'
|
||||
) {
|
||||
// Both staging and working tree have changes
|
||||
const indexText = GIT_STATUS_MAP[indexStatus] || "Changed";
|
||||
const workText = GIT_STATUS_MAP[workTreeStatus] || "Changed";
|
||||
const indexText = GIT_STATUS_MAP[indexStatus] || 'Changed';
|
||||
const workText = GIT_STATUS_MAP[workTreeStatus] || 'Changed';
|
||||
if (indexText === workText) {
|
||||
return indexText;
|
||||
}
|
||||
return `${indexText} (staged), ${workText} (unstaged)`;
|
||||
}
|
||||
|
||||
return GIT_STATUS_MAP[primaryStatus] || "Changed";
|
||||
return GIT_STATUS_MAP[primaryStatus] || 'Changed';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +50,7 @@ function getStatusText(indexStatus: string, workTreeStatus: string): string {
|
||||
*/
|
||||
export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
|
||||
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -59,21 +64,21 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
*/
|
||||
export function parseGitStatus(statusOutput: string): FileStatus[] {
|
||||
return statusOutput
|
||||
.split("\n")
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
// Git porcelain format uses two status characters: XY
|
||||
// X = status in staging area (index)
|
||||
// Y = status in working tree
|
||||
const indexStatus = line[0] || " ";
|
||||
const workTreeStatus = line[1] || " ";
|
||||
const indexStatus = line[0] || ' ';
|
||||
const workTreeStatus = line[1] || ' ';
|
||||
|
||||
// File path starts at position 3 (after "XY ")
|
||||
let filePath = line.slice(3);
|
||||
|
||||
// Handle renamed files (format: "R old_path -> new_path")
|
||||
if (indexStatus === "R" || workTreeStatus === "R") {
|
||||
const arrowIndex = filePath.indexOf(" -> ");
|
||||
if (indexStatus === 'R' || workTreeStatus === 'R') {
|
||||
const arrowIndex = filePath.indexOf(' -> ');
|
||||
if (arrowIndex !== -1) {
|
||||
filePath = filePath.slice(arrowIndex + 4); // Use new path
|
||||
}
|
||||
@@ -82,9 +87,9 @@ export function parseGitStatus(statusOutput: string): FileStatus[] {
|
||||
// Determine the primary status character for backwards compatibility
|
||||
// Prioritize staging area status, then working tree
|
||||
let primaryStatus: string;
|
||||
if (indexStatus === "?" && workTreeStatus === "?") {
|
||||
primaryStatus = "?"; // Untracked
|
||||
} else if (indexStatus !== " " && indexStatus !== "?") {
|
||||
if (indexStatus === '?' && workTreeStatus === '?') {
|
||||
primaryStatus = '?'; // Untracked
|
||||
} else if (indexStatus !== ' ' && indexStatus !== '?') {
|
||||
primaryStatus = indexStatus; // Staged change
|
||||
} else {
|
||||
primaryStatus = workTreeStatus; // Working tree change
|
||||
|
||||
@@ -4,28 +4,63 @@
|
||||
|
||||
// Binary file extensions to skip
|
||||
export const BINARY_EXTENSIONS = new Set([
|
||||
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
".zip", ".tar", ".gz", ".rar", ".7z",
|
||||
".exe", ".dll", ".so", ".dylib",
|
||||
".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
|
||||
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
||||
".db", ".sqlite", ".sqlite3",
|
||||
".pyc", ".pyo", ".class", ".o", ".obj",
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.ico',
|
||||
'.webp',
|
||||
'.svg',
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
'.zip',
|
||||
'.tar',
|
||||
'.gz',
|
||||
'.rar',
|
||||
'.7z',
|
||||
'.exe',
|
||||
'.dll',
|
||||
'.so',
|
||||
'.dylib',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.wav',
|
||||
'.avi',
|
||||
'.mov',
|
||||
'.mkv',
|
||||
'.ttf',
|
||||
'.otf',
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.eot',
|
||||
'.db',
|
||||
'.sqlite',
|
||||
'.sqlite3',
|
||||
'.pyc',
|
||||
'.pyo',
|
||||
'.class',
|
||||
'.o',
|
||||
'.obj',
|
||||
]);
|
||||
|
||||
// Status map for git status codes
|
||||
// Git porcelain format uses XY where X=staging area, Y=working tree
|
||||
export const GIT_STATUS_MAP: Record<string, string> = {
|
||||
M: "Modified",
|
||||
A: "Added",
|
||||
D: "Deleted",
|
||||
R: "Renamed",
|
||||
C: "Copied",
|
||||
U: "Updated",
|
||||
"?": "Untracked",
|
||||
"!": "Ignored",
|
||||
" ": "Unmodified",
|
||||
M: 'Modified',
|
||||
A: 'Added',
|
||||
D: 'Deleted',
|
||||
R: 'Renamed',
|
||||
C: 'Copied',
|
||||
U: 'Updated',
|
||||
'?': 'Untracked',
|
||||
'!': 'Ignored',
|
||||
' ': 'Unmodified',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
generateSyntheticDiffForNewFile,
|
||||
appendUntrackedFileDiffs,
|
||||
listAllFilesInDirectory,
|
||||
generateDiffsForNonGitDirectory,
|
||||
getGitRepositoryDiffs,
|
||||
} from "../src/diff";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
} from '../src/diff';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
describe("diff.ts", () => {
|
||||
describe('diff.ts', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for each test
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "git-utils-test-"));
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-utils-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -27,104 +27,104 @@ describe("diff.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("generateSyntheticDiffForNewFile", () => {
|
||||
it("should generate diff for binary file", async () => {
|
||||
const fileName = "test.png";
|
||||
describe('generateSyntheticDiffForNewFile', () => {
|
||||
it('should generate diff for binary file', async () => {
|
||||
const fileName = 'test.png';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
await fs.writeFile(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain("new file mode 100644");
|
||||
expect(diff).toContain('new file mode 100644');
|
||||
expect(diff).toContain(`Binary file ${fileName} added`);
|
||||
});
|
||||
|
||||
it("should generate diff for large text file", async () => {
|
||||
const fileName = "large.txt";
|
||||
it('should generate diff for large text file', async () => {
|
||||
const fileName = 'large.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
// Create a file > 1MB
|
||||
const largeContent = "x".repeat(1024 * 1024 + 100);
|
||||
const largeContent = 'x'.repeat(1024 * 1024 + 100);
|
||||
await fs.writeFile(filePath, largeContent);
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain("[File too large to display:");
|
||||
expect(diff).toContain('[File too large to display:');
|
||||
expect(diff).toMatch(/\d+KB\]/);
|
||||
});
|
||||
|
||||
it("should generate diff for small text file with trailing newline", async () => {
|
||||
const fileName = "test.txt";
|
||||
it('should generate diff for small text file with trailing newline', async () => {
|
||||
const fileName = 'test.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
const content = "line 1\nline 2\nline 3\n";
|
||||
const content = 'line 1\nline 2\nline 3\n';
|
||||
await fs.writeFile(filePath, content);
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain("new file mode 100644");
|
||||
expect(diff).toContain("--- /dev/null");
|
||||
expect(diff).toContain('new file mode 100644');
|
||||
expect(diff).toContain('--- /dev/null');
|
||||
expect(diff).toContain(`+++ b/${fileName}`);
|
||||
expect(diff).toContain("@@ -0,0 +1,3 @@");
|
||||
expect(diff).toContain("+line 1");
|
||||
expect(diff).toContain("+line 2");
|
||||
expect(diff).toContain("+line 3");
|
||||
expect(diff).not.toContain("\\ No newline at end of file");
|
||||
expect(diff).toContain('@@ -0,0 +1,3 @@');
|
||||
expect(diff).toContain('+line 1');
|
||||
expect(diff).toContain('+line 2');
|
||||
expect(diff).toContain('+line 3');
|
||||
expect(diff).not.toContain('\\ No newline at end of file');
|
||||
});
|
||||
|
||||
it("should generate diff for text file without trailing newline", async () => {
|
||||
const fileName = "no-newline.txt";
|
||||
it('should generate diff for text file without trailing newline', async () => {
|
||||
const fileName = 'no-newline.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
const content = "line 1\nline 2";
|
||||
const content = 'line 1\nline 2';
|
||||
await fs.writeFile(filePath, content);
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain("+line 1");
|
||||
expect(diff).toContain("+line 2");
|
||||
expect(diff).toContain("\\ No newline at end of file");
|
||||
expect(diff).toContain('+line 1');
|
||||
expect(diff).toContain('+line 2');
|
||||
expect(diff).toContain('\\ No newline at end of file');
|
||||
});
|
||||
|
||||
it("should generate diff for empty file", async () => {
|
||||
const fileName = "empty.txt";
|
||||
it('should generate diff for empty file', async () => {
|
||||
const fileName = 'empty.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
await fs.writeFile(filePath, "");
|
||||
await fs.writeFile(filePath, '');
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain("@@ -0,0 +1,0 @@");
|
||||
expect(diff).toContain('@@ -0,0 +1,0 @@');
|
||||
});
|
||||
|
||||
it("should generate diff for single line file", async () => {
|
||||
const fileName = "single.txt";
|
||||
it('should generate diff for single line file', async () => {
|
||||
const fileName = 'single.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
await fs.writeFile(filePath, "single line\n");
|
||||
await fs.writeFile(filePath, 'single line\n');
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain("@@ -0,0 +1,1 @@");
|
||||
expect(diff).toContain("+single line");
|
||||
expect(diff).toContain('@@ -0,0 +1,1 @@');
|
||||
expect(diff).toContain('+single line');
|
||||
});
|
||||
|
||||
it("should handle file not found error", async () => {
|
||||
const fileName = "nonexistent.txt";
|
||||
it('should handle file not found error', async () => {
|
||||
const fileName = 'nonexistent.txt';
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain("[Unable to read file content]");
|
||||
expect(diff).toContain('[Unable to read file content]');
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendUntrackedFileDiffs", () => {
|
||||
it("should return existing diff when no untracked files", async () => {
|
||||
const existingDiff = "diff --git a/test.txt b/test.txt\n";
|
||||
describe('appendUntrackedFileDiffs', () => {
|
||||
it('should return existing diff when no untracked files', async () => {
|
||||
const existingDiff = 'diff --git a/test.txt b/test.txt\n';
|
||||
const files = [
|
||||
{ status: "M", path: "test.txt" },
|
||||
{ status: "A", path: "new.txt" },
|
||||
{ status: 'M', path: 'test.txt' },
|
||||
{ status: 'A', path: 'new.txt' },
|
||||
];
|
||||
|
||||
const result = await appendUntrackedFileDiffs(tempDir, existingDiff, files);
|
||||
@@ -132,175 +132,175 @@ describe("diff.ts", () => {
|
||||
expect(result).toBe(existingDiff);
|
||||
});
|
||||
|
||||
it("should append synthetic diffs for untracked files", async () => {
|
||||
const existingDiff = "existing diff\n";
|
||||
const untrackedFile = "untracked.txt";
|
||||
it('should append synthetic diffs for untracked files', async () => {
|
||||
const existingDiff = 'existing diff\n';
|
||||
const untrackedFile = 'untracked.txt';
|
||||
const filePath = path.join(tempDir, untrackedFile);
|
||||
await fs.writeFile(filePath, "content\n");
|
||||
await fs.writeFile(filePath, 'content\n');
|
||||
|
||||
const files = [
|
||||
{ status: "M", path: "modified.txt" },
|
||||
{ status: "?", path: untrackedFile },
|
||||
{ status: 'M', path: 'modified.txt' },
|
||||
{ status: '?', path: untrackedFile },
|
||||
];
|
||||
|
||||
const result = await appendUntrackedFileDiffs(tempDir, existingDiff, files);
|
||||
|
||||
expect(result).toContain("existing diff");
|
||||
expect(result).toContain('existing diff');
|
||||
expect(result).toContain(`diff --git a/${untrackedFile} b/${untrackedFile}`);
|
||||
expect(result).toContain("+content");
|
||||
expect(result).toContain('+content');
|
||||
});
|
||||
|
||||
it("should handle multiple untracked files", async () => {
|
||||
const file1 = "file1.txt";
|
||||
const file2 = "file2.txt";
|
||||
await fs.writeFile(path.join(tempDir, file1), "file1\n");
|
||||
await fs.writeFile(path.join(tempDir, file2), "file2\n");
|
||||
it('should handle multiple untracked files', async () => {
|
||||
const file1 = 'file1.txt';
|
||||
const file2 = 'file2.txt';
|
||||
await fs.writeFile(path.join(tempDir, file1), 'file1\n');
|
||||
await fs.writeFile(path.join(tempDir, file2), 'file2\n');
|
||||
|
||||
const files = [
|
||||
{ status: "?", path: file1 },
|
||||
{ status: "?", path: file2 },
|
||||
{ status: '?', path: file1 },
|
||||
{ status: '?', path: file2 },
|
||||
];
|
||||
|
||||
const result = await appendUntrackedFileDiffs(tempDir, "", files);
|
||||
const result = await appendUntrackedFileDiffs(tempDir, '', files);
|
||||
|
||||
expect(result).toContain(`diff --git a/${file1} b/${file1}`);
|
||||
expect(result).toContain(`diff --git a/${file2} b/${file2}`);
|
||||
expect(result).toContain("+file1");
|
||||
expect(result).toContain("+file2");
|
||||
expect(result).toContain('+file1');
|
||||
expect(result).toContain('+file2');
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAllFilesInDirectory", () => {
|
||||
it("should list files in empty directory", async () => {
|
||||
describe('listAllFilesInDirectory', () => {
|
||||
it('should list files in empty directory', async () => {
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it("should list files in flat directory", async () => {
|
||||
await fs.writeFile(path.join(tempDir, "file1.txt"), "content");
|
||||
await fs.writeFile(path.join(tempDir, "file2.js"), "code");
|
||||
it('should list files in flat directory', async () => {
|
||||
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'content');
|
||||
await fs.writeFile(path.join(tempDir, 'file2.js'), 'code');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files).toContain("file1.txt");
|
||||
expect(files).toContain("file2.js");
|
||||
expect(files).toContain('file1.txt');
|
||||
expect(files).toContain('file2.js');
|
||||
});
|
||||
|
||||
it("should list files in nested directories", async () => {
|
||||
await fs.mkdir(path.join(tempDir, "subdir"));
|
||||
await fs.writeFile(path.join(tempDir, "root.txt"), "");
|
||||
await fs.writeFile(path.join(tempDir, "subdir", "nested.txt"), "");
|
||||
it('should list files in nested directories', async () => {
|
||||
await fs.mkdir(path.join(tempDir, 'subdir'));
|
||||
await fs.writeFile(path.join(tempDir, 'root.txt'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'subdir', 'nested.txt'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files).toContain("root.txt");
|
||||
expect(files).toContain("subdir/nested.txt");
|
||||
expect(files).toContain('root.txt');
|
||||
expect(files).toContain('subdir/nested.txt');
|
||||
});
|
||||
|
||||
it("should skip node_modules directory", async () => {
|
||||
await fs.mkdir(path.join(tempDir, "node_modules"));
|
||||
await fs.writeFile(path.join(tempDir, "app.js"), "");
|
||||
await fs.writeFile(path.join(tempDir, "node_modules", "package.js"), "");
|
||||
it('should skip node_modules directory', async () => {
|
||||
await fs.mkdir(path.join(tempDir, 'node_modules'));
|
||||
await fs.writeFile(path.join(tempDir, 'app.js'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'node_modules', 'package.js'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files).toContain("app.js");
|
||||
expect(files).not.toContain("node_modules/package.js");
|
||||
expect(files).toContain('app.js');
|
||||
expect(files).not.toContain('node_modules/package.js');
|
||||
});
|
||||
|
||||
it("should skip common build directories", async () => {
|
||||
await fs.mkdir(path.join(tempDir, "dist"));
|
||||
await fs.mkdir(path.join(tempDir, "build"));
|
||||
await fs.mkdir(path.join(tempDir, ".next"));
|
||||
await fs.writeFile(path.join(tempDir, "source.ts"), "");
|
||||
await fs.writeFile(path.join(tempDir, "dist", "output.js"), "");
|
||||
await fs.writeFile(path.join(tempDir, "build", "output.js"), "");
|
||||
it('should skip common build directories', async () => {
|
||||
await fs.mkdir(path.join(tempDir, 'dist'));
|
||||
await fs.mkdir(path.join(tempDir, 'build'));
|
||||
await fs.mkdir(path.join(tempDir, '.next'));
|
||||
await fs.writeFile(path.join(tempDir, 'source.ts'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'dist', 'output.js'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'build', 'output.js'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files).toContain("source.ts");
|
||||
expect(files).toContain('source.ts');
|
||||
});
|
||||
|
||||
it("should skip hidden files except .env", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".hidden"), "");
|
||||
await fs.writeFile(path.join(tempDir, ".env"), "");
|
||||
await fs.writeFile(path.join(tempDir, "visible.txt"), "");
|
||||
it('should skip hidden files except .env', async () => {
|
||||
await fs.writeFile(path.join(tempDir, '.hidden'), '');
|
||||
await fs.writeFile(path.join(tempDir, '.env'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'visible.txt'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files).toContain(".env");
|
||||
expect(files).toContain("visible.txt");
|
||||
expect(files).not.toContain(".hidden");
|
||||
expect(files).toContain('.env');
|
||||
expect(files).toContain('visible.txt');
|
||||
expect(files).not.toContain('.hidden');
|
||||
});
|
||||
|
||||
it("should skip .git directory", async () => {
|
||||
await fs.mkdir(path.join(tempDir, ".git"));
|
||||
await fs.writeFile(path.join(tempDir, ".git", "config"), "");
|
||||
await fs.writeFile(path.join(tempDir, "README.md"), "");
|
||||
it('should skip .git directory', async () => {
|
||||
await fs.mkdir(path.join(tempDir, '.git'));
|
||||
await fs.writeFile(path.join(tempDir, '.git', 'config'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'README.md'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files).toContain("README.md");
|
||||
expect(files).toContain('README.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateDiffsForNonGitDirectory", () => {
|
||||
it("should generate diffs for all files in directory", async () => {
|
||||
await fs.writeFile(path.join(tempDir, "file1.txt"), "content1\n");
|
||||
await fs.writeFile(path.join(tempDir, "file2.js"), "console.log('hi');\n");
|
||||
describe('generateDiffsForNonGitDirectory', () => {
|
||||
it('should generate diffs for all files in directory', async () => {
|
||||
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'content1\n');
|
||||
await fs.writeFile(path.join(tempDir, 'file2.js'), "console.log('hi');\n");
|
||||
|
||||
const result = await generateDiffsForNonGitDirectory(tempDir);
|
||||
|
||||
expect(result.files).toHaveLength(2);
|
||||
expect(result.files.every(f => f.status === "?")).toBe(true);
|
||||
expect(result.diff).toContain("diff --git a/file1.txt b/file1.txt");
|
||||
expect(result.diff).toContain("diff --git a/file2.js b/file2.js");
|
||||
expect(result.diff).toContain("+content1");
|
||||
expect(result.files.every((f) => f.status === '?')).toBe(true);
|
||||
expect(result.diff).toContain('diff --git a/file1.txt b/file1.txt');
|
||||
expect(result.diff).toContain('diff --git a/file2.js b/file2.js');
|
||||
expect(result.diff).toContain('+content1');
|
||||
expect(result.diff).toContain("+console.log('hi');");
|
||||
});
|
||||
|
||||
it("should return empty result for empty directory", async () => {
|
||||
it('should return empty result for empty directory', async () => {
|
||||
const result = await generateDiffsForNonGitDirectory(tempDir);
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.diff).toBe("");
|
||||
expect(result.diff).toBe('');
|
||||
});
|
||||
|
||||
it("should mark all files as untracked", async () => {
|
||||
await fs.writeFile(path.join(tempDir, "test.txt"), "test");
|
||||
it('should mark all files as untracked', async () => {
|
||||
await fs.writeFile(path.join(tempDir, 'test.txt'), 'test');
|
||||
|
||||
const result = await generateDiffsForNonGitDirectory(tempDir);
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].status).toBe("?");
|
||||
expect(result.files[0].statusText).toBe("New");
|
||||
expect(result.files[0].status).toBe('?');
|
||||
expect(result.files[0].statusText).toBe('New');
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGitRepositoryDiffs", () => {
|
||||
it("should treat non-git directory as all new files", async () => {
|
||||
await fs.writeFile(path.join(tempDir, "file.txt"), "content\n");
|
||||
describe('getGitRepositoryDiffs', () => {
|
||||
it('should treat non-git directory as all new files', async () => {
|
||||
await fs.writeFile(path.join(tempDir, 'file.txt'), 'content\n');
|
||||
|
||||
const result = await getGitRepositoryDiffs(tempDir);
|
||||
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].status).toBe("?");
|
||||
expect(result.diff).toContain("diff --git a/file.txt b/file.txt");
|
||||
expect(result.files[0].status).toBe('?');
|
||||
expect(result.diff).toContain('diff --git a/file.txt b/file.txt');
|
||||
});
|
||||
|
||||
it("should return no changes for empty non-git directory", async () => {
|
||||
it('should return no changes for empty non-git directory', async () => {
|
||||
const result = await getGitRepositoryDiffs(tempDir);
|
||||
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.diff).toBe("");
|
||||
expect(result.diff).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.d.ts", "src/index.ts", "src/types.ts"],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', 'src/index.ts', 'src/types.ts'],
|
||||
thresholds: {
|
||||
lines: 65,
|
||||
functions: 75,
|
||||
|
||||
@@ -15,6 +15,7 @@ npm install @automaker/model-resolver
|
||||
## Exports
|
||||
|
||||
### Model Resolution
|
||||
|
||||
Convert model aliases to full model identifiers.
|
||||
|
||||
```typescript
|
||||
@@ -41,6 +42,7 @@ const model5 = resolveModelString('claude-opus-4-5-20251101');
|
||||
```
|
||||
|
||||
### Get Effective Model
|
||||
|
||||
Get the actual model that will be used.
|
||||
|
||||
```typescript
|
||||
@@ -50,11 +52,12 @@ import { getEffectiveModel } from '@automaker/model-resolver';
|
||||
const model = getEffectiveModel({
|
||||
requestedModel: 'sonnet',
|
||||
featureModel: undefined,
|
||||
defaultModel: 'claude-sonnet-4-20250514'
|
||||
defaultModel: 'claude-sonnet-4-20250514',
|
||||
});
|
||||
```
|
||||
|
||||
### Model Constants
|
||||
|
||||
Access model mappings and defaults.
|
||||
|
||||
```typescript
|
||||
@@ -62,14 +65,14 @@ import { DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
|
||||
// Default models for different contexts
|
||||
console.log(DEFAULT_MODELS.claude); // 'claude-sonnet-4-20250514'
|
||||
console.log(DEFAULT_MODELS.autoMode); // 'claude-sonnet-4-20250514'
|
||||
console.log(DEFAULT_MODELS.chat); // 'claude-sonnet-4-20250514'
|
||||
console.log(DEFAULT_MODELS.claude); // 'claude-sonnet-4-20250514'
|
||||
console.log(DEFAULT_MODELS.autoMode); // 'claude-sonnet-4-20250514'
|
||||
console.log(DEFAULT_MODELS.chat); // 'claude-sonnet-4-20250514'
|
||||
|
||||
// Model alias mappings
|
||||
console.log(CLAUDE_MODEL_MAP.haiku); // 'claude-haiku-4-5'
|
||||
console.log(CLAUDE_MODEL_MAP.sonnet); // 'claude-sonnet-4-20250514'
|
||||
console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-5-20251101'
|
||||
console.log(CLAUDE_MODEL_MAP.haiku); // 'claude-haiku-4-5'
|
||||
console.log(CLAUDE_MODEL_MAP.sonnet); // 'claude-sonnet-4-20250514'
|
||||
console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-5-20251101'
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
@@ -80,10 +83,7 @@ import type { Feature } from '@automaker/types';
|
||||
|
||||
function prepareFeatureExecution(feature: Feature) {
|
||||
// Resolve model from feature or use default
|
||||
const model = resolveModelString(
|
||||
feature.model,
|
||||
DEFAULT_MODELS.autoMode
|
||||
);
|
||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.autoMode);
|
||||
|
||||
console.log(`Executing feature with model: ${model}`);
|
||||
|
||||
@@ -99,7 +99,7 @@ const feature: Feature = {
|
||||
id: 'auth-feature',
|
||||
category: 'backend',
|
||||
description: 'Add authentication',
|
||||
model: 'opus' // User-friendly alias
|
||||
model: 'opus', // User-friendly alias
|
||||
};
|
||||
|
||||
prepareFeatureExecution(feature);
|
||||
@@ -109,11 +109,13 @@ prepareFeatureExecution(feature);
|
||||
## Supported Models
|
||||
|
||||
### Current Model Aliases
|
||||
|
||||
- `haiku` → `claude-haiku-4-5`
|
||||
- `sonnet` → `claude-sonnet-4-20250514`
|
||||
- `opus` → `claude-opus-4-5-20251101`
|
||||
|
||||
### Model Selection Guide
|
||||
|
||||
- **Haiku**: Fast responses, simple tasks, lower cost
|
||||
- **Sonnet**: Balanced performance, most tasks (recommended default)
|
||||
- **Opus**: Maximum capability, complex reasoning, highest cost
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": ["automaker", "model", "resolver"],
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"model",
|
||||
"resolver"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
|
||||
@@ -7,7 +7,4 @@
|
||||
export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias } from '@automaker/types';
|
||||
|
||||
// Export resolver functions
|
||||
export {
|
||||
resolveModelString,
|
||||
getEffectiveModel,
|
||||
} from './resolver.js';
|
||||
export { resolveModelString, getEffectiveModel } from './resolver.js';
|
||||
|
||||
@@ -26,7 +26,7 @@ export function resolveModelString(
|
||||
}
|
||||
|
||||
// Full Claude model string - pass through unchanged
|
||||
if (modelKey.includes("claude-")) {
|
||||
if (modelKey.includes('claude-')) {
|
||||
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
|
||||
return modelKey;
|
||||
}
|
||||
@@ -34,16 +34,12 @@ export function resolveModelString(
|
||||
// Look up Claude model alias
|
||||
const resolved = CLAUDE_MODEL_MAP[modelKey];
|
||||
if (resolved) {
|
||||
console.log(
|
||||
`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`
|
||||
);
|
||||
console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Unknown model key - use default
|
||||
console.warn(
|
||||
`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`
|
||||
);
|
||||
console.warn(`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`);
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { resolveModelString, getEffectiveModel } from "../src/resolver";
|
||||
import { CLAUDE_MODEL_MAP, DEFAULT_MODELS } from "@automaker/types";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { resolveModelString, getEffectiveModel } from '../src/resolver';
|
||||
import { CLAUDE_MODEL_MAP, DEFAULT_MODELS } from '@automaker/types';
|
||||
|
||||
describe("model-resolver", () => {
|
||||
describe('model-resolver', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -16,61 +16,61 @@ describe("model-resolver", () => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe("resolveModelString", () => {
|
||||
describe("with undefined/null input", () => {
|
||||
it("should return default model when modelKey is undefined", () => {
|
||||
describe('resolveModelString', () => {
|
||||
describe('with undefined/null input', () => {
|
||||
it('should return default model when modelKey is undefined', () => {
|
||||
const result = resolveModelString(undefined);
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
|
||||
it("should return custom default when modelKey is undefined", () => {
|
||||
const customDefault = "claude-opus-4-20241113";
|
||||
it('should return custom default when modelKey is undefined', () => {
|
||||
const customDefault = 'claude-opus-4-20241113';
|
||||
const result = resolveModelString(undefined, customDefault);
|
||||
expect(result).toBe(customDefault);
|
||||
});
|
||||
|
||||
it("should return default when modelKey is empty string", () => {
|
||||
const result = resolveModelString("");
|
||||
it('should return default when modelKey is empty string', () => {
|
||||
const result = resolveModelString('');
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with full Claude model strings", () => {
|
||||
it("should pass through full Claude model string unchanged", () => {
|
||||
const fullModel = "claude-sonnet-4-20250514";
|
||||
describe('with full Claude model strings', () => {
|
||||
it('should pass through full Claude model string unchanged', () => {
|
||||
const fullModel = 'claude-sonnet-4-20250514';
|
||||
const result = resolveModelString(fullModel);
|
||||
|
||||
expect(result).toBe(fullModel);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Using full Claude model string")
|
||||
expect.stringContaining('Using full Claude model string')
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle claude-opus model strings", () => {
|
||||
const fullModel = "claude-opus-4-20241113";
|
||||
it('should handle claude-opus model strings', () => {
|
||||
const fullModel = 'claude-opus-4-20241113';
|
||||
const result = resolveModelString(fullModel);
|
||||
|
||||
expect(result).toBe(fullModel);
|
||||
});
|
||||
|
||||
it("should handle claude-haiku model strings", () => {
|
||||
const fullModel = "claude-3-5-haiku-20241022";
|
||||
it('should handle claude-haiku model strings', () => {
|
||||
const fullModel = 'claude-3-5-haiku-20241022';
|
||||
const result = resolveModelString(fullModel);
|
||||
|
||||
expect(result).toBe(fullModel);
|
||||
});
|
||||
|
||||
it("should handle any string containing 'claude-'", () => {
|
||||
const customModel = "claude-custom-experimental-v1";
|
||||
const customModel = 'claude-custom-experimental-v1';
|
||||
const result = resolveModelString(customModel);
|
||||
|
||||
expect(result).toBe(customModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with model aliases", () => {
|
||||
describe('with model aliases', () => {
|
||||
it("should resolve 'sonnet' alias", () => {
|
||||
const result = resolveModelString("sonnet");
|
||||
const result = resolveModelString('sonnet');
|
||||
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
@@ -79,7 +79,7 @@ describe("model-resolver", () => {
|
||||
});
|
||||
|
||||
it("should resolve 'opus' alias", () => {
|
||||
const result = resolveModelString("opus");
|
||||
const result = resolveModelString('opus');
|
||||
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
@@ -88,62 +88,54 @@ describe("model-resolver", () => {
|
||||
});
|
||||
|
||||
it("should resolve 'haiku' alias", () => {
|
||||
const result = resolveModelString("haiku");
|
||||
const result = resolveModelString('haiku');
|
||||
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
|
||||
});
|
||||
|
||||
it("should log the resolution for aliases", () => {
|
||||
resolveModelString("sonnet");
|
||||
it('should log the resolution for aliases', () => {
|
||||
resolveModelString('sonnet');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Resolved model alias")
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Resolved model alias'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(CLAUDE_MODEL_MAP.sonnet)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with unknown model keys", () => {
|
||||
it("should return default for unknown model key", () => {
|
||||
const result = resolveModelString("unknown-model");
|
||||
describe('with unknown model keys', () => {
|
||||
it('should return default for unknown model key', () => {
|
||||
const result = resolveModelString('unknown-model');
|
||||
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
|
||||
it("should warn about unknown model key", () => {
|
||||
resolveModelString("unknown-model");
|
||||
it('should warn about unknown model key', () => {
|
||||
resolveModelString('unknown-model');
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unknown model key")
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("unknown-model")
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown model key'));
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('unknown-model'));
|
||||
});
|
||||
|
||||
it("should use custom default for unknown model key", () => {
|
||||
const customDefault = "claude-opus-4-20241113";
|
||||
const result = resolveModelString("gpt-4", customDefault);
|
||||
it('should use custom default for unknown model key', () => {
|
||||
const customDefault = 'claude-opus-4-20241113';
|
||||
const result = resolveModelString('gpt-4', customDefault);
|
||||
|
||||
expect(result).toBe(customDefault);
|
||||
});
|
||||
|
||||
it("should warn and show default being used", () => {
|
||||
const customDefault = "claude-custom-default";
|
||||
resolveModelString("invalid-key", customDefault);
|
||||
it('should warn and show default being used', () => {
|
||||
const customDefault = 'claude-custom-default';
|
||||
resolveModelString('invalid-key', customDefault);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(customDefault)
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining(customDefault));
|
||||
});
|
||||
});
|
||||
|
||||
describe("case sensitivity", () => {
|
||||
it("should be case-sensitive for aliases", () => {
|
||||
const resultUpper = resolveModelString("SONNET");
|
||||
const resultLower = resolveModelString("sonnet");
|
||||
describe('case sensitivity', () => {
|
||||
it('should be case-sensitive for aliases', () => {
|
||||
const resultUpper = resolveModelString('SONNET');
|
||||
const resultLower = resolveModelString('sonnet');
|
||||
|
||||
// Uppercase should not resolve (falls back to default)
|
||||
expect(resultUpper).toBe(DEFAULT_MODELS.claude);
|
||||
@@ -151,165 +143,158 @@ describe("model-resolver", () => {
|
||||
expect(resultLower).toBe(CLAUDE_MODEL_MAP.sonnet);
|
||||
});
|
||||
|
||||
it("should handle mixed case in claude- strings", () => {
|
||||
const result = resolveModelString("Claude-Sonnet-4-20250514");
|
||||
it('should handle mixed case in claude- strings', () => {
|
||||
const result = resolveModelString('Claude-Sonnet-4-20250514');
|
||||
|
||||
// Capital 'C' means it won't match 'claude-', falls back to default
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle model key with whitespace", () => {
|
||||
const result = resolveModelString(" sonnet ");
|
||||
describe('edge cases', () => {
|
||||
it('should handle model key with whitespace', () => {
|
||||
const result = resolveModelString(' sonnet ');
|
||||
|
||||
// Will not match due to whitespace, falls back to default
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
|
||||
it("should handle special characters in model key", () => {
|
||||
const result = resolveModelString("model@123");
|
||||
it('should handle special characters in model key', () => {
|
||||
const result = resolveModelString('model@123');
|
||||
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEffectiveModel", () => {
|
||||
describe("priority handling", () => {
|
||||
it("should prioritize explicit model over all others", () => {
|
||||
const explicit = "claude-opus-4-20241113";
|
||||
const session = "claude-sonnet-4-20250514";
|
||||
const defaultModel = "claude-3-5-haiku-20241022";
|
||||
describe('getEffectiveModel', () => {
|
||||
describe('priority handling', () => {
|
||||
it('should prioritize explicit model over all others', () => {
|
||||
const explicit = 'claude-opus-4-20241113';
|
||||
const session = 'claude-sonnet-4-20250514';
|
||||
const defaultModel = 'claude-3-5-haiku-20241022';
|
||||
|
||||
const result = getEffectiveModel(explicit, session, defaultModel);
|
||||
|
||||
expect(result).toBe(explicit);
|
||||
});
|
||||
|
||||
it("should use session model when explicit is undefined", () => {
|
||||
const session = "claude-sonnet-4-20250514";
|
||||
const defaultModel = "claude-3-5-haiku-20241022";
|
||||
it('should use session model when explicit is undefined', () => {
|
||||
const session = 'claude-sonnet-4-20250514';
|
||||
const defaultModel = 'claude-3-5-haiku-20241022';
|
||||
|
||||
const result = getEffectiveModel(undefined, session, defaultModel);
|
||||
|
||||
expect(result).toBe(session);
|
||||
});
|
||||
|
||||
it("should use default model when both explicit and session are undefined", () => {
|
||||
const defaultModel = "claude-opus-4-20241113";
|
||||
it('should use default model when both explicit and session are undefined', () => {
|
||||
const defaultModel = 'claude-opus-4-20241113';
|
||||
|
||||
const result = getEffectiveModel(undefined, undefined, defaultModel);
|
||||
|
||||
expect(result).toBe(defaultModel);
|
||||
});
|
||||
|
||||
it("should use system default when all are undefined", () => {
|
||||
it('should use system default when all are undefined', () => {
|
||||
const result = getEffectiveModel(undefined, undefined, undefined);
|
||||
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with aliases", () => {
|
||||
it("should resolve explicit model alias", () => {
|
||||
const result = getEffectiveModel("opus", "sonnet");
|
||||
describe('with aliases', () => {
|
||||
it('should resolve explicit model alias', () => {
|
||||
const result = getEffectiveModel('opus', 'sonnet');
|
||||
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
|
||||
});
|
||||
|
||||
it("should resolve session model alias when explicit is undefined", () => {
|
||||
const result = getEffectiveModel(undefined, "haiku");
|
||||
it('should resolve session model alias when explicit is undefined', () => {
|
||||
const result = getEffectiveModel(undefined, 'haiku');
|
||||
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
|
||||
});
|
||||
|
||||
it("should prioritize explicit alias over session full string", () => {
|
||||
const result = getEffectiveModel(
|
||||
"sonnet",
|
||||
"claude-opus-4-20241113"
|
||||
);
|
||||
it('should prioritize explicit alias over session full string', () => {
|
||||
const result = getEffectiveModel('sonnet', 'claude-opus-4-20241113');
|
||||
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with empty strings", () => {
|
||||
it("should treat empty explicit string as undefined", () => {
|
||||
const session = "claude-sonnet-4-20250514";
|
||||
describe('with empty strings', () => {
|
||||
it('should treat empty explicit string as undefined', () => {
|
||||
const session = 'claude-sonnet-4-20250514';
|
||||
|
||||
const result = getEffectiveModel("", session);
|
||||
const result = getEffectiveModel('', session);
|
||||
|
||||
expect(result).toBe(session);
|
||||
});
|
||||
|
||||
it("should treat empty session string as undefined", () => {
|
||||
const defaultModel = "claude-opus-4-20241113";
|
||||
it('should treat empty session string as undefined', () => {
|
||||
const defaultModel = 'claude-opus-4-20241113';
|
||||
|
||||
const result = getEffectiveModel(undefined, "", defaultModel);
|
||||
const result = getEffectiveModel(undefined, '', defaultModel);
|
||||
|
||||
expect(result).toBe(defaultModel);
|
||||
});
|
||||
|
||||
it("should handle all empty strings", () => {
|
||||
const result = getEffectiveModel("", "", "");
|
||||
it('should handle all empty strings', () => {
|
||||
const result = getEffectiveModel('', '', '');
|
||||
|
||||
// Empty strings are falsy, so explicit || session becomes "" || "" = ""
|
||||
// Then resolveModelString("", "") returns "" (not in CLAUDE_MODEL_MAP, not containing "claude-")
|
||||
// This actually returns the custom default which is ""
|
||||
expect(result).toBe("");
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration scenarios", () => {
|
||||
it("should handle user overriding session model with alias", () => {
|
||||
const sessionModel = "claude-sonnet-4-20250514";
|
||||
const userChoice = "opus";
|
||||
describe('integration scenarios', () => {
|
||||
it('should handle user overriding session model with alias', () => {
|
||||
const sessionModel = 'claude-sonnet-4-20250514';
|
||||
const userChoice = 'opus';
|
||||
|
||||
const result = getEffectiveModel(userChoice, sessionModel);
|
||||
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
|
||||
});
|
||||
|
||||
it("should handle fallback chain: unknown -> session -> default", () => {
|
||||
const result = getEffectiveModel(
|
||||
"invalid",
|
||||
"also-invalid",
|
||||
"claude-opus-4-20241113"
|
||||
);
|
||||
it('should handle fallback chain: unknown -> session -> default', () => {
|
||||
const result = getEffectiveModel('invalid', 'also-invalid', 'claude-opus-4-20241113');
|
||||
|
||||
// Both invalid models fall back to default
|
||||
expect(result).toBe("claude-opus-4-20241113");
|
||||
expect(result).toBe('claude-opus-4-20241113');
|
||||
});
|
||||
|
||||
it("should handle session with alias, no explicit", () => {
|
||||
const result = getEffectiveModel(undefined, "haiku");
|
||||
it('should handle session with alias, no explicit', () => {
|
||||
const result = getEffectiveModel(undefined, 'haiku');
|
||||
|
||||
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLAUDE_MODEL_MAP integration", () => {
|
||||
it("should have valid mappings for all known aliases", () => {
|
||||
const aliases = ["sonnet", "opus", "haiku"];
|
||||
describe('CLAUDE_MODEL_MAP integration', () => {
|
||||
it('should have valid mappings for all known aliases', () => {
|
||||
const aliases = ['sonnet', 'opus', 'haiku'];
|
||||
|
||||
for (const alias of aliases) {
|
||||
const resolved = resolveModelString(alias);
|
||||
expect(resolved).toBeDefined();
|
||||
expect(resolved).toContain("claude-");
|
||||
expect(resolved).toContain('claude-');
|
||||
expect(resolved).toBe(CLAUDE_MODEL_MAP[alias]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_MODELS integration", () => {
|
||||
it("should use DEFAULT_MODELS.claude as fallback", () => {
|
||||
describe('DEFAULT_MODELS integration', () => {
|
||||
it('should use DEFAULT_MODELS.claude as fallback', () => {
|
||||
const result = resolveModelString(undefined);
|
||||
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
expect(DEFAULT_MODELS.claude).toBeDefined();
|
||||
expect(DEFAULT_MODELS.claude).toContain("claude-");
|
||||
expect(DEFAULT_MODELS.claude).toContain('claude-');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.d.ts", "src/index.ts"],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', 'src/index.ts'],
|
||||
thresholds: {
|
||||
lines: 95,
|
||||
functions: 95,
|
||||
|
||||
@@ -15,6 +15,7 @@ npm install @automaker/platform
|
||||
## Exports
|
||||
|
||||
### Path Management
|
||||
|
||||
AutoMaker directory structure utilities.
|
||||
|
||||
```typescript
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
getWorktreesDir,
|
||||
getAppSpecPath,
|
||||
getBranchTrackingPath,
|
||||
ensureAutomakerDir
|
||||
ensureAutomakerDir,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Get AutoMaker directory: /project/.automaker
|
||||
@@ -49,6 +50,7 @@ await ensureAutomakerDir('/project/path');
|
||||
```
|
||||
|
||||
### Subprocess Management
|
||||
|
||||
Spawn and manage subprocesses with JSON-lines output.
|
||||
|
||||
```typescript
|
||||
@@ -60,18 +62,19 @@ const result = await spawnJSONLProcess({
|
||||
args: ['--output', 'jsonl'],
|
||||
cwd: '/project/path',
|
||||
onLine: (data) => console.log('Received:', data),
|
||||
onError: (error) => console.error('Error:', error)
|
||||
onError: (error) => console.error('Error:', error),
|
||||
});
|
||||
|
||||
// Spawn regular process
|
||||
const output = await spawnProcess({
|
||||
command: 'git',
|
||||
args: ['status'],
|
||||
cwd: '/project/path'
|
||||
cwd: '/project/path',
|
||||
});
|
||||
```
|
||||
|
||||
### Security Validation
|
||||
|
||||
Path validation and security checks.
|
||||
|
||||
```typescript
|
||||
@@ -82,7 +85,7 @@ import {
|
||||
getAllowedPaths,
|
||||
getAllowedRootDirectory,
|
||||
getDataDirectory,
|
||||
PathNotAllowedError
|
||||
PathNotAllowedError,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Initialize allowed paths from environment
|
||||
@@ -116,7 +119,7 @@ import {
|
||||
getFeatureDir,
|
||||
ensureAutomakerDir,
|
||||
spawnJSONLProcess,
|
||||
validatePath
|
||||
validatePath,
|
||||
} from '@automaker/platform';
|
||||
|
||||
async function executeFeature(projectPath: string, featureId: string) {
|
||||
@@ -138,7 +141,7 @@ async function executeFeature(projectPath: string, featureId: string) {
|
||||
if (data.type === 'progress') {
|
||||
console.log('Progress:', data.progress);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": ["automaker", "platform"],
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"platform"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
* Directory creation is handled separately by ensure* functions.
|
||||
*/
|
||||
|
||||
import * as secureFs from "./secure-fs.js";
|
||||
import path from "path";
|
||||
import * as secureFs from './secure-fs.js';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Get the automaker data directory root for a project
|
||||
@@ -22,7 +22,7 @@ import path from "path";
|
||||
* @returns Absolute path to {projectPath}/.automaker
|
||||
*/
|
||||
export function getAutomakerDir(projectPath: string): string {
|
||||
return path.join(projectPath, ".automaker");
|
||||
return path.join(projectPath, '.automaker');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ export function getAutomakerDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/features
|
||||
*/
|
||||
export function getFeaturesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "features");
|
||||
return path.join(getAutomakerDir(projectPath), 'features');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,11 +59,8 @@ export function getFeatureDir(projectPath: string, featureId: string): string {
|
||||
* @param featureId - Feature identifier
|
||||
* @returns Absolute path to {projectPath}/.automaker/features/{featureId}/images
|
||||
*/
|
||||
export function getFeatureImagesDir(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): string {
|
||||
return path.join(getFeatureDir(projectPath, featureId), "images");
|
||||
export function getFeatureImagesDir(projectPath: string, featureId: string): string {
|
||||
return path.join(getFeatureDir(projectPath, featureId), 'images');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +72,7 @@ export function getFeatureImagesDir(
|
||||
* @returns Absolute path to {projectPath}/.automaker/board
|
||||
*/
|
||||
export function getBoardDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "board");
|
||||
return path.join(getAutomakerDir(projectPath), 'board');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +84,7 @@ export function getBoardDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/images
|
||||
*/
|
||||
export function getImagesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "images");
|
||||
return path.join(getAutomakerDir(projectPath), 'images');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +96,7 @@ export function getImagesDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/context
|
||||
*/
|
||||
export function getContextDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "context");
|
||||
return path.join(getAutomakerDir(projectPath), 'context');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +108,7 @@ export function getContextDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/worktrees
|
||||
*/
|
||||
export function getWorktreesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "worktrees");
|
||||
return path.join(getAutomakerDir(projectPath), 'worktrees');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +120,7 @@ export function getWorktreesDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/app_spec.txt
|
||||
*/
|
||||
export function getAppSpecPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "app_spec.txt");
|
||||
return path.join(getAutomakerDir(projectPath), 'app_spec.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,7 +132,7 @@ export function getAppSpecPath(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/active-branches.json
|
||||
*/
|
||||
export function getBranchTrackingPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "active-branches.json");
|
||||
return path.join(getAutomakerDir(projectPath), 'active-branches.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,7 +169,7 @@ export async function ensureAutomakerDir(projectPath: string): Promise<string> {
|
||||
* @returns Absolute path to {dataDir}/settings.json
|
||||
*/
|
||||
export function getGlobalSettingsPath(dataDir: string): string {
|
||||
return path.join(dataDir, "settings.json");
|
||||
return path.join(dataDir, 'settings.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +182,7 @@ export function getGlobalSettingsPath(dataDir: string): string {
|
||||
* @returns Absolute path to {dataDir}/credentials.json
|
||||
*/
|
||||
export function getCredentialsPath(dataDir: string): string {
|
||||
return path.join(dataDir, "credentials.json");
|
||||
return path.join(dataDir, 'credentials.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,7 +195,7 @@ export function getCredentialsPath(dataDir: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/settings.json
|
||||
*/
|
||||
export function getProjectSettingsPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "settings.json");
|
||||
return path.join(getAutomakerDir(projectPath), 'settings.json');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
* not just at the API layer. This provides defense-in-depth security.
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import type { Dirent } from "fs";
|
||||
import path from "path";
|
||||
import { validatePath } from "./security.js";
|
||||
import fs from 'fs/promises';
|
||||
import type { Dirent } from 'fs';
|
||||
import path from 'path';
|
||||
import { validatePath } from './security.js';
|
||||
|
||||
/**
|
||||
* Wrapper around fs.access that validates path first
|
||||
@@ -108,11 +108,7 @@ export async function unlink(filePath: string): Promise<void> {
|
||||
/**
|
||||
* Wrapper around fs.copyFile that validates both paths first
|
||||
*/
|
||||
export async function copyFile(
|
||||
src: string,
|
||||
dest: string,
|
||||
mode?: number
|
||||
): Promise<void> {
|
||||
export async function copyFile(src: string, dest: string, mode?: number): Promise<void> {
|
||||
const validatedSrc = validatePath(src);
|
||||
const validatedDest = validatePath(dest);
|
||||
return fs.copyFile(validatedSrc, validatedDest, mode);
|
||||
@@ -133,10 +129,7 @@ export async function appendFile(
|
||||
/**
|
||||
* Wrapper around fs.rename that validates both paths first
|
||||
*/
|
||||
export async function rename(
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Promise<void> {
|
||||
export async function rename(oldPath: string, newPath: string): Promise<void> {
|
||||
const validatedOldPath = validatePath(oldPath);
|
||||
const validatedNewPath = validatePath(newPath);
|
||||
return fs.rename(validatedOldPath, validatedNewPath);
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
* Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Error thrown when a path is not allowed by security policy
|
||||
*/
|
||||
export class PathNotAllowedError extends Error {
|
||||
constructor(filePath: string) {
|
||||
super(
|
||||
`Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY or DATA_DIR.`
|
||||
);
|
||||
this.name = "PathNotAllowedError";
|
||||
super(`Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY or DATA_DIR.`);
|
||||
this.name = 'PathNotAllowedError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,13 +31,9 @@ export function initAllowedPaths(): void {
|
||||
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
if (rootDir) {
|
||||
allowedRootDirectory = path.resolve(rootDir);
|
||||
console.log(
|
||||
`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`
|
||||
);
|
||||
console.log(`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`);
|
||||
} else {
|
||||
console.log(
|
||||
"[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths"
|
||||
);
|
||||
console.log('[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths');
|
||||
}
|
||||
|
||||
// Load DATA_DIR (appData exception - always allowed)
|
||||
@@ -72,10 +66,7 @@ export function isPathAllowed(filePath: string): boolean {
|
||||
}
|
||||
|
||||
// Allow if within ALLOWED_ROOT_DIRECTORY
|
||||
if (
|
||||
allowedRootDirectory &&
|
||||
isPathWithinDirectory(resolvedPath, allowedRootDirectory)
|
||||
) {
|
||||
if (allowedRootDirectory && isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -101,17 +92,14 @@ export function validatePath(filePath: string): string {
|
||||
* Check if a path is within a directory, with protection against path traversal
|
||||
* Returns true only if resolvedPath is within directoryPath
|
||||
*/
|
||||
export function isPathWithinDirectory(
|
||||
resolvedPath: string,
|
||||
directoryPath: string
|
||||
): boolean {
|
||||
export function isPathWithinDirectory(resolvedPath: string, directoryPath: string): boolean {
|
||||
// Get the relative path from directory to the target
|
||||
const relativePath = path.relative(directoryPath, resolvedPath);
|
||||
|
||||
// If relative path starts with "..", it's outside the directory
|
||||
// If relative path is absolute, it's outside the directory
|
||||
// If relative path is empty or ".", it's the directory itself
|
||||
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
||||
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Subprocess management utilities for CLI providers
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import readline from "readline";
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import readline from 'readline';
|
||||
|
||||
export interface SubprocessOptions {
|
||||
command: string;
|
||||
@@ -23,9 +23,7 @@ export interface SubprocessResult {
|
||||
/**
|
||||
* Spawns a subprocess and streams JSONL output line-by-line
|
||||
*/
|
||||
export async function* spawnJSONLProcess(
|
||||
options: SubprocessOptions
|
||||
): AsyncGenerator<unknown> {
|
||||
export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator<unknown> {
|
||||
const { command, args, cwd, env, abortController, timeout = 30000 } = options;
|
||||
|
||||
const processEnv = {
|
||||
@@ -33,22 +31,22 @@ export async function* spawnJSONLProcess(
|
||||
...env,
|
||||
};
|
||||
|
||||
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`);
|
||||
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(' ')}`);
|
||||
console.log(`[SubprocessManager] Working directory: ${cwd}`);
|
||||
|
||||
const childProcess: ChildProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderrOutput = "";
|
||||
let stderrOutput = '';
|
||||
let lastOutputTime = Date.now();
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
|
||||
// Collect stderr for error reporting
|
||||
if (childProcess.stderr) {
|
||||
childProcess.stderr.on("data", (data: Buffer) => {
|
||||
childProcess.stderr.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderrOutput += text;
|
||||
console.error(`[SubprocessManager] stderr: ${text}`);
|
||||
@@ -64,10 +62,8 @@ export async function* spawnJSONLProcess(
|
||||
timeoutHandle = setTimeout(() => {
|
||||
const elapsed = Date.now() - lastOutputTime;
|
||||
if (elapsed >= timeout) {
|
||||
console.error(
|
||||
`[SubprocessManager] Process timeout: no output for ${timeout}ms`
|
||||
);
|
||||
childProcess.kill("SIGTERM");
|
||||
console.error(`[SubprocessManager] Process timeout: no output for ${timeout}ms`);
|
||||
childProcess.kill('SIGTERM');
|
||||
}
|
||||
}, timeout);
|
||||
};
|
||||
@@ -76,12 +72,12 @@ export async function* spawnJSONLProcess(
|
||||
|
||||
// Setup abort handling
|
||||
if (abortController) {
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
console.log("[SubprocessManager] Abort signal received, killing process");
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
console.log('[SubprocessManager] Abort signal received, killing process');
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
childProcess.kill("SIGTERM");
|
||||
childProcess.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,19 +98,16 @@ export async function* spawnJSONLProcess(
|
||||
const parsed = JSON.parse(line);
|
||||
yield parsed;
|
||||
} catch (parseError) {
|
||||
console.error(
|
||||
`[SubprocessManager] Failed to parse JSONL line: ${line}`,
|
||||
parseError
|
||||
);
|
||||
console.error(`[SubprocessManager] Failed to parse JSONL line: ${line}`, parseError);
|
||||
// Yield error but continue processing
|
||||
yield {
|
||||
type: "error",
|
||||
type: 'error',
|
||||
error: `Failed to parse output: ${line}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SubprocessManager] Error reading stdout:", error);
|
||||
console.error('[SubprocessManager] Error reading stdout:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
@@ -125,13 +118,13 @@ export async function* spawnJSONLProcess(
|
||||
|
||||
// Wait for process to exit
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
childProcess.on("exit", (code) => {
|
||||
childProcess.on('exit', (code) => {
|
||||
console.log(`[SubprocessManager] Process exited with code: ${code}`);
|
||||
resolve(code);
|
||||
});
|
||||
|
||||
childProcess.on("error", (error) => {
|
||||
console.error("[SubprocessManager] Process error:", error);
|
||||
childProcess.on('error', (error) => {
|
||||
console.error('[SubprocessManager] Process error:', error);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
@@ -141,23 +134,21 @@ export async function* spawnJSONLProcess(
|
||||
const errorMessage = stderrOutput || `Process exited with code ${exitCode}`;
|
||||
console.error(`[SubprocessManager] Process failed: ${errorMessage}`);
|
||||
yield {
|
||||
type: "error",
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// Process completed successfully
|
||||
if (exitCode === 0 && !stderrOutput) {
|
||||
console.log("[SubprocessManager] Process completed successfully");
|
||||
console.log('[SubprocessManager] Process completed successfully');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a subprocess and collects all output
|
||||
*/
|
||||
export async function spawnProcess(
|
||||
options: SubprocessOptions
|
||||
): Promise<SubprocessResult> {
|
||||
export async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult> {
|
||||
const { command, args, cwd, env, abortController } = options;
|
||||
|
||||
const processEnv = {
|
||||
@@ -169,37 +160,37 @@ export async function spawnProcess(
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
if (childProcess.stdout) {
|
||||
childProcess.stdout.on("data", (data: Buffer) => {
|
||||
childProcess.stdout.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
}
|
||||
|
||||
if (childProcess.stderr) {
|
||||
childProcess.stderr.on("data", (data: Buffer) => {
|
||||
childProcess.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup abort handling
|
||||
if (abortController) {
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
childProcess.kill("SIGTERM");
|
||||
reject(new Error("Process aborted"));
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
childProcess.kill('SIGTERM');
|
||||
reject(new Error('Process aborted'));
|
||||
});
|
||||
}
|
||||
|
||||
childProcess.on("exit", (code) => {
|
||||
childProcess.on('exit', (code) => {
|
||||
resolve({ stdout, stderr, exitCode: code });
|
||||
});
|
||||
|
||||
childProcess.on("error", (error) => {
|
||||
childProcess.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import {
|
||||
getAutomakerDir,
|
||||
getFeaturesDir,
|
||||
@@ -18,18 +18,18 @@ import {
|
||||
getCredentialsPath,
|
||||
getProjectSettingsPath,
|
||||
ensureDataDir,
|
||||
} from "../src/paths";
|
||||
} from '../src/paths';
|
||||
|
||||
describe("paths.ts", () => {
|
||||
describe('paths.ts', () => {
|
||||
let tempDir: string;
|
||||
let projectPath: string;
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for testing
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "platform-paths-test-"));
|
||||
projectPath = path.join(tempDir, "test-project");
|
||||
dataDir = path.join(tempDir, "user-data");
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'platform-paths-test-'));
|
||||
projectPath = path.join(tempDir, 'test-project');
|
||||
dataDir = path.join(tempDir, 'user-data');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
});
|
||||
|
||||
@@ -42,98 +42,88 @@ describe("paths.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("Project-level path construction", () => {
|
||||
it("should return automaker directory path", () => {
|
||||
describe('Project-level path construction', () => {
|
||||
it('should return automaker directory path', () => {
|
||||
const result = getAutomakerDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker'));
|
||||
});
|
||||
|
||||
it("should return features directory path", () => {
|
||||
it('should return features directory path', () => {
|
||||
const result = getFeaturesDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "features"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'features'));
|
||||
});
|
||||
|
||||
it("should return feature directory path", () => {
|
||||
const featureId = "auth-feature";
|
||||
it('should return feature directory path', () => {
|
||||
const featureId = 'auth-feature';
|
||||
const result = getFeatureDir(projectPath, featureId);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "features", featureId)
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'features', featureId));
|
||||
});
|
||||
|
||||
it("should return feature images directory path", () => {
|
||||
const featureId = "auth-feature";
|
||||
it('should return feature images directory path', () => {
|
||||
const featureId = 'auth-feature';
|
||||
const result = getFeatureImagesDir(projectPath, featureId);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "features", featureId, "images")
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'features', featureId, 'images'));
|
||||
});
|
||||
|
||||
it("should return board directory path", () => {
|
||||
it('should return board directory path', () => {
|
||||
const result = getBoardDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "board"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'board'));
|
||||
});
|
||||
|
||||
it("should return images directory path", () => {
|
||||
it('should return images directory path', () => {
|
||||
const result = getImagesDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "images"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'images'));
|
||||
});
|
||||
|
||||
it("should return context directory path", () => {
|
||||
it('should return context directory path', () => {
|
||||
const result = getContextDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "context"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'context'));
|
||||
});
|
||||
|
||||
it("should return worktrees directory path", () => {
|
||||
it('should return worktrees directory path', () => {
|
||||
const result = getWorktreesDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "worktrees"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'worktrees'));
|
||||
});
|
||||
|
||||
it("should return app spec file path", () => {
|
||||
it('should return app spec file path', () => {
|
||||
const result = getAppSpecPath(projectPath);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "app_spec.txt")
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'app_spec.txt'));
|
||||
});
|
||||
|
||||
it("should return branch tracking file path", () => {
|
||||
it('should return branch tracking file path', () => {
|
||||
const result = getBranchTrackingPath(projectPath);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "active-branches.json")
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'active-branches.json'));
|
||||
});
|
||||
|
||||
it("should return project settings file path", () => {
|
||||
it('should return project settings file path', () => {
|
||||
const result = getProjectSettingsPath(projectPath);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "settings.json")
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'settings.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Global settings path construction", () => {
|
||||
it("should return global settings path", () => {
|
||||
describe('Global settings path construction', () => {
|
||||
it('should return global settings path', () => {
|
||||
const result = getGlobalSettingsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, "settings.json"));
|
||||
expect(result).toBe(path.join(dataDir, 'settings.json'));
|
||||
});
|
||||
|
||||
it("should return credentials path", () => {
|
||||
it('should return credentials path', () => {
|
||||
const result = getCredentialsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, "credentials.json"));
|
||||
expect(result).toBe(path.join(dataDir, 'credentials.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Directory creation", () => {
|
||||
it("should create automaker directory", async () => {
|
||||
describe('Directory creation', () => {
|
||||
it('should create automaker directory', async () => {
|
||||
const automakerDir = await ensureAutomakerDir(projectPath);
|
||||
|
||||
expect(automakerDir).toBe(path.join(projectPath, ".automaker"));
|
||||
expect(automakerDir).toBe(path.join(projectPath, '.automaker'));
|
||||
|
||||
const stats = await fs.stat(automakerDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be idempotent when creating automaker directory", async () => {
|
||||
it('should be idempotent when creating automaker directory', async () => {
|
||||
// Create directory first time
|
||||
const firstResult = await ensureAutomakerDir(projectPath);
|
||||
|
||||
@@ -146,7 +136,7 @@ describe("paths.ts", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should create data directory", async () => {
|
||||
it('should create data directory', async () => {
|
||||
const result = await ensureDataDir(dataDir);
|
||||
|
||||
expect(result).toBe(dataDir);
|
||||
@@ -155,7 +145,7 @@ describe("paths.ts", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be idempotent when creating data directory", async () => {
|
||||
it('should be idempotent when creating data directory', async () => {
|
||||
// Create directory first time
|
||||
const firstResult = await ensureDataDir(dataDir);
|
||||
|
||||
@@ -168,13 +158,8 @@ describe("paths.ts", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should create nested directories recursively", async () => {
|
||||
const deepProjectPath = path.join(
|
||||
tempDir,
|
||||
"nested",
|
||||
"deep",
|
||||
"project"
|
||||
);
|
||||
it('should create nested directories recursively', async () => {
|
||||
const deepProjectPath = path.join(tempDir, 'nested', 'deep', 'project');
|
||||
await fs.mkdir(deepProjectPath, { recursive: true });
|
||||
|
||||
const automakerDir = await ensureAutomakerDir(deepProjectPath);
|
||||
@@ -184,29 +169,29 @@ describe("paths.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path handling with special characters", () => {
|
||||
it("should handle feature IDs with special characters", () => {
|
||||
const featureId = "feature-with-dashes_and_underscores";
|
||||
describe('Path handling with special characters', () => {
|
||||
it('should handle feature IDs with special characters', () => {
|
||||
const featureId = 'feature-with-dashes_and_underscores';
|
||||
const result = getFeatureDir(projectPath, featureId);
|
||||
expect(result).toContain(featureId);
|
||||
});
|
||||
|
||||
it("should handle paths with spaces", () => {
|
||||
const pathWithSpaces = path.join(tempDir, "path with spaces");
|
||||
it('should handle paths with spaces', () => {
|
||||
const pathWithSpaces = path.join(tempDir, 'path with spaces');
|
||||
const result = getAutomakerDir(pathWithSpaces);
|
||||
expect(result).toBe(path.join(pathWithSpaces, ".automaker"));
|
||||
expect(result).toBe(path.join(pathWithSpaces, '.automaker'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path relationships", () => {
|
||||
it("should have feature dir as child of features dir", () => {
|
||||
describe('Path relationships', () => {
|
||||
it('should have feature dir as child of features dir', () => {
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
const featureDir = getFeatureDir(projectPath, "test-feature");
|
||||
const featureDir = getFeatureDir(projectPath, 'test-feature');
|
||||
|
||||
expect(featureDir.startsWith(featuresDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("should have all project paths under automaker dir", () => {
|
||||
it('should have all project paths under automaker dir', () => {
|
||||
const automakerDir = getAutomakerDir(projectPath);
|
||||
const paths = [
|
||||
getFeaturesDir(projectPath),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import path from "path";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
|
||||
describe("security.ts", () => {
|
||||
describe('security.ts', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -16,176 +16,162 @@ describe("security.ts", () => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("initAllowedPaths", () => {
|
||||
it("should load ALLOWED_ROOT_DIRECTORY if set", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
describe('initAllowedPaths', () => {
|
||||
it('should load ALLOWED_ROOT_DIRECTORY if set', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/projects"));
|
||||
expect(allowed).toContain(path.resolve('/projects'));
|
||||
});
|
||||
|
||||
it("should load DATA_DIR if set", async () => {
|
||||
it('should load DATA_DIR if set', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
process.env.DATA_DIR = "/data/directory";
|
||||
process.env.DATA_DIR = '/data/directory';
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/data/directory"));
|
||||
expect(allowed).toContain(path.resolve('/data/directory'));
|
||||
});
|
||||
|
||||
it("should load both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
process.env.DATA_DIR = "/app/data";
|
||||
it('should load both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
process.env.DATA_DIR = '/app/data';
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/projects"));
|
||||
expect(allowed).toContain(path.resolve("/app/data"));
|
||||
expect(allowed).toContain(path.resolve('/projects'));
|
||||
expect(allowed).toContain(path.resolve('/app/data'));
|
||||
});
|
||||
|
||||
it("should handle missing environment variables gracefully", async () => {
|
||||
it('should handle missing environment variables gracefully', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths } = await import("../src/security");
|
||||
const { initAllowedPaths } = await import('../src/security');
|
||||
expect(() => initAllowedPaths()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPathAllowed", () => {
|
||||
it("should allow paths within ALLOWED_ROOT_DIRECTORY", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
describe('isPathAllowed', () => {
|
||||
it('should allow paths within ALLOWED_ROOT_DIRECTORY', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/allowed/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/allowed/subdir/file.txt")).toBe(true);
|
||||
expect(isPathAllowed('/allowed/file.txt')).toBe(true);
|
||||
expect(isPathAllowed('/allowed/subdir/file.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny paths outside ALLOWED_ROOT_DIRECTORY", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
it('should deny paths outside ALLOWED_ROOT_DIRECTORY', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/not-allowed/file.txt")).toBe(false);
|
||||
expect(isPathAllowed("/etc/passwd")).toBe(false);
|
||||
expect(isPathAllowed('/not-allowed/file.txt')).toBe(false);
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(false);
|
||||
});
|
||||
|
||||
it("should always allow DATA_DIR paths", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
process.env.DATA_DIR = "/app/data";
|
||||
it('should always allow DATA_DIR paths', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
process.env.DATA_DIR = '/app/data';
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
// DATA_DIR paths are always allowed
|
||||
expect(isPathAllowed("/app/data/settings.json")).toBe(true);
|
||||
expect(isPathAllowed("/app/data/credentials.json")).toBe(true);
|
||||
expect(isPathAllowed('/app/data/settings.json')).toBe(true);
|
||||
expect(isPathAllowed('/app/data/credentials.json')).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow all paths when no restrictions configured", async () => {
|
||||
it('should allow all paths when no restrictions configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/any/path")).toBe(true);
|
||||
expect(isPathAllowed("/etc/passwd")).toBe(true);
|
||||
expect(isPathAllowed('/any/path')).toBe(true);
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow all paths when only DATA_DIR is configured", async () => {
|
||||
it('should allow all paths when only DATA_DIR is configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
process.env.DATA_DIR = "/data";
|
||||
process.env.DATA_DIR = '/data';
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
// DATA_DIR should be allowed
|
||||
expect(isPathAllowed("/data/file.txt")).toBe(true);
|
||||
expect(isPathAllowed('/data/file.txt')).toBe(true);
|
||||
// And all other paths should be allowed since no ALLOWED_ROOT_DIRECTORY restriction
|
||||
expect(isPathAllowed("/any/path")).toBe(true);
|
||||
expect(isPathAllowed('/any/path')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePath", () => {
|
||||
it("should return resolved path for allowed paths", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
describe('validatePath', () => {
|
||||
it('should return resolved path for allowed paths', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, validatePath } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("/allowed/file.txt");
|
||||
expect(result).toBe(path.resolve("/allowed/file.txt"));
|
||||
const result = validatePath('/allowed/file.txt');
|
||||
expect(result).toBe(path.resolve('/allowed/file.txt'));
|
||||
});
|
||||
|
||||
it("should throw error for paths outside allowed directories", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
it('should throw error for paths outside allowed directories', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath, PathNotAllowedError } =
|
||||
await import("../src/security");
|
||||
await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(() => validatePath("/not-allowed/file.txt")).toThrow(
|
||||
PathNotAllowedError
|
||||
);
|
||||
expect(() => validatePath('/not-allowed/file.txt')).toThrow(PathNotAllowedError);
|
||||
});
|
||||
|
||||
it("should resolve relative paths", async () => {
|
||||
it('should resolve relative paths', async () => {
|
||||
const cwd = process.cwd();
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = cwd;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, validatePath } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("./file.txt");
|
||||
expect(result).toBe(path.resolve(cwd, "./file.txt"));
|
||||
const result = validatePath('./file.txt');
|
||||
expect(result).toBe(path.resolve(cwd, './file.txt'));
|
||||
});
|
||||
|
||||
it("should not throw when no restrictions configured", async () => {
|
||||
it('should not throw when no restrictions configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, validatePath } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(() => validatePath("/any/path")).not.toThrow();
|
||||
expect(() => validatePath('/any/path')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllowedPaths", () => {
|
||||
it("should return empty array when no paths configured", async () => {
|
||||
describe('getAllowedPaths', () => {
|
||||
it('should return empty array when no paths configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
@@ -193,58 +179,53 @@ describe("security.ts", () => {
|
||||
expect(allowed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return configured paths", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
process.env.DATA_DIR = "/data";
|
||||
it('should return configured paths', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
process.env.DATA_DIR = '/data';
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/projects"));
|
||||
expect(allowed).toContain(path.resolve("/data"));
|
||||
expect(allowed).toContain(path.resolve('/projects'));
|
||||
expect(allowed).toContain(path.resolve('/data'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllowedRootDirectory", () => {
|
||||
it("should return the configured root directory", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
describe('getAllowedRootDirectory', () => {
|
||||
it('should return the configured root directory', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
|
||||
const { initAllowedPaths, getAllowedRootDirectory } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedRootDirectory } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getAllowedRootDirectory()).toBe(path.resolve("/projects"));
|
||||
expect(getAllowedRootDirectory()).toBe(path.resolve('/projects'));
|
||||
});
|
||||
|
||||
it("should return null when not configured", async () => {
|
||||
it('should return null when not configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
|
||||
const { initAllowedPaths, getAllowedRootDirectory } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedRootDirectory } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getAllowedRootDirectory()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDataDirectory", () => {
|
||||
it("should return the configured data directory", async () => {
|
||||
process.env.DATA_DIR = "/data";
|
||||
describe('getDataDirectory', () => {
|
||||
it('should return the configured data directory', async () => {
|
||||
process.env.DATA_DIR = '/data';
|
||||
|
||||
const { initAllowedPaths, getDataDirectory } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getDataDirectory } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getDataDirectory()).toBe(path.resolve("/data"));
|
||||
expect(getDataDirectory()).toBe(path.resolve('/data'));
|
||||
});
|
||||
|
||||
it("should return null when not configured", async () => {
|
||||
it('should return null when not configured', async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getDataDirectory } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getDataDirectory } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getDataDirectory()).toBeNull();
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
spawnJSONLProcess,
|
||||
spawnProcess,
|
||||
type SubprocessOptions,
|
||||
} from "../src/subprocess";
|
||||
import * as cp from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import { Readable } from "stream";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { spawnJSONLProcess, spawnProcess, type SubprocessOptions } from '../src/subprocess';
|
||||
import * as cp from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
vi.mock("child_process");
|
||||
vi.mock('child_process');
|
||||
|
||||
/**
|
||||
* Helper to collect all items from an async generator
|
||||
*/
|
||||
async function collectAsyncGenerator<T>(
|
||||
generator: AsyncGenerator<T>
|
||||
): Promise<T[]> {
|
||||
async function collectAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
for await (const item of generator) {
|
||||
results.push(item);
|
||||
@@ -23,7 +17,7 @@ async function collectAsyncGenerator<T>(
|
||||
return results;
|
||||
}
|
||||
|
||||
describe("subprocess.ts", () => {
|
||||
describe('subprocess.ts', () => {
|
||||
let consoleSpy: {
|
||||
log: ReturnType<typeof vi.spyOn>;
|
||||
error: ReturnType<typeof vi.spyOn>;
|
||||
@@ -32,8 +26,8 @@ describe("subprocess.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -71,7 +65,7 @@ describe("subprocess.ts", () => {
|
||||
// Emit stderr lines immediately
|
||||
if (config.stderrLines) {
|
||||
for (const line of config.stderrLines) {
|
||||
stderr.emit("data", Buffer.from(line));
|
||||
stderr.emit('data', Buffer.from(line));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +73,7 @@ describe("subprocess.ts", () => {
|
||||
const emitLines = async () => {
|
||||
if (config.stdoutLines) {
|
||||
for (const line of config.stdoutLines) {
|
||||
stdout.push(line + "\n");
|
||||
stdout.push(line + '\n');
|
||||
// Small delay to allow readline to process
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
@@ -90,15 +84,13 @@ describe("subprocess.ts", () => {
|
||||
stdout.push(null); // End stdout
|
||||
|
||||
// Small delay before exit
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, config.delayMs ?? 10)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, config.delayMs ?? 10));
|
||||
|
||||
// Emit exit or error
|
||||
if (config.error) {
|
||||
mockProcess.emit("error", config.error);
|
||||
mockProcess.emit('error', config.error);
|
||||
} else {
|
||||
mockProcess.emit("exit", config.exitCode ?? 0);
|
||||
mockProcess.emit('exit', config.exitCode ?? 0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,14 +100,14 @@ describe("subprocess.ts", () => {
|
||||
return mockProcess;
|
||||
}
|
||||
|
||||
describe("spawnJSONLProcess", () => {
|
||||
describe('spawnJSONLProcess', () => {
|
||||
const baseOptions: SubprocessOptions = {
|
||||
command: "test-command",
|
||||
args: ["arg1", "arg2"],
|
||||
cwd: "/test/dir",
|
||||
command: 'test-command',
|
||||
args: ['arg1', 'arg2'],
|
||||
cwd: '/test/dir',
|
||||
};
|
||||
|
||||
it("should yield parsed JSONL objects line by line", async () => {
|
||||
it('should yield parsed JSONL objects line by line', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"start","id":1}',
|
||||
@@ -131,19 +123,14 @@ describe("subprocess.ts", () => {
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0]).toEqual({ type: "start", id: 1 });
|
||||
expect(results[1]).toEqual({ type: "progress", value: 50 });
|
||||
expect(results[2]).toEqual({ type: "complete", result: "success" });
|
||||
expect(results[0]).toEqual({ type: 'start', id: 1 });
|
||||
expect(results[1]).toEqual({ type: 'progress', value: 50 });
|
||||
expect(results[2]).toEqual({ type: 'complete', result: 'success' });
|
||||
});
|
||||
|
||||
it("should skip empty lines", async () => {
|
||||
it('should skip empty lines', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"first"}',
|
||||
"",
|
||||
" ",
|
||||
'{"type":"second"}',
|
||||
],
|
||||
stdoutLines: ['{"type":"first"}', '', ' ', '{"type":"second"}'],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
@@ -153,17 +140,13 @@ describe("subprocess.ts", () => {
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "first" });
|
||||
expect(results[1]).toEqual({ type: "second" });
|
||||
expect(results[0]).toEqual({ type: 'first' });
|
||||
expect(results[1]).toEqual({ type: 'second' });
|
||||
});
|
||||
|
||||
it("should yield error for malformed JSON and continue processing", async () => {
|
||||
it('should yield error for malformed JSON and continue processing', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"valid"}',
|
||||
"{invalid json}",
|
||||
'{"type":"also_valid"}',
|
||||
],
|
||||
stdoutLines: ['{"type":"valid"}', '{invalid json}', '{"type":"also_valid"}'],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
@@ -173,18 +156,18 @@ describe("subprocess.ts", () => {
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0]).toEqual({ type: "valid" });
|
||||
expect(results[0]).toEqual({ type: 'valid' });
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: expect.stringContaining("Failed to parse output"),
|
||||
type: 'error',
|
||||
error: expect.stringContaining('Failed to parse output'),
|
||||
});
|
||||
expect(results[2]).toEqual({ type: "also_valid" });
|
||||
expect(results[2]).toEqual({ type: 'also_valid' });
|
||||
});
|
||||
|
||||
it("should collect stderr output", async () => {
|
||||
it('should collect stderr output', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"test"}'],
|
||||
stderrLines: ["Warning: something happened", "Error: critical issue"],
|
||||
stderrLines: ['Warning: something happened', 'Error: critical issue'],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
@@ -194,17 +177,17 @@ describe("subprocess.ts", () => {
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Warning: something happened")
|
||||
expect.stringContaining('Warning: something happened')
|
||||
);
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error: critical issue")
|
||||
expect.stringContaining('Error: critical issue')
|
||||
);
|
||||
});
|
||||
|
||||
it("should yield error on non-zero exit code", async () => {
|
||||
it('should yield error on non-zero exit code', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"started"}'],
|
||||
stderrLines: ["Process failed with error"],
|
||||
stderrLines: ['Process failed with error'],
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
@@ -214,14 +197,14 @@ describe("subprocess.ts", () => {
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "started" });
|
||||
expect(results[0]).toEqual({ type: 'started' });
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: expect.stringContaining("Process failed with error"),
|
||||
type: 'error',
|
||||
error: expect.stringContaining('Process failed with error'),
|
||||
});
|
||||
});
|
||||
|
||||
it("should yield error with exit code when stderr is empty", async () => {
|
||||
it('should yield error with exit code when stderr is empty', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"test"}'],
|
||||
exitCode: 127,
|
||||
@@ -234,14 +217,14 @@ describe("subprocess.ts", () => {
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: "Process exited with code 127",
|
||||
type: 'error',
|
||||
error: 'Process exited with code 127',
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle process spawn errors", async () => {
|
||||
it('should handle process spawn errors', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
error: new Error("Command not found"),
|
||||
error: new Error('Command not found'),
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
@@ -254,7 +237,7 @@ describe("subprocess.ts", () => {
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("should kill process on AbortController signal", async () => {
|
||||
it('should kill process on AbortController signal', async () => {
|
||||
const abortController = new AbortController();
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"start"}'],
|
||||
@@ -277,53 +260,51 @@ describe("subprocess.ts", () => {
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Abort signal received")
|
||||
);
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('Abort signal received'));
|
||||
});
|
||||
|
||||
it("should spawn process with correct arguments", async () => {
|
||||
it('should spawn process with correct arguments', async () => {
|
||||
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "my-command",
|
||||
args: ["--flag", "value"],
|
||||
cwd: "/work/dir",
|
||||
env: { CUSTOM_VAR: "test" },
|
||||
command: 'my-command',
|
||||
args: ['--flag', 'value'],
|
||||
cwd: '/work/dir',
|
||||
env: { CUSTOM_VAR: 'test' },
|
||||
};
|
||||
|
||||
const generator = spawnJSONLProcess(options);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith("my-command", ["--flag", "value"], {
|
||||
cwd: "/work/dir",
|
||||
env: expect.objectContaining({ CUSTOM_VAR: "test" }),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
expect(cp.spawn).toHaveBeenCalledWith('my-command', ['--flag', 'value'], {
|
||||
cwd: '/work/dir',
|
||||
env: expect.objectContaining({ CUSTOM_VAR: 'test' }),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
});
|
||||
|
||||
it("should merge env with process.env", async () => {
|
||||
it('should merge env with process.env', async () => {
|
||||
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "test",
|
||||
command: 'test',
|
||||
args: [],
|
||||
cwd: "/test",
|
||||
env: { CUSTOM: "value" },
|
||||
cwd: '/test',
|
||||
env: { CUSTOM: 'value' },
|
||||
};
|
||||
|
||||
const generator = spawnJSONLProcess(options);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith(
|
||||
"test",
|
||||
'test',
|
||||
[],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CUSTOM: "value",
|
||||
CUSTOM: 'value',
|
||||
// Should also include existing process.env
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
}),
|
||||
@@ -331,9 +312,9 @@ describe("subprocess.ts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle complex JSON objects", async () => {
|
||||
it('should handle complex JSON objects', async () => {
|
||||
const complexObject = {
|
||||
type: "complex",
|
||||
type: 'complex',
|
||||
nested: { deep: { value: [1, 2, 3] } },
|
||||
array: [{ id: 1 }, { id: 2 }],
|
||||
string: 'with "quotes" and \\backslashes',
|
||||
@@ -354,14 +335,14 @@ describe("subprocess.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnProcess", () => {
|
||||
describe('spawnProcess', () => {
|
||||
const baseOptions: SubprocessOptions = {
|
||||
command: "test-command",
|
||||
args: ["arg1"],
|
||||
cwd: "/test",
|
||||
command: 'test-command',
|
||||
args: ['arg1'],
|
||||
cwd: '/test',
|
||||
};
|
||||
|
||||
it("should collect stdout and stderr", async () => {
|
||||
it('should collect stdout and stderr', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -377,25 +358,25 @@ describe("subprocess.ts", () => {
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
stdout.push("line 1\n");
|
||||
stdout.push("line 2\n");
|
||||
stdout.push('line 1\n');
|
||||
stdout.push('line 2\n');
|
||||
stdout.push(null);
|
||||
|
||||
stderr.push("error 1\n");
|
||||
stderr.push("error 2\n");
|
||||
stderr.push('error 1\n');
|
||||
stderr.push('error 2\n');
|
||||
stderr.push(null);
|
||||
|
||||
mockProcess.emit("exit", 0);
|
||||
mockProcess.emit('exit', 0);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.stdout).toBe("line 1\nline 2\n");
|
||||
expect(result.stderr).toBe("error 1\nerror 2\n");
|
||||
expect(result.stdout).toBe('line 1\nline 2\n');
|
||||
expect(result.stderr).toBe('error 1\nerror 2\n');
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should return correct exit code", async () => {
|
||||
it('should return correct exit code', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -410,7 +391,7 @@ describe("subprocess.ts", () => {
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 42);
|
||||
mockProcess.emit('exit', 42);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
@@ -418,7 +399,7 @@ describe("subprocess.ts", () => {
|
||||
expect(result.exitCode).toBe(42);
|
||||
});
|
||||
|
||||
it("should handle process errors", async () => {
|
||||
it('should handle process errors', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -431,13 +412,13 @@ describe("subprocess.ts", () => {
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.emit("error", new Error("Spawn failed"));
|
||||
mockProcess.emit('error', new Error('Spawn failed'));
|
||||
}, 10);
|
||||
|
||||
await expect(spawnProcess(baseOptions)).rejects.toThrow("Spawn failed");
|
||||
await expect(spawnProcess(baseOptions)).rejects.toThrow('Spawn failed');
|
||||
});
|
||||
|
||||
it("should handle AbortController signal", async () => {
|
||||
it('should handle AbortController signal', async () => {
|
||||
const abortController = new AbortController();
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
@@ -452,14 +433,14 @@ describe("subprocess.ts", () => {
|
||||
|
||||
setTimeout(() => abortController.abort(), 20);
|
||||
|
||||
await expect(
|
||||
spawnProcess({ ...baseOptions, abortController })
|
||||
).rejects.toThrow("Process aborted");
|
||||
await expect(spawnProcess({ ...baseOptions, abortController })).rejects.toThrow(
|
||||
'Process aborted'
|
||||
);
|
||||
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
});
|
||||
|
||||
it("should spawn with correct options", async () => {
|
||||
it('should spawn with correct options', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -474,26 +455,26 @@ describe("subprocess.ts", () => {
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 0);
|
||||
mockProcess.emit('exit', 0);
|
||||
}, 10);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "my-cmd",
|
||||
args: ["--verbose"],
|
||||
cwd: "/my/dir",
|
||||
env: { MY_VAR: "value" },
|
||||
command: 'my-cmd',
|
||||
args: ['--verbose'],
|
||||
cwd: '/my/dir',
|
||||
env: { MY_VAR: 'value' },
|
||||
};
|
||||
|
||||
await spawnProcess(options);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith("my-cmd", ["--verbose"], {
|
||||
cwd: "/my/dir",
|
||||
env: expect.objectContaining({ MY_VAR: "value" }),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
expect(cp.spawn).toHaveBeenCalledWith('my-cmd', ['--verbose'], {
|
||||
cwd: '/my/dir',
|
||||
env: expect.objectContaining({ MY_VAR: 'value' }),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty stdout and stderr", async () => {
|
||||
it('should handle empty stdout and stderr', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -508,13 +489,13 @@ describe("subprocess.ts", () => {
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 0);
|
||||
mockProcess.emit('exit', 0);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.d.ts", "src/index.ts"],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', 'src/index.ts'],
|
||||
thresholds: {
|
||||
// Excellent coverage: 94.69% stmts, 80.48% branches, 97.14% funcs, 94.64% lines
|
||||
// All files now have comprehensive tests
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
IMPROVE_SYSTEM_PROMPT,
|
||||
TECHNICAL_SYSTEM_PROMPT,
|
||||
SIMPLIFY_SYSTEM_PROMPT,
|
||||
ACCEPTANCE_SYSTEM_PROMPT
|
||||
ACCEPTANCE_SYSTEM_PROMPT,
|
||||
} from '@automaker/prompts';
|
||||
|
||||
console.log(IMPROVE_SYSTEM_PROMPT); // Full system prompt for improve mode
|
||||
@@ -50,7 +50,7 @@ import { getEnhancementPrompt } from '@automaker/prompts';
|
||||
const result = getEnhancementPrompt('improve', 'make app faster');
|
||||
|
||||
console.log(result.systemPrompt); // System instructions for improve mode
|
||||
console.log(result.userPrompt); // User prompt with examples and input
|
||||
console.log(result.userPrompt); // User prompt with examples and input
|
||||
```
|
||||
|
||||
#### `getSystemPrompt(mode)`
|
||||
@@ -122,7 +122,7 @@ async function enhanceDescription(description: string, mode: string) {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 1024,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }]
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
});
|
||||
|
||||
return response.content[0].text;
|
||||
@@ -187,7 +187,7 @@ app.post('/api/enhance', async (req, res) => {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 1024,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }]
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
});
|
||||
|
||||
logger.info(`Enhanced with mode: ${mode}`);
|
||||
@@ -207,6 +207,7 @@ Transforms vague or unclear requests into clear, actionable specifications.
|
||||
|
||||
**Before:** "make app faster"
|
||||
**After:** "Optimize application performance by:
|
||||
|
||||
1. Profiling code to identify bottlenecks
|
||||
2. Implementing caching for frequently accessed data
|
||||
3. Optimizing database queries..."
|
||||
@@ -217,6 +218,7 @@ Adds implementation details and technical specifications.
|
||||
|
||||
**Before:** "add search"
|
||||
**After:** "Implement full-text search using:
|
||||
|
||||
- Backend: Elasticsearch or PostgreSQL full-text search
|
||||
- Frontend: Debounced search input with loading states
|
||||
- API: GET /api/search endpoint with pagination..."
|
||||
@@ -234,6 +236,7 @@ Adds testable acceptance criteria to feature descriptions.
|
||||
|
||||
**Before:** "user login"
|
||||
**After:** "User login feature
|
||||
|
||||
- User can enter email and password
|
||||
- System validates credentials
|
||||
- On success: redirect to dashboard
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": ["automaker", "prompts", "ai"],
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"prompts",
|
||||
"ai"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
* Uses chain-of-thought prompting with few-shot examples for consistent results.
|
||||
*/
|
||||
|
||||
import type { EnhancementMode, EnhancementExample } from "@automaker/types";
|
||||
import type { EnhancementMode, EnhancementExample } from '@automaker/types';
|
||||
|
||||
// Re-export enhancement types from shared package
|
||||
export type { EnhancementMode, EnhancementExample } from "@automaker/types";
|
||||
export type { EnhancementMode, EnhancementExample } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* System prompt for the "improve" enhancement mode.
|
||||
@@ -142,7 +142,7 @@ Output the original description followed by a clear "Acceptance Criteria:" secti
|
||||
*/
|
||||
export const IMPROVE_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: "add dark mode",
|
||||
input: 'add dark mode',
|
||||
output: `Add Dark Mode Theme Support
|
||||
|
||||
Implement a dark mode option that allows users to switch between light and dark themes throughout the application.
|
||||
@@ -155,7 +155,7 @@ Requirements:
|
||||
- Ensure smooth transition when switching themes`,
|
||||
},
|
||||
{
|
||||
input: "fix the login bug",
|
||||
input: 'fix the login bug',
|
||||
output: `Fix Login Authentication Flow
|
||||
|
||||
Investigate and resolve issues preventing users from successfully logging in to the application.
|
||||
@@ -168,7 +168,7 @@ Requirements:
|
||||
- Test with various credential combinations (valid, invalid, edge cases)`,
|
||||
},
|
||||
{
|
||||
input: "make it faster",
|
||||
input: 'make it faster',
|
||||
output: `Improve Application Performance
|
||||
|
||||
Optimize application loading and response times to enhance user experience.
|
||||
@@ -188,7 +188,7 @@ Requirements:
|
||||
*/
|
||||
export const TECHNICAL_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: "Add user profile page",
|
||||
input: 'Add user profile page',
|
||||
output: `Add User Profile Page
|
||||
|
||||
Create a dedicated profile page for viewing and editing user information.
|
||||
@@ -209,7 +209,7 @@ Components:
|
||||
Security: Ensure users can only edit their own profile (auth middleware)`,
|
||||
},
|
||||
{
|
||||
input: "Add search functionality",
|
||||
input: 'Add search functionality',
|
||||
output: `Add Search Functionality
|
||||
|
||||
Implement full-text search across application content.
|
||||
@@ -271,7 +271,7 @@ Enhancements:
|
||||
*/
|
||||
export const ACCEPTANCE_EXAMPLES: EnhancementExample[] = [
|
||||
{
|
||||
input: "Add password reset functionality",
|
||||
input: 'Add password reset functionality',
|
||||
output: `Add Password Reset Functionality
|
||||
|
||||
Allow users to reset their password via email when they forget it.
|
||||
@@ -291,7 +291,7 @@ Acceptance Criteria:
|
||||
6. Given a reset link older than 1 hour, when a user clicks it, then they should see an expiration message with option to request a new link.`,
|
||||
},
|
||||
{
|
||||
input: "Shopping cart checkout",
|
||||
input: 'Shopping cart checkout',
|
||||
output: `Shopping Cart Checkout
|
||||
|
||||
Implement the checkout flow for purchasing items in the shopping cart.
|
||||
@@ -348,10 +348,10 @@ export interface EnhancementPromptConfig {
|
||||
* Descriptions for each enhancement mode
|
||||
*/
|
||||
const MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
|
||||
improve: "Transform vague requests into clear, actionable task descriptions",
|
||||
technical: "Add implementation details and technical specifications",
|
||||
simplify: "Make verbose descriptions concise and focused",
|
||||
acceptance: "Add testable acceptance criteria to task descriptions",
|
||||
improve: 'Transform vague requests into clear, actionable task descriptions',
|
||||
technical: 'Add implementation details and technical specifications',
|
||||
simplify: 'Make verbose descriptions concise and focused',
|
||||
acceptance: 'Add testable acceptance criteria to task descriptions',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -362,7 +362,7 @@ const MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
|
||||
*/
|
||||
export function getEnhancementPrompt(mode: string): EnhancementPromptConfig {
|
||||
const normalizedMode = mode.toLowerCase() as EnhancementMode;
|
||||
const validMode = normalizedMode in SYSTEM_PROMPTS ? normalizedMode : "improve";
|
||||
const validMode = normalizedMode in SYSTEM_PROMPTS ? normalizedMode : 'improve';
|
||||
|
||||
return {
|
||||
systemPrompt: SYSTEM_PROMPTS[validMode],
|
||||
@@ -415,7 +415,7 @@ export function buildUserPrompt(
|
||||
(example, index) =>
|
||||
`Example ${index + 1}:\nInput: ${example.input}\nOutput: ${example.output}`
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
return `Here are some examples of how to enhance task descriptions:
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getEnhancementPrompt,
|
||||
getSystemPrompt,
|
||||
@@ -14,93 +14,93 @@ import {
|
||||
TECHNICAL_EXAMPLES,
|
||||
SIMPLIFY_EXAMPLES,
|
||||
ACCEPTANCE_EXAMPLES,
|
||||
} from "../src/enhancement.js";
|
||||
} from '../src/enhancement.js';
|
||||
|
||||
describe("enhancement.ts", () => {
|
||||
describe("System Prompt Constants", () => {
|
||||
it("should export IMPROVE_SYSTEM_PROMPT", () => {
|
||||
describe('enhancement.ts', () => {
|
||||
describe('System Prompt Constants', () => {
|
||||
it('should export IMPROVE_SYSTEM_PROMPT', () => {
|
||||
expect(IMPROVE_SYSTEM_PROMPT).toBeDefined();
|
||||
expect(typeof IMPROVE_SYSTEM_PROMPT).toBe("string");
|
||||
expect(IMPROVE_SYSTEM_PROMPT).toContain("vague, unclear");
|
||||
expect(IMPROVE_SYSTEM_PROMPT).toContain("actionable");
|
||||
expect(typeof IMPROVE_SYSTEM_PROMPT).toBe('string');
|
||||
expect(IMPROVE_SYSTEM_PROMPT).toContain('vague, unclear');
|
||||
expect(IMPROVE_SYSTEM_PROMPT).toContain('actionable');
|
||||
});
|
||||
|
||||
it("should export TECHNICAL_SYSTEM_PROMPT", () => {
|
||||
it('should export TECHNICAL_SYSTEM_PROMPT', () => {
|
||||
expect(TECHNICAL_SYSTEM_PROMPT).toBeDefined();
|
||||
expect(typeof TECHNICAL_SYSTEM_PROMPT).toBe("string");
|
||||
expect(TECHNICAL_SYSTEM_PROMPT).toContain("technical");
|
||||
expect(TECHNICAL_SYSTEM_PROMPT).toContain("implementation");
|
||||
expect(typeof TECHNICAL_SYSTEM_PROMPT).toBe('string');
|
||||
expect(TECHNICAL_SYSTEM_PROMPT).toContain('technical');
|
||||
expect(TECHNICAL_SYSTEM_PROMPT).toContain('implementation');
|
||||
});
|
||||
|
||||
it("should export SIMPLIFY_SYSTEM_PROMPT", () => {
|
||||
it('should export SIMPLIFY_SYSTEM_PROMPT', () => {
|
||||
expect(SIMPLIFY_SYSTEM_PROMPT).toBeDefined();
|
||||
expect(typeof SIMPLIFY_SYSTEM_PROMPT).toBe("string");
|
||||
expect(SIMPLIFY_SYSTEM_PROMPT).toContain("verbose");
|
||||
expect(SIMPLIFY_SYSTEM_PROMPT).toContain("concise");
|
||||
expect(typeof SIMPLIFY_SYSTEM_PROMPT).toBe('string');
|
||||
expect(SIMPLIFY_SYSTEM_PROMPT).toContain('verbose');
|
||||
expect(SIMPLIFY_SYSTEM_PROMPT).toContain('concise');
|
||||
});
|
||||
|
||||
it("should export ACCEPTANCE_SYSTEM_PROMPT", () => {
|
||||
it('should export ACCEPTANCE_SYSTEM_PROMPT', () => {
|
||||
expect(ACCEPTANCE_SYSTEM_PROMPT).toBeDefined();
|
||||
expect(typeof ACCEPTANCE_SYSTEM_PROMPT).toBe("string");
|
||||
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain("acceptance criteria");
|
||||
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain("testable");
|
||||
expect(typeof ACCEPTANCE_SYSTEM_PROMPT).toBe('string');
|
||||
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain('acceptance criteria');
|
||||
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain('testable');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Examples Constants", () => {
|
||||
it("should export IMPROVE_EXAMPLES with valid structure", () => {
|
||||
describe('Examples Constants', () => {
|
||||
it('should export IMPROVE_EXAMPLES with valid structure', () => {
|
||||
expect(IMPROVE_EXAMPLES).toBeDefined();
|
||||
expect(Array.isArray(IMPROVE_EXAMPLES)).toBe(true);
|
||||
expect(IMPROVE_EXAMPLES.length).toBeGreaterThan(0);
|
||||
|
||||
IMPROVE_EXAMPLES.forEach((example) => {
|
||||
expect(example).toHaveProperty("input");
|
||||
expect(example).toHaveProperty("output");
|
||||
expect(typeof example.input).toBe("string");
|
||||
expect(typeof example.output).toBe("string");
|
||||
expect(example).toHaveProperty('input');
|
||||
expect(example).toHaveProperty('output');
|
||||
expect(typeof example.input).toBe('string');
|
||||
expect(typeof example.output).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it("should export TECHNICAL_EXAMPLES with valid structure", () => {
|
||||
it('should export TECHNICAL_EXAMPLES with valid structure', () => {
|
||||
expect(TECHNICAL_EXAMPLES).toBeDefined();
|
||||
expect(Array.isArray(TECHNICAL_EXAMPLES)).toBe(true);
|
||||
expect(TECHNICAL_EXAMPLES.length).toBeGreaterThan(0);
|
||||
|
||||
TECHNICAL_EXAMPLES.forEach((example) => {
|
||||
expect(example).toHaveProperty("input");
|
||||
expect(example).toHaveProperty("output");
|
||||
expect(typeof example.input).toBe("string");
|
||||
expect(typeof example.output).toBe("string");
|
||||
expect(example).toHaveProperty('input');
|
||||
expect(example).toHaveProperty('output');
|
||||
expect(typeof example.input).toBe('string');
|
||||
expect(typeof example.output).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it("should export SIMPLIFY_EXAMPLES with valid structure", () => {
|
||||
it('should export SIMPLIFY_EXAMPLES with valid structure', () => {
|
||||
expect(SIMPLIFY_EXAMPLES).toBeDefined();
|
||||
expect(Array.isArray(SIMPLIFY_EXAMPLES)).toBe(true);
|
||||
expect(SIMPLIFY_EXAMPLES.length).toBeGreaterThan(0);
|
||||
|
||||
SIMPLIFY_EXAMPLES.forEach((example) => {
|
||||
expect(example).toHaveProperty("input");
|
||||
expect(example).toHaveProperty("output");
|
||||
expect(typeof example.input).toBe("string");
|
||||
expect(typeof example.output).toBe("string");
|
||||
expect(example).toHaveProperty('input');
|
||||
expect(example).toHaveProperty('output');
|
||||
expect(typeof example.input).toBe('string');
|
||||
expect(typeof example.output).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it("should export ACCEPTANCE_EXAMPLES with valid structure", () => {
|
||||
it('should export ACCEPTANCE_EXAMPLES with valid structure', () => {
|
||||
expect(ACCEPTANCE_EXAMPLES).toBeDefined();
|
||||
expect(Array.isArray(ACCEPTANCE_EXAMPLES)).toBe(true);
|
||||
expect(ACCEPTANCE_EXAMPLES.length).toBeGreaterThan(0);
|
||||
|
||||
ACCEPTANCE_EXAMPLES.forEach((example) => {
|
||||
expect(example).toHaveProperty("input");
|
||||
expect(example).toHaveProperty("output");
|
||||
expect(typeof example.input).toBe("string");
|
||||
expect(typeof example.output).toBe("string");
|
||||
expect(example).toHaveProperty('input');
|
||||
expect(example).toHaveProperty('output');
|
||||
expect(typeof example.input).toBe('string');
|
||||
expect(typeof example.output).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it("should have shorter outputs in SIMPLIFY_EXAMPLES", () => {
|
||||
it('should have shorter outputs in SIMPLIFY_EXAMPLES', () => {
|
||||
SIMPLIFY_EXAMPLES.forEach((example) => {
|
||||
// Simplify examples should have shorter output than input
|
||||
// (though not always strictly enforced, it's the general pattern)
|
||||
@@ -110,158 +110,158 @@ describe("enhancement.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnhancementPrompt", () => {
|
||||
describe('getEnhancementPrompt', () => {
|
||||
it("should return prompt config for 'improve' mode", () => {
|
||||
const result = getEnhancementPrompt("improve");
|
||||
const result = getEnhancementPrompt('improve');
|
||||
|
||||
expect(result).toHaveProperty("systemPrompt");
|
||||
expect(result).toHaveProperty("description");
|
||||
expect(result).toHaveProperty('systemPrompt');
|
||||
expect(result).toHaveProperty('description');
|
||||
expect(result.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
|
||||
expect(result.description).toContain("vague");
|
||||
expect(result.description).toContain("actionable");
|
||||
expect(result.description).toContain('vague');
|
||||
expect(result.description).toContain('actionable');
|
||||
});
|
||||
|
||||
it("should return prompt config for 'technical' mode", () => {
|
||||
const result = getEnhancementPrompt("technical");
|
||||
const result = getEnhancementPrompt('technical');
|
||||
|
||||
expect(result).toHaveProperty("systemPrompt");
|
||||
expect(result).toHaveProperty("description");
|
||||
expect(result).toHaveProperty('systemPrompt');
|
||||
expect(result).toHaveProperty('description');
|
||||
expect(result.systemPrompt).toBe(TECHNICAL_SYSTEM_PROMPT);
|
||||
expect(result.description).toContain("implementation");
|
||||
expect(result.description).toContain('implementation');
|
||||
});
|
||||
|
||||
it("should return prompt config for 'simplify' mode", () => {
|
||||
const result = getEnhancementPrompt("simplify");
|
||||
const result = getEnhancementPrompt('simplify');
|
||||
|
||||
expect(result).toHaveProperty("systemPrompt");
|
||||
expect(result).toHaveProperty("description");
|
||||
expect(result).toHaveProperty('systemPrompt');
|
||||
expect(result).toHaveProperty('description');
|
||||
expect(result.systemPrompt).toBe(SIMPLIFY_SYSTEM_PROMPT);
|
||||
expect(result.description).toContain("verbose");
|
||||
expect(result.description).toContain('verbose');
|
||||
});
|
||||
|
||||
it("should return prompt config for 'acceptance' mode", () => {
|
||||
const result = getEnhancementPrompt("acceptance");
|
||||
const result = getEnhancementPrompt('acceptance');
|
||||
|
||||
expect(result).toHaveProperty("systemPrompt");
|
||||
expect(result).toHaveProperty("description");
|
||||
expect(result).toHaveProperty('systemPrompt');
|
||||
expect(result).toHaveProperty('description');
|
||||
expect(result.systemPrompt).toBe(ACCEPTANCE_SYSTEM_PROMPT);
|
||||
expect(result.description).toContain("acceptance");
|
||||
expect(result.description).toContain('acceptance');
|
||||
});
|
||||
|
||||
it("should handle uppercase mode", () => {
|
||||
const result = getEnhancementPrompt("IMPROVE");
|
||||
it('should handle uppercase mode', () => {
|
||||
const result = getEnhancementPrompt('IMPROVE');
|
||||
|
||||
expect(result.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
|
||||
});
|
||||
|
||||
it("should handle mixed case mode", () => {
|
||||
const result = getEnhancementPrompt("TeChnIcaL");
|
||||
it('should handle mixed case mode', () => {
|
||||
const result = getEnhancementPrompt('TeChnIcaL');
|
||||
|
||||
expect(result.systemPrompt).toBe(TECHNICAL_SYSTEM_PROMPT);
|
||||
});
|
||||
|
||||
it("should fall back to 'improve' for invalid mode", () => {
|
||||
const result = getEnhancementPrompt("invalid-mode");
|
||||
const result = getEnhancementPrompt('invalid-mode');
|
||||
|
||||
expect(result.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
|
||||
expect(result.description).toContain("vague");
|
||||
expect(result.description).toContain('vague');
|
||||
});
|
||||
|
||||
it("should fall back to 'improve' for empty string", () => {
|
||||
const result = getEnhancementPrompt("");
|
||||
const result = getEnhancementPrompt('');
|
||||
|
||||
expect(result.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSystemPrompt", () => {
|
||||
describe('getSystemPrompt', () => {
|
||||
it("should return IMPROVE_SYSTEM_PROMPT for 'improve'", () => {
|
||||
const result = getSystemPrompt("improve");
|
||||
const result = getSystemPrompt('improve');
|
||||
expect(result).toBe(IMPROVE_SYSTEM_PROMPT);
|
||||
});
|
||||
|
||||
it("should return TECHNICAL_SYSTEM_PROMPT for 'technical'", () => {
|
||||
const result = getSystemPrompt("technical");
|
||||
const result = getSystemPrompt('technical');
|
||||
expect(result).toBe(TECHNICAL_SYSTEM_PROMPT);
|
||||
});
|
||||
|
||||
it("should return SIMPLIFY_SYSTEM_PROMPT for 'simplify'", () => {
|
||||
const result = getSystemPrompt("simplify");
|
||||
const result = getSystemPrompt('simplify');
|
||||
expect(result).toBe(SIMPLIFY_SYSTEM_PROMPT);
|
||||
});
|
||||
|
||||
it("should return ACCEPTANCE_SYSTEM_PROMPT for 'acceptance'", () => {
|
||||
const result = getSystemPrompt("acceptance");
|
||||
const result = getSystemPrompt('acceptance');
|
||||
expect(result).toBe(ACCEPTANCE_SYSTEM_PROMPT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExamples", () => {
|
||||
describe('getExamples', () => {
|
||||
it("should return IMPROVE_EXAMPLES for 'improve'", () => {
|
||||
const result = getExamples("improve");
|
||||
const result = getExamples('improve');
|
||||
expect(result).toBe(IMPROVE_EXAMPLES);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return TECHNICAL_EXAMPLES for 'technical'", () => {
|
||||
const result = getExamples("technical");
|
||||
const result = getExamples('technical');
|
||||
expect(result).toBe(TECHNICAL_EXAMPLES);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return SIMPLIFY_EXAMPLES for 'simplify'", () => {
|
||||
const result = getExamples("simplify");
|
||||
const result = getExamples('simplify');
|
||||
expect(result).toBe(SIMPLIFY_EXAMPLES);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return ACCEPTANCE_EXAMPLES for 'acceptance'", () => {
|
||||
const result = getExamples("acceptance");
|
||||
const result = getExamples('acceptance');
|
||||
expect(result).toBe(ACCEPTANCE_EXAMPLES);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUserPrompt", () => {
|
||||
const testText = "Add a login feature";
|
||||
describe('buildUserPrompt', () => {
|
||||
const testText = 'Add a login feature';
|
||||
|
||||
describe("with examples (default)", () => {
|
||||
describe('with examples (default)', () => {
|
||||
it("should include examples by default for 'improve' mode", () => {
|
||||
const result = buildUserPrompt("improve", testText);
|
||||
const result = buildUserPrompt('improve', testText);
|
||||
|
||||
expect(result).toContain("Here are some examples");
|
||||
expect(result).toContain("Example 1:");
|
||||
expect(result).toContain('Here are some examples');
|
||||
expect(result).toContain('Example 1:');
|
||||
expect(result).toContain(IMPROVE_EXAMPLES[0].input);
|
||||
expect(result).toContain(IMPROVE_EXAMPLES[0].output);
|
||||
expect(result).toContain(testText);
|
||||
});
|
||||
|
||||
it("should include examples by default for 'technical' mode", () => {
|
||||
const result = buildUserPrompt("technical", testText);
|
||||
const result = buildUserPrompt('technical', testText);
|
||||
|
||||
expect(result).toContain("Here are some examples");
|
||||
expect(result).toContain("Example 1:");
|
||||
expect(result).toContain('Here are some examples');
|
||||
expect(result).toContain('Example 1:');
|
||||
expect(result).toContain(TECHNICAL_EXAMPLES[0].input);
|
||||
expect(result).toContain(testText);
|
||||
});
|
||||
|
||||
it("should include examples when explicitly set to true", () => {
|
||||
const result = buildUserPrompt("improve", testText, true);
|
||||
it('should include examples when explicitly set to true', () => {
|
||||
const result = buildUserPrompt('improve', testText, true);
|
||||
|
||||
expect(result).toContain("Here are some examples");
|
||||
expect(result).toContain('Here are some examples');
|
||||
expect(result).toContain(testText);
|
||||
});
|
||||
|
||||
it("should format all examples with numbered labels", () => {
|
||||
const result = buildUserPrompt("improve", testText);
|
||||
it('should format all examples with numbered labels', () => {
|
||||
const result = buildUserPrompt('improve', testText);
|
||||
|
||||
IMPROVE_EXAMPLES.forEach((_, index) => {
|
||||
expect(result).toContain(`Example ${index + 1}:`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should separate examples with dividers", () => {
|
||||
const result = buildUserPrompt("improve", testText);
|
||||
it('should separate examples with dividers', () => {
|
||||
const result = buildUserPrompt('improve', testText);
|
||||
|
||||
// Count dividers (---) - should be (examples.length) + 1
|
||||
const dividerCount = (result.match(/---/g) || []).length;
|
||||
@@ -269,72 +269,74 @@ describe("enhancement.ts", () => {
|
||||
});
|
||||
|
||||
it("should include 'Now, please enhance' before user text", () => {
|
||||
const result = buildUserPrompt("improve", testText);
|
||||
const result = buildUserPrompt('improve', testText);
|
||||
|
||||
expect(result).toContain("Now, please enhance the following");
|
||||
expect(result).toContain('Now, please enhance the following');
|
||||
expect(result).toContain(testText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("without examples", () => {
|
||||
it("should not include examples when includeExamples is false", () => {
|
||||
const result = buildUserPrompt("improve", testText, false);
|
||||
describe('without examples', () => {
|
||||
it('should not include examples when includeExamples is false', () => {
|
||||
const result = buildUserPrompt('improve', testText, false);
|
||||
|
||||
expect(result).not.toContain("Here are some examples");
|
||||
expect(result).not.toContain("Example 1:");
|
||||
expect(result).not.toContain('Here are some examples');
|
||||
expect(result).not.toContain('Example 1:');
|
||||
expect(result).not.toContain(IMPROVE_EXAMPLES[0].input);
|
||||
});
|
||||
|
||||
it("should have simple prompt without examples", () => {
|
||||
const result = buildUserPrompt("improve", testText, false);
|
||||
it('should have simple prompt without examples', () => {
|
||||
const result = buildUserPrompt('improve', testText, false);
|
||||
|
||||
expect(result).toBe(
|
||||
`Please enhance the following task description:\n\n${testText}`
|
||||
);
|
||||
expect(result).toBe(`Please enhance the following task description:\n\n${testText}`);
|
||||
});
|
||||
|
||||
it("should preserve user text without examples", () => {
|
||||
const result = buildUserPrompt("technical", testText, false);
|
||||
it('should preserve user text without examples', () => {
|
||||
const result = buildUserPrompt('technical', testText, false);
|
||||
|
||||
expect(result).toContain(testText);
|
||||
expect(result).toContain("Please enhance");
|
||||
expect(result).toContain('Please enhance');
|
||||
});
|
||||
});
|
||||
|
||||
describe("text formatting", () => {
|
||||
it("should preserve multiline text", () => {
|
||||
const multilineText = "Line 1\nLine 2\nLine 3";
|
||||
const result = buildUserPrompt("improve", multilineText);
|
||||
describe('text formatting', () => {
|
||||
it('should preserve multiline text', () => {
|
||||
const multilineText = 'Line 1\nLine 2\nLine 3';
|
||||
const result = buildUserPrompt('improve', multilineText);
|
||||
|
||||
expect(result).toContain(multilineText);
|
||||
});
|
||||
|
||||
it("should handle empty text", () => {
|
||||
const result = buildUserPrompt("improve", "");
|
||||
it('should handle empty text', () => {
|
||||
const result = buildUserPrompt('improve', '');
|
||||
|
||||
// With examples by default, it should contain "Now, please enhance"
|
||||
expect(result).toContain("Now, please enhance");
|
||||
expect(result).toContain("Here are some examples");
|
||||
expect(result).toContain('Now, please enhance');
|
||||
expect(result).toContain('Here are some examples');
|
||||
});
|
||||
|
||||
it("should handle whitespace-only text", () => {
|
||||
const result = buildUserPrompt("improve", " ");
|
||||
it('should handle whitespace-only text', () => {
|
||||
const result = buildUserPrompt('improve', ' ');
|
||||
|
||||
expect(result).toContain(" ");
|
||||
expect(result).toContain(' ');
|
||||
});
|
||||
|
||||
it("should handle special characters in text", () => {
|
||||
const specialText = "Test <html> & \"quotes\" 'apostrophes'";
|
||||
const result = buildUserPrompt("improve", specialText);
|
||||
it('should handle special characters in text', () => {
|
||||
const specialText = 'Test <html> & "quotes" \'apostrophes\'';
|
||||
const result = buildUserPrompt('improve', specialText);
|
||||
|
||||
expect(result).toContain(specialText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("all modes", () => {
|
||||
it("should work for all valid enhancement modes", () => {
|
||||
const modes: Array<"improve" | "technical" | "simplify" | "acceptance"> =
|
||||
["improve", "technical", "simplify", "acceptance"];
|
||||
describe('all modes', () => {
|
||||
it('should work for all valid enhancement modes', () => {
|
||||
const modes: Array<'improve' | 'technical' | 'simplify' | 'acceptance'> = [
|
||||
'improve',
|
||||
'technical',
|
||||
'simplify',
|
||||
'acceptance',
|
||||
];
|
||||
|
||||
modes.forEach((mode) => {
|
||||
const result = buildUserPrompt(mode, testText);
|
||||
@@ -347,76 +349,76 @@ describe("enhancement.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidEnhancementMode", () => {
|
||||
describe('isValidEnhancementMode', () => {
|
||||
it("should return true for 'improve'", () => {
|
||||
expect(isValidEnhancementMode("improve")).toBe(true);
|
||||
expect(isValidEnhancementMode('improve')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for 'technical'", () => {
|
||||
expect(isValidEnhancementMode("technical")).toBe(true);
|
||||
expect(isValidEnhancementMode('technical')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for 'simplify'", () => {
|
||||
expect(isValidEnhancementMode("simplify")).toBe(true);
|
||||
expect(isValidEnhancementMode('simplify')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for 'acceptance'", () => {
|
||||
expect(isValidEnhancementMode("acceptance")).toBe(true);
|
||||
expect(isValidEnhancementMode('acceptance')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid mode", () => {
|
||||
expect(isValidEnhancementMode("invalid")).toBe(false);
|
||||
it('should return false for invalid mode', () => {
|
||||
expect(isValidEnhancementMode('invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty string", () => {
|
||||
expect(isValidEnhancementMode("")).toBe(false);
|
||||
it('should return false for empty string', () => {
|
||||
expect(isValidEnhancementMode('')).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for uppercase mode", () => {
|
||||
it('should return false for uppercase mode', () => {
|
||||
// Should be case-sensitive since we check object keys directly
|
||||
expect(isValidEnhancementMode("IMPROVE")).toBe(false);
|
||||
expect(isValidEnhancementMode('IMPROVE')).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for mixed case mode", () => {
|
||||
expect(isValidEnhancementMode("ImProve")).toBe(false);
|
||||
it('should return false for mixed case mode', () => {
|
||||
expect(isValidEnhancementMode('ImProve')).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for partial mode names", () => {
|
||||
expect(isValidEnhancementMode("impro")).toBe(false);
|
||||
expect(isValidEnhancementMode("tech")).toBe(false);
|
||||
it('should return false for partial mode names', () => {
|
||||
expect(isValidEnhancementMode('impro')).toBe(false);
|
||||
expect(isValidEnhancementMode('tech')).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for mode with extra characters", () => {
|
||||
expect(isValidEnhancementMode("improve ")).toBe(false);
|
||||
expect(isValidEnhancementMode(" improve")).toBe(false);
|
||||
it('should return false for mode with extra characters', () => {
|
||||
expect(isValidEnhancementMode('improve ')).toBe(false);
|
||||
expect(isValidEnhancementMode(' improve')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableEnhancementModes", () => {
|
||||
it("should return array of all enhancement modes", () => {
|
||||
describe('getAvailableEnhancementModes', () => {
|
||||
it('should return array of all enhancement modes', () => {
|
||||
const modes = getAvailableEnhancementModes();
|
||||
|
||||
expect(Array.isArray(modes)).toBe(true);
|
||||
expect(modes.length).toBe(4);
|
||||
});
|
||||
|
||||
it("should include all valid modes", () => {
|
||||
it('should include all valid modes', () => {
|
||||
const modes = getAvailableEnhancementModes();
|
||||
|
||||
expect(modes).toContain("improve");
|
||||
expect(modes).toContain("technical");
|
||||
expect(modes).toContain("simplify");
|
||||
expect(modes).toContain("acceptance");
|
||||
expect(modes).toContain('improve');
|
||||
expect(modes).toContain('technical');
|
||||
expect(modes).toContain('simplify');
|
||||
expect(modes).toContain('acceptance');
|
||||
});
|
||||
|
||||
it("should return modes in consistent order", () => {
|
||||
it('should return modes in consistent order', () => {
|
||||
const modes1 = getAvailableEnhancementModes();
|
||||
const modes2 = getAvailableEnhancementModes();
|
||||
|
||||
expect(modes1).toEqual(modes2);
|
||||
});
|
||||
|
||||
it("should return all valid modes that pass isValidEnhancementMode", () => {
|
||||
it('should return all valid modes that pass isValidEnhancementMode', () => {
|
||||
const modes = getAvailableEnhancementModes();
|
||||
|
||||
modes.forEach((mode) => {
|
||||
@@ -425,10 +427,10 @@ describe("enhancement.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration tests", () => {
|
||||
it("should work together: getEnhancementPrompt + buildUserPrompt", () => {
|
||||
const mode = "improve";
|
||||
const text = "Add search feature";
|
||||
describe('Integration tests', () => {
|
||||
it('should work together: getEnhancementPrompt + buildUserPrompt', () => {
|
||||
const mode = 'improve';
|
||||
const text = 'Add search feature';
|
||||
|
||||
const { systemPrompt, description } = getEnhancementPrompt(mode);
|
||||
const userPrompt = buildUserPrompt(mode, text);
|
||||
@@ -438,7 +440,7 @@ describe("enhancement.ts", () => {
|
||||
expect(userPrompt).toContain(text);
|
||||
});
|
||||
|
||||
it("should handle complete enhancement workflow", () => {
|
||||
it('should handle complete enhancement workflow', () => {
|
||||
const availableModes = getAvailableEnhancementModes();
|
||||
|
||||
expect(availableModes.length).toBeGreaterThan(0);
|
||||
@@ -455,13 +457,13 @@ describe("enhancement.ts", () => {
|
||||
expect(Array.isArray(examples)).toBe(true);
|
||||
expect(examples.length).toBeGreaterThan(0);
|
||||
|
||||
const userPrompt = buildUserPrompt(mode, "test description");
|
||||
expect(userPrompt).toContain("test description");
|
||||
const userPrompt = buildUserPrompt(mode, 'test description');
|
||||
expect(userPrompt).toContain('test description');
|
||||
});
|
||||
});
|
||||
|
||||
it("should provide consistent data across functions", () => {
|
||||
const mode = "technical";
|
||||
it('should provide consistent data across functions', () => {
|
||||
const mode = 'technical';
|
||||
|
||||
const promptConfig = getEnhancementPrompt(mode);
|
||||
const systemPrompt = getSystemPrompt(mode);
|
||||
@@ -472,8 +474,8 @@ describe("enhancement.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Examples content validation", () => {
|
||||
it("IMPROVE_EXAMPLES should demonstrate improvement", () => {
|
||||
describe('Examples content validation', () => {
|
||||
it('IMPROVE_EXAMPLES should demonstrate improvement', () => {
|
||||
IMPROVE_EXAMPLES.forEach((example) => {
|
||||
// Output should be longer and more detailed than input
|
||||
expect(example.output.length).toBeGreaterThan(example.input.length);
|
||||
@@ -482,43 +484,40 @@ describe("enhancement.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("TECHNICAL_EXAMPLES should contain technical terms", () => {
|
||||
it('TECHNICAL_EXAMPLES should contain technical terms', () => {
|
||||
const technicalTerms = [
|
||||
"API",
|
||||
"endpoint",
|
||||
"component",
|
||||
"database",
|
||||
"frontend",
|
||||
"backend",
|
||||
"validation",
|
||||
"schema",
|
||||
"React",
|
||||
"GET",
|
||||
"PUT",
|
||||
"POST",
|
||||
'API',
|
||||
'endpoint',
|
||||
'component',
|
||||
'database',
|
||||
'frontend',
|
||||
'backend',
|
||||
'validation',
|
||||
'schema',
|
||||
'React',
|
||||
'GET',
|
||||
'PUT',
|
||||
'POST',
|
||||
];
|
||||
|
||||
TECHNICAL_EXAMPLES.forEach((example) => {
|
||||
const hasAnyTechnicalTerm = technicalTerms.some((term) =>
|
||||
example.output.includes(term)
|
||||
);
|
||||
const hasAnyTechnicalTerm = technicalTerms.some((term) => example.output.includes(term));
|
||||
expect(hasAnyTechnicalTerm).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("ACCEPTANCE_EXAMPLES should contain acceptance criteria format", () => {
|
||||
it('ACCEPTANCE_EXAMPLES should contain acceptance criteria format', () => {
|
||||
ACCEPTANCE_EXAMPLES.forEach((example) => {
|
||||
// Should contain numbered criteria or Given-When-Then format
|
||||
const hasAcceptanceCriteria =
|
||||
example.output.includes("Acceptance Criteria") ||
|
||||
example.output.match(/\d+\./g);
|
||||
example.output.includes('Acceptance Criteria') || example.output.match(/\d+\./g);
|
||||
expect(hasAcceptanceCriteria).toBeTruthy();
|
||||
|
||||
// Should contain Given-When-Then format
|
||||
const hasGWT =
|
||||
example.output.includes("Given") &&
|
||||
example.output.includes("when") &&
|
||||
example.output.includes("then");
|
||||
example.output.includes('Given') &&
|
||||
example.output.includes('when') &&
|
||||
example.output.includes('then');
|
||||
expect(hasGWT).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.d.ts", "src/index.ts"],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', 'src/index.ts'],
|
||||
thresholds: {
|
||||
lines: 90,
|
||||
functions: 95,
|
||||
|
||||
@@ -15,6 +15,7 @@ npm install @automaker/types
|
||||
## Exports
|
||||
|
||||
### Provider Types
|
||||
|
||||
Types for AI provider integration and Claude SDK.
|
||||
|
||||
```typescript
|
||||
@@ -26,23 +27,20 @@ import type {
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition
|
||||
ModelDefinition,
|
||||
} from '@automaker/types';
|
||||
```
|
||||
|
||||
### Feature Types
|
||||
|
||||
Feature management and workflow types.
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
Feature,
|
||||
FeatureStatus,
|
||||
PlanningMode,
|
||||
PlanSpec
|
||||
} from '@automaker/types';
|
||||
import type { Feature, FeatureStatus, PlanningMode, PlanSpec } from '@automaker/types';
|
||||
```
|
||||
|
||||
**Feature Interface:**
|
||||
|
||||
- `id` - Unique feature identifier
|
||||
- `category` - Feature category/type
|
||||
- `description` - Feature description
|
||||
@@ -52,6 +50,7 @@ import type {
|
||||
- `planSpec` - Plan specification and approval status
|
||||
|
||||
### Session Types
|
||||
|
||||
Agent session management.
|
||||
|
||||
```typescript
|
||||
@@ -59,39 +58,32 @@ import type {
|
||||
AgentSession,
|
||||
SessionListItem,
|
||||
CreateSessionParams,
|
||||
UpdateSessionParams
|
||||
UpdateSessionParams,
|
||||
} from '@automaker/types';
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
Error classification and handling.
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
ErrorType,
|
||||
ErrorInfo
|
||||
} from '@automaker/types';
|
||||
import type { ErrorType, ErrorInfo } from '@automaker/types';
|
||||
```
|
||||
|
||||
### Image Types
|
||||
|
||||
Image handling for prompts.
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
ImageData,
|
||||
ImageContentBlock
|
||||
} from '@automaker/types';
|
||||
import type { ImageData, ImageContentBlock } from '@automaker/types';
|
||||
```
|
||||
|
||||
### Model Types
|
||||
|
||||
Claude model definitions and mappings.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
CLAUDE_MODEL_MAP,
|
||||
DEFAULT_MODELS,
|
||||
type ModelAlias
|
||||
} from '@automaker/types';
|
||||
import { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias } from '@automaker/types';
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
@@ -105,12 +97,12 @@ const feature: Feature = {
|
||||
description: 'Implement user authentication',
|
||||
dependencies: ['database-setup'],
|
||||
status: 'pending',
|
||||
planningMode: 'spec'
|
||||
planningMode: 'spec',
|
||||
};
|
||||
|
||||
const options: ExecuteOptions = {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
temperature: 0.7
|
||||
temperature: 0.7,
|
||||
};
|
||||
```
|
||||
|
||||
@@ -140,6 +132,7 @@ To maintain the package dependency hierarchy and prevent circular dependencies:
|
||||
4. **Document the rule** - When adding new functionality, ensure it follows this constraint
|
||||
|
||||
This constraint ensures a clean one-way dependency flow:
|
||||
|
||||
```
|
||||
@automaker/types (foundation - no dependencies)
|
||||
↓
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch"
|
||||
},
|
||||
"keywords": ["automaker", "types"],
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"types"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/**
|
||||
* Available enhancement modes for transforming task descriptions
|
||||
*/
|
||||
export type EnhancementMode = "improve" | "technical" | "simplify" | "acceptance";
|
||||
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance';
|
||||
|
||||
/**
|
||||
* Example input/output pair for few-shot learning
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Error type classification
|
||||
*/
|
||||
export type ErrorType = "authentication" | "cancellation" | "abort" | "execution" | "unknown";
|
||||
export type ErrorType = 'authentication' | 'cancellation' | 'abort' | 'execution' | 'unknown';
|
||||
|
||||
/**
|
||||
* Classified error information
|
||||
|
||||
@@ -3,27 +3,27 @@
|
||||
*/
|
||||
|
||||
export type EventType =
|
||||
| "agent:stream"
|
||||
| "auto-mode:event"
|
||||
| "auto-mode:started"
|
||||
| "auto-mode:stopped"
|
||||
| "auto-mode:idle"
|
||||
| "auto-mode:error"
|
||||
| "feature:started"
|
||||
| "feature:completed"
|
||||
| "feature:stopped"
|
||||
| "feature:error"
|
||||
| "feature:progress"
|
||||
| "feature:tool-use"
|
||||
| "feature:follow-up-started"
|
||||
| "feature:follow-up-completed"
|
||||
| "feature:verified"
|
||||
| "feature:committed"
|
||||
| "project:analysis-started"
|
||||
| "project:analysis-progress"
|
||||
| "project:analysis-completed"
|
||||
| "project:analysis-error"
|
||||
| "suggestions:event"
|
||||
| "spec-regeneration:event";
|
||||
| 'agent:stream'
|
||||
| 'auto-mode:event'
|
||||
| 'auto-mode:started'
|
||||
| 'auto-mode:stopped'
|
||||
| 'auto-mode:idle'
|
||||
| 'auto-mode:error'
|
||||
| 'feature:started'
|
||||
| 'feature:completed'
|
||||
| 'feature:stopped'
|
||||
| 'feature:error'
|
||||
| 'feature:progress'
|
||||
| 'feature:tool-use'
|
||||
| 'feature:follow-up-started'
|
||||
| 'feature:follow-up-completed'
|
||||
| 'feature:verified'
|
||||
| 'feature:committed'
|
||||
| 'project:analysis-started'
|
||||
| 'project:analysis-progress'
|
||||
| 'project:analysis-completed'
|
||||
| 'project:analysis-error'
|
||||
| 'suggestions:event'
|
||||
| 'spec-regeneration:event';
|
||||
|
||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||
|
||||
@@ -12,6 +12,15 @@ export interface FeatureImagePath {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FeatureTextFilePath {
|
||||
id: string;
|
||||
path: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
content: string; // Text content of the file
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
id: string;
|
||||
title?: string;
|
||||
@@ -26,6 +35,7 @@ export interface Feature {
|
||||
spec?: string;
|
||||
model?: string;
|
||||
imagePaths?: Array<string | FeatureImagePath | { path: string; [key: string]: unknown }>;
|
||||
textFilePaths?: FeatureTextFilePath[];
|
||||
// Branch info - worktree path is derived at runtime from branchName
|
||||
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
||||
skipTests?: boolean;
|
||||
@@ -45,7 +55,7 @@ export interface Feature {
|
||||
error?: string;
|
||||
summary?: string;
|
||||
startedAt?: string;
|
||||
[key: string]: unknown; // Keep catch-all for extensibility
|
||||
[key: string]: unknown; // Keep catch-all for extensibility
|
||||
}
|
||||
|
||||
export type FeatureStatus = 'pending' | 'running' | 'completed' | 'failed' | 'verified';
|
||||
|
||||
@@ -12,9 +12,9 @@ export interface ImageData {
|
||||
* Content block for image (Claude SDK format)
|
||||
*/
|
||||
export interface ImageContentBlock {
|
||||
type: "image";
|
||||
type: 'image';
|
||||
source: {
|
||||
type: "base64";
|
||||
type: 'base64';
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
@@ -16,11 +16,7 @@ export type {
|
||||
} from './provider.js';
|
||||
|
||||
// Feature types
|
||||
export type {
|
||||
Feature,
|
||||
FeatureImagePath,
|
||||
FeatureStatus,
|
||||
} from './feature.js';
|
||||
export type { Feature, FeatureImagePath, FeatureTextFilePath, FeatureStatus } from './feature.js';
|
||||
|
||||
// Session types
|
||||
export type {
|
||||
@@ -31,44 +27,23 @@ export type {
|
||||
} from './session.js';
|
||||
|
||||
// Error types
|
||||
export type {
|
||||
ErrorType,
|
||||
ErrorInfo,
|
||||
} from './error.js';
|
||||
export type { ErrorType, ErrorInfo } from './error.js';
|
||||
|
||||
// Image types
|
||||
export type {
|
||||
ImageData,
|
||||
ImageContentBlock,
|
||||
} from './image.js';
|
||||
export type { ImageData, ImageContentBlock } from './image.js';
|
||||
|
||||
// Model types and constants
|
||||
export {
|
||||
CLAUDE_MODEL_MAP,
|
||||
DEFAULT_MODELS,
|
||||
type ModelAlias,
|
||||
type AgentModel,
|
||||
} from './model.js';
|
||||
export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias, type AgentModel } from './model.js';
|
||||
|
||||
// Event types
|
||||
export type {
|
||||
EventType,
|
||||
EventCallback,
|
||||
} from './event.js';
|
||||
export type { EventType, EventCallback } from './event.js';
|
||||
|
||||
// Spec types
|
||||
export type {
|
||||
SpecOutput,
|
||||
} from './spec.js';
|
||||
export {
|
||||
specOutputSchema,
|
||||
} from './spec.js';
|
||||
export type { SpecOutput } from './spec.js';
|
||||
export { specOutputSchema } from './spec.js';
|
||||
|
||||
// Enhancement types
|
||||
export type {
|
||||
EnhancementMode,
|
||||
EnhancementExample,
|
||||
} from './enhancement.js';
|
||||
export type { EnhancementMode, EnhancementExample } from './enhancement.js';
|
||||
|
||||
// Settings types and constants
|
||||
export type {
|
||||
@@ -99,10 +74,7 @@ export {
|
||||
} from './settings.js';
|
||||
|
||||
// Model display constants
|
||||
export type {
|
||||
ModelOption,
|
||||
ThinkingLevelOption,
|
||||
} from './model-display.js';
|
||||
export type { ModelOption, ThinkingLevelOption } from './model-display.js';
|
||||
export {
|
||||
CLAUDE_MODELS,
|
||||
THINKING_LEVELS,
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ModelOption {
|
||||
/** Optional badge text (e.g., "Speed", "Balanced", "Premium") */
|
||||
badge?: string;
|
||||
/** AI provider (currently only "claude") */
|
||||
provider: "claude";
|
||||
provider: 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,25 +40,25 @@ export interface ThinkingLevelOption {
|
||||
*/
|
||||
export const CLAUDE_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: "haiku",
|
||||
label: "Claude Haiku",
|
||||
description: "Fast and efficient for simple tasks.",
|
||||
badge: "Speed",
|
||||
provider: "claude",
|
||||
id: 'haiku',
|
||||
label: 'Claude Haiku',
|
||||
description: 'Fast and efficient for simple tasks.',
|
||||
badge: 'Speed',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: "sonnet",
|
||||
label: "Claude Sonnet",
|
||||
description: "Balanced performance with strong reasoning.",
|
||||
badge: "Balanced",
|
||||
provider: "claude",
|
||||
id: 'sonnet',
|
||||
label: 'Claude Sonnet',
|
||||
description: 'Balanced performance with strong reasoning.',
|
||||
badge: 'Balanced',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: "opus",
|
||||
label: "Claude Opus",
|
||||
description: "Most capable model for complex work.",
|
||||
badge: "Premium",
|
||||
provider: "claude",
|
||||
id: 'opus',
|
||||
label: 'Claude Opus',
|
||||
description: 'Most capable model for complex work.',
|
||||
badge: 'Premium',
|
||||
provider: 'claude',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -68,11 +68,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
|
||||
* Ordered from least to most intensive reasoning.
|
||||
*/
|
||||
export const THINKING_LEVELS: ThinkingLevelOption[] = [
|
||||
{ id: "none", label: "None" },
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "ultrathink", label: "Ultrathink" },
|
||||
{ id: 'none', label: 'None' },
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'ultrathink', label: 'Ultrathink' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -81,11 +81,11 @@ export const THINKING_LEVELS: ThinkingLevelOption[] = [
|
||||
* Used for compact UI elements like badges or dropdowns.
|
||||
*/
|
||||
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
none: "None",
|
||||
low: "Low",
|
||||
medium: "Med",
|
||||
high: "High",
|
||||
ultrathink: "Ultra",
|
||||
none: 'None',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
ultrathink: 'Ultra',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -103,9 +103,9 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
*/
|
||||
export function getModelDisplayName(model: AgentModel | string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
haiku: "Claude Haiku",
|
||||
sonnet: "Claude Sonnet",
|
||||
opus: "Claude Opus",
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
};
|
||||
return displayNames[model] || model;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* Model alias mapping for Claude models
|
||||
*/
|
||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
haiku: "claude-haiku-4-5",
|
||||
sonnet: "claude-sonnet-4-20250514",
|
||||
opus: "claude-opus-4-5-20251101",
|
||||
haiku: 'claude-haiku-4-5-20251001',
|
||||
sonnet: 'claude-sonnet-4-5-20250929',
|
||||
opus: 'claude-opus-4-5-20251101',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default models per provider
|
||||
*/
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: "claude-opus-4-5-20251101",
|
||||
claude: 'claude-opus-4-5-20251101',
|
||||
} as const;
|
||||
|
||||
export type ModelAlias = keyof typeof CLAUDE_MODEL_MAP;
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ProviderConfig {
|
||||
* Message in conversation history
|
||||
*/
|
||||
export interface ConversationMessage {
|
||||
role: "user" | "assistant";
|
||||
role: 'user' | 'assistant';
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ExecuteOptions {
|
||||
* Content block in a provider message (matches Claude SDK format)
|
||||
*/
|
||||
export interface ContentBlock {
|
||||
type: "text" | "tool_use" | "thinking" | "tool_result";
|
||||
type: 'text' | 'tool_use' | 'thinking' | 'tool_result';
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
name?: string;
|
||||
@@ -52,11 +52,11 @@ export interface ContentBlock {
|
||||
* Message returned by a provider (matches Claude SDK streaming format)
|
||||
*/
|
||||
export interface ProviderMessage {
|
||||
type: "assistant" | "user" | "error" | "result";
|
||||
subtype?: "success" | "error";
|
||||
type: 'assistant' | 'user' | 'error' | 'result';
|
||||
subtype?: 'success' | 'error';
|
||||
session_id?: string;
|
||||
message?: {
|
||||
role: "user" | "assistant";
|
||||
role: 'user' | 'assistant';
|
||||
content: ContentBlock[];
|
||||
};
|
||||
result?: string;
|
||||
@@ -71,7 +71,7 @@ export interface InstallationStatus {
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
method?: "cli" | "npm" | "brew" | "sdk";
|
||||
method?: 'cli' | 'npm' | 'brew' | 'sdk';
|
||||
hasApiKey?: boolean;
|
||||
authenticated?: boolean;
|
||||
error?: string;
|
||||
@@ -99,6 +99,6 @@ export interface ModelDefinition {
|
||||
maxOutputTokens?: number;
|
||||
supportsVision?: boolean;
|
||||
supportsTools?: boolean;
|
||||
tier?: "basic" | "standard" | "premium";
|
||||
tier?: 'basic' | 'standard' | 'premium';
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface SpecOutput {
|
||||
development_guidelines?: string[];
|
||||
implementation_roadmap?: Array<{
|
||||
phase: string;
|
||||
status: "completed" | "in_progress" | "pending";
|
||||
status: 'completed' | 'in_progress' | 'pending';
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
@@ -29,91 +29,90 @@ export interface SpecOutput {
|
||||
* Used with Claude's structured output feature for reliable parsing
|
||||
*/
|
||||
export const specOutputSchema = {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
project_name: {
|
||||
type: "string",
|
||||
description: "The name of the project",
|
||||
type: 'string',
|
||||
description: 'The name of the project',
|
||||
},
|
||||
overview: {
|
||||
type: "string",
|
||||
type: 'string',
|
||||
description:
|
||||
"A comprehensive description of what the project does, its purpose, and key goals",
|
||||
'A comprehensive description of what the project does, its purpose, and key goals',
|
||||
},
|
||||
technology_stack: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"List of all technologies, frameworks, libraries, and tools used",
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of all technologies, frameworks, libraries, and tools used',
|
||||
},
|
||||
core_capabilities: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "List of main features and capabilities the project provides",
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of main features and capabilities the project provides',
|
||||
},
|
||||
implemented_features: {
|
||||
type: "array",
|
||||
type: 'array',
|
||||
items: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Name of the implemented feature",
|
||||
type: 'string',
|
||||
description: 'Name of the implemented feature',
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Description of what the feature does",
|
||||
type: 'string',
|
||||
description: 'Description of what the feature does',
|
||||
},
|
||||
file_locations: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "File paths where this feature is implemented",
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'File paths where this feature is implemented',
|
||||
},
|
||||
},
|
||||
required: ["name", "description"],
|
||||
required: ['name', 'description'],
|
||||
},
|
||||
description: "Features that have been implemented based on code analysis",
|
||||
description: 'Features that have been implemented based on code analysis',
|
||||
},
|
||||
additional_requirements: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Any additional requirements or constraints",
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Any additional requirements or constraints',
|
||||
},
|
||||
development_guidelines: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Development standards and practices",
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Development standards and practices',
|
||||
},
|
||||
implementation_roadmap: {
|
||||
type: "array",
|
||||
type: 'array',
|
||||
items: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
phase: {
|
||||
type: "string",
|
||||
description: "Name of the implementation phase",
|
||||
type: 'string',
|
||||
description: 'Name of the implementation phase',
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["completed", "in_progress", "pending"],
|
||||
description: "Current status of this phase",
|
||||
type: 'string',
|
||||
enum: ['completed', 'in_progress', 'pending'],
|
||||
description: 'Current status of this phase',
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Description of what this phase involves",
|
||||
type: 'string',
|
||||
description: 'Description of what this phase involves',
|
||||
},
|
||||
},
|
||||
required: ["phase", "status", "description"],
|
||||
required: ['phase', 'status', 'description'],
|
||||
},
|
||||
description: "Phases or roadmap items for implementation",
|
||||
description: 'Phases or roadmap items for implementation',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
"project_name",
|
||||
"overview",
|
||||
"technology_stack",
|
||||
"core_capabilities",
|
||||
"implemented_features",
|
||||
'project_name',
|
||||
'overview',
|
||||
'technology_stack',
|
||||
'core_capabilities',
|
||||
'implemented_features',
|
||||
],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ npm install @automaker/utils
|
||||
## Exports
|
||||
|
||||
### Logger
|
||||
|
||||
Structured logging with context.
|
||||
|
||||
```typescript
|
||||
@@ -27,6 +28,7 @@ logger.debug('Debug information', { data });
|
||||
```
|
||||
|
||||
### Error Handler
|
||||
|
||||
Error classification and user-friendly messages.
|
||||
|
||||
```typescript
|
||||
@@ -35,7 +37,7 @@ import {
|
||||
isCancellationError,
|
||||
isAuthenticationError,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage
|
||||
getUserFriendlyErrorMessage,
|
||||
} from '@automaker/utils';
|
||||
|
||||
try {
|
||||
@@ -51,6 +53,7 @@ try {
|
||||
```
|
||||
|
||||
### Conversation Utils
|
||||
|
||||
Message formatting and conversion.
|
||||
|
||||
```typescript
|
||||
@@ -58,7 +61,7 @@ import {
|
||||
extractTextFromContent,
|
||||
normalizeContentBlocks,
|
||||
formatHistoryAsText,
|
||||
convertHistoryToMessages
|
||||
convertHistoryToMessages,
|
||||
} from '@automaker/utils';
|
||||
|
||||
const text = extractTextFromContent(contentBlocks);
|
||||
@@ -68,6 +71,7 @@ const converted = convertHistoryToMessages(history);
|
||||
```
|
||||
|
||||
### Image Handler
|
||||
|
||||
Image processing for Claude prompts.
|
||||
|
||||
```typescript
|
||||
@@ -75,7 +79,7 @@ import {
|
||||
getMimeTypeForImage,
|
||||
readImageAsBase64,
|
||||
convertImagesToContentBlocks,
|
||||
formatImagePathsForPrompt
|
||||
formatImagePathsForPrompt,
|
||||
} from '@automaker/utils';
|
||||
|
||||
const mimeType = getMimeTypeForImage('screenshot.png');
|
||||
@@ -85,6 +89,7 @@ const formatted = formatImagePathsForPrompt(imagePaths);
|
||||
```
|
||||
|
||||
### Prompt Builder
|
||||
|
||||
Build prompts with images for Claude.
|
||||
|
||||
```typescript
|
||||
@@ -93,7 +98,7 @@ import { buildPromptWithImages } from '@automaker/utils';
|
||||
const result = await buildPromptWithImages({
|
||||
basePrompt: 'Analyze this screenshot',
|
||||
imagePaths: ['/path/to/screenshot.png'],
|
||||
basePath: '/project/path'
|
||||
basePath: '/project/path',
|
||||
});
|
||||
|
||||
console.log(result.prompt); // Prompt with image references
|
||||
@@ -101,15 +106,11 @@ console.log(result.images); // Image data for Claude
|
||||
```
|
||||
|
||||
### File System Utils
|
||||
|
||||
Common file system operations.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ensureDir,
|
||||
fileExists,
|
||||
readJsonFile,
|
||||
writeJsonFile
|
||||
} from '@automaker/utils';
|
||||
import { ensureDir, fileExists, readJsonFile, writeJsonFile } from '@automaker/utils';
|
||||
|
||||
await ensureDir('/path/to/dir');
|
||||
const exists = await fileExists('/path/to/file');
|
||||
@@ -131,7 +132,7 @@ async function executeWithImages(prompt: string, images: string[]) {
|
||||
const result = await buildPromptWithImages({
|
||||
basePrompt: prompt,
|
||||
imagePaths: images,
|
||||
basePath: process.cwd()
|
||||
basePath: process.cwd(),
|
||||
});
|
||||
|
||||
logger.debug('Prompt built successfully', { imageCount: result.images.length });
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": ["automaker", "utils"],
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"utils"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
|
||||
238
libs/utils/src/context-loader.ts
Normal file
238
libs/utils/src/context-loader.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Context Loader - Loads project context files for agent prompts
|
||||
*
|
||||
* Provides a shared utility to load context files from .automaker/context/
|
||||
* and format them as system prompt content. Used by both auto-mode-service
|
||||
* and agent-service to ensure all agents are aware of project context.
|
||||
*
|
||||
* Context files contain project-specific rules, conventions, and guidelines
|
||||
* that agents must follow when working on the project.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Metadata structure for context files
|
||||
* Stored in {projectPath}/.automaker/context/context-metadata.json
|
||||
*/
|
||||
export interface ContextMetadata {
|
||||
files: Record<string, { description: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual context file with metadata
|
||||
*/
|
||||
export interface ContextFileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
content: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of loading context files
|
||||
*/
|
||||
export interface ContextFilesResult {
|
||||
files: ContextFileInfo[];
|
||||
formattedPrompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for loading context files
|
||||
*/
|
||||
export interface LoadContextFilesOptions {
|
||||
/** Project path to load context from */
|
||||
projectPath: string;
|
||||
/** Optional custom secure fs module (for dependency injection) */
|
||||
fsModule?: {
|
||||
access: (path: string) => Promise<void>;
|
||||
readdir: (path: string) => Promise<string[]>;
|
||||
readFile: (path: string, encoding: string) => Promise<string>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context directory path for a project
|
||||
*/
|
||||
function getContextDir(projectPath: string): string {
|
||||
return path.join(projectPath, '.automaker', 'context');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load context metadata from the metadata file
|
||||
*/
|
||||
async function loadContextMetadata(
|
||||
contextDir: string,
|
||||
fsModule: typeof fs
|
||||
): Promise<ContextMetadata> {
|
||||
const metadataPath = path.join(contextDir, 'context-metadata.json');
|
||||
try {
|
||||
const content = await fsModule.readFile(metadataPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
// Metadata file doesn't exist yet - that's fine
|
||||
return { files: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single context file entry for the prompt
|
||||
*/
|
||||
function formatContextFileEntry(file: ContextFileInfo): string {
|
||||
const header = `## ${file.name}`;
|
||||
const pathInfo = `**Path:** \`${file.path}\``;
|
||||
|
||||
let descriptionInfo = '';
|
||||
if (file.description) {
|
||||
descriptionInfo = `\n**Purpose:** ${file.description}`;
|
||||
}
|
||||
|
||||
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the formatted system prompt from context files
|
||||
*/
|
||||
function buildContextPrompt(files: ContextFileInfo[]): string {
|
||||
if (files.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const formattedFiles = files.map(formatContextFileEntry);
|
||||
|
||||
return `# Project Context Files
|
||||
|
||||
The following context files provide project-specific rules, conventions, and guidelines.
|
||||
Each file serves a specific purpose - use the description to understand when to reference it.
|
||||
If you need more details about a context file, you can read the full file at the path provided.
|
||||
|
||||
**IMPORTANT**: You MUST follow the rules and conventions specified in these files.
|
||||
- Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`)
|
||||
- Follow ALL coding conventions, commit message formats, and architectural patterns specified
|
||||
- Reference these rules before running ANY shell commands or making commits
|
||||
|
||||
---
|
||||
|
||||
${formattedFiles.join('\n\n---\n\n')}
|
||||
|
||||
---
|
||||
|
||||
**REMINDER**: Before taking any action, verify you are following the conventions specified above.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load context files from a project's .automaker/context/ directory
|
||||
*
|
||||
* This function loads all .md and .txt files from the context directory,
|
||||
* along with their metadata (descriptions), and formats them into a
|
||||
* system prompt that can be prepended to agent prompts.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns Promise resolving to context files and formatted prompt
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { formattedPrompt, files } = await loadContextFiles({
|
||||
* projectPath: '/path/to/project'
|
||||
* });
|
||||
*
|
||||
* // Use as system prompt
|
||||
* const executeOptions = {
|
||||
* prompt: userPrompt,
|
||||
* systemPrompt: formattedPrompt,
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export async function loadContextFiles(
|
||||
options: LoadContextFilesOptions
|
||||
): Promise<ContextFilesResult> {
|
||||
const { projectPath, fsModule = fs } = options;
|
||||
const contextDir = path.resolve(getContextDir(projectPath));
|
||||
|
||||
try {
|
||||
// Check if directory exists
|
||||
await fsModule.access(contextDir);
|
||||
|
||||
// Read directory contents
|
||||
const allFiles = await fsModule.readdir(contextDir);
|
||||
|
||||
// Filter for text-based context files (case-insensitive for cross-platform)
|
||||
const textFiles = allFiles.filter((f) => {
|
||||
const lower = f.toLowerCase();
|
||||
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
|
||||
});
|
||||
|
||||
if (textFiles.length === 0) {
|
||||
return { files: [], formattedPrompt: '' };
|
||||
}
|
||||
|
||||
// Load metadata for descriptions
|
||||
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
|
||||
|
||||
// Load each file with its content and metadata
|
||||
const files: ContextFileInfo[] = [];
|
||||
for (const fileName of textFiles) {
|
||||
const filePath = path.join(contextDir, fileName);
|
||||
try {
|
||||
const content = await fsModule.readFile(filePath, 'utf-8');
|
||||
files.push({
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
content,
|
||||
description: metadata.files[fileName]?.description,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedPrompt = buildContextPrompt(files);
|
||||
|
||||
console.log(
|
||||
`[ContextLoader] Loaded ${files.length} context file(s): ${files.map((f) => f.name).join(', ')}`
|
||||
);
|
||||
|
||||
return { files, formattedPrompt };
|
||||
} catch {
|
||||
// Context directory doesn't exist or is inaccessible - this is fine
|
||||
return { files: [], formattedPrompt: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of available context files (names and descriptions only)
|
||||
* Useful for informing the agent about what context is available without
|
||||
* loading full content.
|
||||
*/
|
||||
export async function getContextFilesSummary(
|
||||
options: LoadContextFilesOptions
|
||||
): Promise<Array<{ name: string; path: string; description?: string }>> {
|
||||
const { projectPath, fsModule = fs } = options;
|
||||
const contextDir = path.resolve(getContextDir(projectPath));
|
||||
|
||||
try {
|
||||
await fsModule.access(contextDir);
|
||||
const allFiles = await fsModule.readdir(contextDir);
|
||||
|
||||
const textFiles = allFiles.filter((f) => {
|
||||
const lower = f.toLowerCase();
|
||||
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
|
||||
});
|
||||
|
||||
if (textFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
|
||||
|
||||
return textFiles.map((fileName) => ({
|
||||
name: fileName,
|
||||
path: path.join(contextDir, fileName),
|
||||
description: metadata.files[fileName]?.description,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,15 @@ import type { ConversationMessage } from '@automaker/types';
|
||||
export function extractTextFromContent(
|
||||
content: string | Array<{ type: string; text?: string; source?: object }>
|
||||
): string {
|
||||
if (typeof content === "string") {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Extract text blocks only
|
||||
return content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text || "")
|
||||
.join("\n");
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.text || '')
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ export function normalizeContentBlocks(
|
||||
if (Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
return [{ type: "text", text: content }];
|
||||
return [{ type: 'text', text: content }];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,18 +53,18 @@ export function normalizeContentBlocks(
|
||||
*/
|
||||
export function formatHistoryAsText(history: ConversationMessage[]): string {
|
||||
if (history.length === 0) {
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
|
||||
let historyText = "Previous conversation:\n\n";
|
||||
let historyText = 'Previous conversation:\n\n';
|
||||
|
||||
for (const msg of history) {
|
||||
const contentText = extractTextFromContent(msg.content);
|
||||
const role = msg.role === "user" ? "User" : "Assistant";
|
||||
const role = msg.role === 'user' ? 'User' : 'Assistant';
|
||||
historyText += `${role}: ${contentText}\n\n`;
|
||||
}
|
||||
|
||||
historyText += "---\n\n";
|
||||
historyText += '---\n\n';
|
||||
return historyText;
|
||||
}
|
||||
|
||||
@@ -74,20 +74,18 @@ export function formatHistoryAsText(history: ConversationMessage[]): string {
|
||||
* @param history - Array of conversation messages
|
||||
* @returns Array of Claude SDK formatted messages
|
||||
*/
|
||||
export function convertHistoryToMessages(
|
||||
history: ConversationMessage[]
|
||||
): Array<{
|
||||
type: "user" | "assistant";
|
||||
export function convertHistoryToMessages(history: ConversationMessage[]): Array<{
|
||||
type: 'user' | 'assistant';
|
||||
session_id: string;
|
||||
message: {
|
||||
role: "user" | "assistant";
|
||||
role: 'user' | 'assistant';
|
||||
content: Array<{ type: string; text?: string; source?: object }>;
|
||||
};
|
||||
parent_tool_use_id: null;
|
||||
}> {
|
||||
return history.map((historyMsg) => ({
|
||||
type: historyMsg.role,
|
||||
session_id: "",
|
||||
session_id: '',
|
||||
message: {
|
||||
role: historyMsg.role,
|
||||
content: normalizeContentBlocks(historyMsg.content),
|
||||
|
||||
@@ -17,10 +17,7 @@ import type { ErrorType, ErrorInfo } from '@automaker/types';
|
||||
* @returns True if the error is an abort error
|
||||
*/
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || error.message.includes("abort"))
|
||||
);
|
||||
return error instanceof Error && (error.name === 'AbortError' || error.message.includes('abort'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,10 +29,10 @@ export function isAbortError(error: unknown): boolean {
|
||||
export function isCancellationError(errorMessage: string): boolean {
|
||||
const lowerMessage = errorMessage.toLowerCase();
|
||||
return (
|
||||
lowerMessage.includes("cancelled") ||
|
||||
lowerMessage.includes("canceled") ||
|
||||
lowerMessage.includes("stopped") ||
|
||||
lowerMessage.includes("aborted")
|
||||
lowerMessage.includes('cancelled') ||
|
||||
lowerMessage.includes('canceled') ||
|
||||
lowerMessage.includes('stopped') ||
|
||||
lowerMessage.includes('aborted')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +44,10 @@ export function isCancellationError(errorMessage: string): boolean {
|
||||
*/
|
||||
export function isAuthenticationError(errorMessage: string): boolean {
|
||||
return (
|
||||
errorMessage.includes("Authentication failed") ||
|
||||
errorMessage.includes("Invalid API key") ||
|
||||
errorMessage.includes("authentication_failed") ||
|
||||
errorMessage.includes("Fix external API key")
|
||||
errorMessage.includes('Authentication failed') ||
|
||||
errorMessage.includes('Invalid API key') ||
|
||||
errorMessage.includes('authentication_failed') ||
|
||||
errorMessage.includes('Fix external API key')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,22 +58,22 @@ export function isAuthenticationError(errorMessage: string): boolean {
|
||||
* @returns Classified error information
|
||||
*/
|
||||
export function classifyError(error: unknown): ErrorInfo {
|
||||
const message = error instanceof Error ? error.message : String(error || "Unknown error");
|
||||
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
|
||||
const isAbort = isAbortError(error);
|
||||
const isAuth = isAuthenticationError(message);
|
||||
const isCancellation = isCancellationError(message);
|
||||
|
||||
let type: ErrorType;
|
||||
if (isAuth) {
|
||||
type = "authentication";
|
||||
type = 'authentication';
|
||||
} else if (isAbort) {
|
||||
type = "abort";
|
||||
type = 'abort';
|
||||
} else if (isCancellation) {
|
||||
type = "cancellation";
|
||||
type = 'cancellation';
|
||||
} else if (error instanceof Error) {
|
||||
type = "execution";
|
||||
type = 'execution';
|
||||
} else {
|
||||
type = "unknown";
|
||||
type = 'unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -99,11 +96,11 @@ export function getUserFriendlyErrorMessage(error: unknown): string {
|
||||
const info = classifyError(error);
|
||||
|
||||
if (info.isAbort) {
|
||||
return "Operation was cancelled";
|
||||
return 'Operation was cancelled';
|
||||
}
|
||||
|
||||
if (info.isAuth) {
|
||||
return "Authentication failed. Please check your API key.";
|
||||
return 'Authentication failed. Please check your API key.';
|
||||
}
|
||||
|
||||
return info.message;
|
||||
@@ -130,5 +127,5 @@ export function getUserFriendlyErrorMessage(error: unknown): string {
|
||||
* ```
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Unknown error";
|
||||
return error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* File system utilities that handle symlinks safely
|
||||
*/
|
||||
|
||||
import { secureFs } from "@automaker/platform";
|
||||
import path from "path";
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Create a directory, handling symlinks safely to avoid ELOOP errors.
|
||||
@@ -23,10 +23,10 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
|
||||
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
|
||||
} catch (error: any) {
|
||||
// ENOENT means path doesn't exist - we should create it
|
||||
if (error.code !== "ENOENT") {
|
||||
if (error.code !== 'ENOENT') {
|
||||
// Some other error (could be ELOOP in parent path)
|
||||
// If it's ELOOP, the path involves symlinks - don't try to create
|
||||
if (error.code === "ELOOP") {
|
||||
if (error.code === 'ELOOP') {
|
||||
console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`);
|
||||
return;
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
|
||||
await secureFs.mkdir(resolvedPath, { recursive: true });
|
||||
} catch (error: any) {
|
||||
// Handle race conditions and symlink issues
|
||||
if (error.code === "EEXIST" || error.code === "ELOOP") {
|
||||
if (error.code === 'EEXIST' || error.code === 'ELOOP') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
@@ -55,11 +55,11 @@ export async function existsSafe(filePath: string): Promise<boolean> {
|
||||
await secureFs.lstat(filePath);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
if (error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
// ELOOP or other errors - path exists but is problematic
|
||||
if (error.code === "ELOOP") {
|
||||
if (error.code === 'ELOOP') {
|
||||
return true; // Symlink exists, even if looping
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
* - Path resolution (relative/absolute)
|
||||
*/
|
||||
|
||||
import { secureFs } from "@automaker/platform";
|
||||
import path from "path";
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import type { ImageData, ImageContentBlock } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* MIME type mapping for image file extensions
|
||||
*/
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
*/
|
||||
export function getMimeTypeForImage(imagePath: string): string {
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
return IMAGE_MIME_TYPES[ext] || "image/png";
|
||||
return IMAGE_MIME_TYPES[ext] || 'image/png';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,8 +42,8 @@ export function getMimeTypeForImage(imagePath: string): string {
|
||||
* @throws Error if file cannot be read
|
||||
*/
|
||||
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
|
||||
const imageBuffer = await secureFs.readFile(imagePath) as Buffer;
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const imageBuffer = (await secureFs.readFile(imagePath)) as Buffer;
|
||||
const base64Data = imageBuffer.toString('base64');
|
||||
const mimeType = getMimeTypeForImage(imagePath);
|
||||
|
||||
return {
|
||||
@@ -71,16 +71,15 @@ export async function convertImagesToContentBlocks(
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
// Resolve to absolute path if needed
|
||||
const absolutePath = workDir && !path.isAbsolute(imagePath)
|
||||
? path.join(workDir, imagePath)
|
||||
: imagePath;
|
||||
const absolutePath =
|
||||
workDir && !path.isAbsolute(imagePath) ? path.join(workDir, imagePath) : imagePath;
|
||||
|
||||
const imageData = await readImageAsBase64(absolutePath);
|
||||
|
||||
blocks.push({
|
||||
type: "image",
|
||||
type: 'image',
|
||||
source: {
|
||||
type: "base64",
|
||||
type: 'base64',
|
||||
media_type: imageData.mimeType,
|
||||
data: imageData.base64,
|
||||
},
|
||||
@@ -103,10 +102,10 @@ export async function convertImagesToContentBlocks(
|
||||
*/
|
||||
export function formatImagePathsForPrompt(imagePaths: string[]): string {
|
||||
if (imagePaths.length === 0) {
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
|
||||
let text = "\n\nAttached images:\n";
|
||||
let text = '\n\nAttached images:\n';
|
||||
for (const imagePath of imagePaths) {
|
||||
text += `- ${imagePath}\n`;
|
||||
}
|
||||
|
||||
@@ -37,21 +37,20 @@ export {
|
||||
} from './prompt-builder.js';
|
||||
|
||||
// Logger
|
||||
export {
|
||||
createLogger,
|
||||
getLogLevel,
|
||||
setLogLevel,
|
||||
LogLevel,
|
||||
} from './logger.js';
|
||||
export { createLogger, getLogLevel, setLogLevel, LogLevel } from './logger.js';
|
||||
|
||||
// File system utilities
|
||||
export {
|
||||
mkdirSafe,
|
||||
existsSafe,
|
||||
} from './fs-utils.js';
|
||||
export { mkdirSafe, existsSafe } from './fs-utils.js';
|
||||
|
||||
// Path utilities
|
||||
export { normalizePath, pathsEqual } from './path-utils.js';
|
||||
|
||||
// Context file loading
|
||||
export {
|
||||
normalizePath,
|
||||
pathsEqual,
|
||||
} from './path-utils.js';
|
||||
loadContextFiles,
|
||||
getContextFilesSummary,
|
||||
type ContextMetadata,
|
||||
type ContextFileInfo,
|
||||
type ContextFilesResult,
|
||||
type LoadContextFilesOptions,
|
||||
} from './context-loader.js';
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createLogger(context: string) {
|
||||
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (currentLogLevel >= LogLevel.DEBUG) {
|
||||
console.log(prefix, "[DEBUG]", ...args);
|
||||
console.log(prefix, '[DEBUG]', ...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* ```
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, "/");
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,10 +45,7 @@ export function normalizePath(p: string): string {
|
||||
* pathsEqual(null, null); // true
|
||||
* ```
|
||||
*/
|
||||
export function pathsEqual(
|
||||
p1: string | undefined | null,
|
||||
p2: string | undefined | null
|
||||
): boolean {
|
||||
export function pathsEqual(p1: string | undefined | null, p2: string | undefined | null): boolean {
|
||||
if (!p1 || !p2) return p1 === p2;
|
||||
return normalizePath(p1) === normalizePath(p2);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,18 @@
|
||||
* - Supports both vision and non-vision models
|
||||
*/
|
||||
|
||||
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js";
|
||||
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from './image-handler.js';
|
||||
|
||||
/**
|
||||
* Content that can be either simple text or structured blocks
|
||||
*/
|
||||
export type PromptContent = string | Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: object;
|
||||
}>;
|
||||
export type PromptContent =
|
||||
| string
|
||||
| Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: object;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Result of building a prompt with optional images
|
||||
@@ -62,7 +64,7 @@ export async function buildPromptWithImages(
|
||||
|
||||
// Add text block if we have text
|
||||
if (textContent.trim()) {
|
||||
contentBlocks.push({ type: "text", text: textContent });
|
||||
contentBlocks.push({ type: 'text', text: textContent });
|
||||
}
|
||||
|
||||
// Add image blocks
|
||||
@@ -71,9 +73,7 @@ export async function buildPromptWithImages(
|
||||
|
||||
// Return appropriate format
|
||||
const content: PromptContent =
|
||||
contentBlocks.length > 1 || contentBlocks[0]?.type === "image"
|
||||
? contentBlocks
|
||||
: textContent;
|
||||
contentBlocks.length > 1 || contentBlocks[0]?.type === 'image' ? contentBlocks : textContent;
|
||||
|
||||
return { content, hasImages: true };
|
||||
}
|
||||
|
||||
@@ -1,261 +1,241 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ConversationMessage } from "@automaker/types";
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ConversationMessage } from '@automaker/types';
|
||||
import {
|
||||
extractTextFromContent,
|
||||
normalizeContentBlocks,
|
||||
formatHistoryAsText,
|
||||
convertHistoryToMessages,
|
||||
} from "../src/conversation-utils";
|
||||
} from '../src/conversation-utils';
|
||||
|
||||
describe("conversation-utils.ts", () => {
|
||||
describe("extractTextFromContent", () => {
|
||||
it("should extract text from string content", () => {
|
||||
const content = "Hello, world!";
|
||||
describe('conversation-utils.ts', () => {
|
||||
describe('extractTextFromContent', () => {
|
||||
it('should extract text from string content', () => {
|
||||
const content = 'Hello, world!';
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("Hello, world!");
|
||||
expect(result).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it("should extract text from array content with text blocks", () => {
|
||||
it('should extract text from array content with text blocks', () => {
|
||||
const content = [
|
||||
{ type: "text", text: "First block" },
|
||||
{ type: "text", text: "Second block" },
|
||||
{ type: 'text', text: 'First block' },
|
||||
{ type: 'text', text: 'Second block' },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("First block\nSecond block");
|
||||
expect(result).toBe('First block\nSecond block');
|
||||
});
|
||||
|
||||
it("should filter out non-text blocks", () => {
|
||||
it('should filter out non-text blocks', () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Text block" },
|
||||
{ type: "image", source: { data: "..." } },
|
||||
{ type: "text", text: "Another text" },
|
||||
{ type: 'text', text: 'Text block' },
|
||||
{ type: 'image', source: { data: '...' } },
|
||||
{ type: 'text', text: 'Another text' },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("Text block\nAnother text");
|
||||
expect(result).toBe('Text block\nAnother text');
|
||||
});
|
||||
|
||||
it("should handle empty text blocks", () => {
|
||||
it('should handle empty text blocks', () => {
|
||||
const content = [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text" },
|
||||
{ type: "text", text: "Third" },
|
||||
{ type: 'text', text: 'First' },
|
||||
{ type: 'text' },
|
||||
{ type: 'text', text: 'Third' },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("First\n\nThird");
|
||||
expect(result).toBe('First\n\nThird');
|
||||
});
|
||||
|
||||
it("should return empty string for array with only non-text blocks", () => {
|
||||
it('should return empty string for array with only non-text blocks', () => {
|
||||
const content = [
|
||||
{ type: "image", source: {} },
|
||||
{ type: "tool_use", source: {} },
|
||||
{ type: 'image', source: {} },
|
||||
{ type: 'tool_use', source: {} },
|
||||
];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("");
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it("should return empty string for empty array", () => {
|
||||
it('should return empty string for empty array', () => {
|
||||
const content: Array<{ type: string; text?: string }> = [];
|
||||
const result = extractTextFromContent(content);
|
||||
expect(result).toBe("");
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeContentBlocks", () => {
|
||||
it("should convert string to array of text blocks", () => {
|
||||
const content = "Simple text";
|
||||
describe('normalizeContentBlocks', () => {
|
||||
it('should convert string to array of text blocks', () => {
|
||||
const content = 'Simple text';
|
||||
const result = normalizeContentBlocks(content);
|
||||
expect(result).toEqual([{ type: "text", text: "Simple text" }]);
|
||||
expect(result).toEqual([{ type: 'text', text: 'Simple text' }]);
|
||||
});
|
||||
|
||||
it("should return array as-is", () => {
|
||||
it('should return array as-is', () => {
|
||||
const content = [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "image", source: {} },
|
||||
{ type: 'text', text: 'First' },
|
||||
{ type: 'image', source: {} },
|
||||
];
|
||||
const result = normalizeContentBlocks(content);
|
||||
expect(result).toBe(content);
|
||||
expect(result).toEqual(content);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const content = "";
|
||||
it('should handle empty string', () => {
|
||||
const content = '';
|
||||
const result = normalizeContentBlocks(content);
|
||||
expect(result).toEqual([{ type: "text", text: "" }]);
|
||||
expect(result).toEqual([{ type: 'text', text: '' }]);
|
||||
});
|
||||
|
||||
it("should handle multiline string", () => {
|
||||
const content = "Line 1\nLine 2\nLine 3";
|
||||
it('should handle multiline string', () => {
|
||||
const content = 'Line 1\nLine 2\nLine 3';
|
||||
const result = normalizeContentBlocks(content);
|
||||
expect(result).toEqual([{ type: "text", text: "Line 1\nLine 2\nLine 3" }]);
|
||||
expect(result).toEqual([{ type: 'text', text: 'Line 1\nLine 2\nLine 3' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHistoryAsText", () => {
|
||||
it("should format empty history as empty string", () => {
|
||||
describe('formatHistoryAsText', () => {
|
||||
it('should format empty history as empty string', () => {
|
||||
const history: ConversationMessage[] = [];
|
||||
const result = formatHistoryAsText(history);
|
||||
expect(result).toBe("");
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it("should format single user message", () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "user", content: "Hello!" },
|
||||
];
|
||||
it('should format single user message', () => {
|
||||
const history: ConversationMessage[] = [{ role: 'user', content: 'Hello!' }];
|
||||
const result = formatHistoryAsText(history);
|
||||
expect(result).toBe("Previous conversation:\n\nUser: Hello!\n\n---\n\n");
|
||||
expect(result).toBe('Previous conversation:\n\nUser: Hello!\n\n---\n\n');
|
||||
});
|
||||
|
||||
it("should format single assistant message", () => {
|
||||
it('should format single assistant message', () => {
|
||||
const history: ConversationMessage[] = [{ role: 'assistant', content: 'Hi there!' }];
|
||||
const result = formatHistoryAsText(history);
|
||||
expect(result).toBe('Previous conversation:\n\nAssistant: Hi there!\n\n---\n\n');
|
||||
});
|
||||
|
||||
it('should format conversation with multiple messages', () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "assistant", content: "Hi there!" },
|
||||
{ role: 'user', content: "What's 2+2?" },
|
||||
{ role: 'assistant', content: 'The answer is 4.' },
|
||||
{ role: 'user', content: 'Thanks!' },
|
||||
];
|
||||
const result = formatHistoryAsText(history);
|
||||
expect(result).toBe(
|
||||
"Previous conversation:\n\nAssistant: Hi there!\n\n---\n\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("should format conversation with multiple messages", () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "user", content: "What's 2+2?" },
|
||||
{ role: "assistant", content: "The answer is 4." },
|
||||
{ role: "user", content: "Thanks!" },
|
||||
];
|
||||
const result = formatHistoryAsText(history);
|
||||
expect(result).toBe(
|
||||
"Previous conversation:\n\n" +
|
||||
'Previous conversation:\n\n' +
|
||||
"User: What's 2+2?\n\n" +
|
||||
"Assistant: The answer is 4.\n\n" +
|
||||
"User: Thanks!\n\n" +
|
||||
"---\n\n"
|
||||
'Assistant: The answer is 4.\n\n' +
|
||||
'User: Thanks!\n\n' +
|
||||
'---\n\n'
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle array content by extracting text", () => {
|
||||
it('should handle array content by extracting text', () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: "text", text: "First part" },
|
||||
{ type: "text", text: "Second part" },
|
||||
{ type: 'text', text: 'First part' },
|
||||
{ type: 'text', text: 'Second part' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = formatHistoryAsText(history);
|
||||
expect(result).toBe(
|
||||
"Previous conversation:\n\nUser: First part\nSecond part\n\n---\n\n"
|
||||
);
|
||||
expect(result).toBe('Previous conversation:\n\nUser: First part\nSecond part\n\n---\n\n');
|
||||
});
|
||||
|
||||
it("should handle mixed string and array content", () => {
|
||||
it('should handle mixed string and array content', () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "user", content: "String message" },
|
||||
{ role: 'user', content: 'String message' },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Array message" }],
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Array message' }],
|
||||
},
|
||||
];
|
||||
const result = formatHistoryAsText(history);
|
||||
expect(result).toContain("User: String message");
|
||||
expect(result).toContain("Assistant: Array message");
|
||||
expect(result).toContain('User: String message');
|
||||
expect(result).toContain('Assistant: Array message');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertHistoryToMessages", () => {
|
||||
it("should convert empty history", () => {
|
||||
describe('convertHistoryToMessages', () => {
|
||||
it('should convert empty history', () => {
|
||||
const history: ConversationMessage[] = [];
|
||||
const result = convertHistoryToMessages(history);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should convert single user message", () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "user", content: "Hello!" },
|
||||
];
|
||||
it('should convert single user message', () => {
|
||||
const history: ConversationMessage[] = [{ role: 'user', content: 'Hello!' }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "user",
|
||||
session_id: "",
|
||||
type: 'user',
|
||||
session_id: '',
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello!" }],
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Hello!' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert single assistant message", () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "assistant", content: "Hi there!" },
|
||||
];
|
||||
it('should convert single assistant message', () => {
|
||||
const history: ConversationMessage[] = [{ role: 'assistant', content: 'Hi there!' }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "assistant",
|
||||
session_id: "",
|
||||
type: 'assistant',
|
||||
session_id: '',
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hi there!" }],
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hi there!' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve array content as-is", () => {
|
||||
it('should preserve array content as-is', () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Text" },
|
||||
{ type: "image", source: { data: "..." } },
|
||||
{ type: 'text', text: 'Text' },
|
||||
{ type: 'image', source: { data: '...' } },
|
||||
];
|
||||
const history: ConversationMessage[] = [{ role: "user", content }];
|
||||
const history: ConversationMessage[] = [{ role: 'user', content }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].message.content).toEqual(content);
|
||||
});
|
||||
|
||||
it("should convert multiple messages", () => {
|
||||
it('should convert multiple messages', () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "user", content: "First" },
|
||||
{ role: "assistant", content: "Second" },
|
||||
{ role: "user", content: "Third" },
|
||||
{ role: 'user', content: 'First' },
|
||||
{ role: 'assistant', content: 'Second' },
|
||||
{ role: 'user', content: 'Third' },
|
||||
];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].type).toBe("user");
|
||||
expect(result[1].type).toBe("assistant");
|
||||
expect(result[2].type).toBe("user");
|
||||
expect(result[0].type).toBe('user');
|
||||
expect(result[1].type).toBe('assistant');
|
||||
expect(result[2].type).toBe('user');
|
||||
});
|
||||
|
||||
it("should set session_id to empty string", () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "user", content: "Test" },
|
||||
];
|
||||
it('should set session_id to empty string', () => {
|
||||
const history: ConversationMessage[] = [{ role: 'user', content: 'Test' }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].session_id).toBe("");
|
||||
expect(result[0].session_id).toBe('');
|
||||
});
|
||||
|
||||
it("should set parent_tool_use_id to null", () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "user", content: "Test" },
|
||||
];
|
||||
it('should set parent_tool_use_id to null', () => {
|
||||
const history: ConversationMessage[] = [{ role: 'user', content: 'Test' }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].parent_tool_use_id).toBeNull();
|
||||
});
|
||||
|
||||
it("should normalize string content to blocks", () => {
|
||||
const history: ConversationMessage[] = [
|
||||
{ role: "user", content: "String content" },
|
||||
];
|
||||
it('should normalize string content to blocks', () => {
|
||||
const history: ConversationMessage[] = [{ role: 'user', content: 'String content' }];
|
||||
const result = convertHistoryToMessages(history);
|
||||
|
||||
expect(result[0].message.content).toEqual([
|
||||
{ type: "text", text: "String content" },
|
||||
]);
|
||||
expect(result[0].message.content).toEqual([{ type: 'text', text: 'String content' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,261 +1,261 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isAbortError,
|
||||
isCancellationError,
|
||||
isAuthenticationError,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
} from "../src/error-handler";
|
||||
} from '../src/error-handler';
|
||||
|
||||
describe("error-handler.ts", () => {
|
||||
describe("isAbortError", () => {
|
||||
describe('error-handler.ts', () => {
|
||||
describe('isAbortError', () => {
|
||||
it("should return true for Error with name 'AbortError'", () => {
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
const error = new Error('Operation aborted');
|
||||
error.name = 'AbortError';
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for Error with message containing 'abort'", () => {
|
||||
const error = new Error("Request was aborted");
|
||||
const error = new Error('Request was aborted');
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for regular Error", () => {
|
||||
const error = new Error("Something went wrong");
|
||||
it('should return false for regular Error', () => {
|
||||
const error = new Error('Something went wrong');
|
||||
expect(isAbortError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-Error values", () => {
|
||||
expect(isAbortError("abort")).toBe(false);
|
||||
it('should return false for non-Error values', () => {
|
||||
expect(isAbortError('abort')).toBe(false);
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
expect(isAbortError(undefined)).toBe(false);
|
||||
expect(isAbortError({})).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle Error with both AbortError name and abort message", () => {
|
||||
const error = new Error("abort");
|
||||
error.name = "AbortError";
|
||||
it('should handle Error with both AbortError name and abort message', () => {
|
||||
const error = new Error('abort');
|
||||
error.name = 'AbortError';
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCancellationError", () => {
|
||||
describe('isCancellationError', () => {
|
||||
it("should return true for 'cancelled' message", () => {
|
||||
expect(isCancellationError("Operation cancelled")).toBe(true);
|
||||
expect(isCancellationError("CANCELLED")).toBe(true);
|
||||
expect(isCancellationError('Operation cancelled')).toBe(true);
|
||||
expect(isCancellationError('CANCELLED')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for 'canceled' message (US spelling)", () => {
|
||||
expect(isCancellationError("Operation canceled")).toBe(true);
|
||||
expect(isCancellationError("CANCELED")).toBe(true);
|
||||
expect(isCancellationError('Operation canceled')).toBe(true);
|
||||
expect(isCancellationError('CANCELED')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for 'stopped' message", () => {
|
||||
expect(isCancellationError("Process stopped")).toBe(true);
|
||||
expect(isCancellationError("STOPPED")).toBe(true);
|
||||
expect(isCancellationError('Process stopped')).toBe(true);
|
||||
expect(isCancellationError('STOPPED')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for 'aborted' message", () => {
|
||||
expect(isCancellationError("Request aborted")).toBe(true);
|
||||
expect(isCancellationError("ABORTED")).toBe(true);
|
||||
expect(isCancellationError('Request aborted')).toBe(true);
|
||||
expect(isCancellationError('ABORTED')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-cancellation messages", () => {
|
||||
expect(isCancellationError("Something went wrong")).toBe(false);
|
||||
expect(isCancellationError("Error occurred")).toBe(false);
|
||||
expect(isCancellationError("")).toBe(false);
|
||||
it('should return false for non-cancellation messages', () => {
|
||||
expect(isCancellationError('Something went wrong')).toBe(false);
|
||||
expect(isCancellationError('Error occurred')).toBe(false);
|
||||
expect(isCancellationError('')).toBe(false);
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
expect(isCancellationError("CaNcElLeD")).toBe(true);
|
||||
expect(isCancellationError("StOpPeD")).toBe(true);
|
||||
it('should be case-insensitive', () => {
|
||||
expect(isCancellationError('CaNcElLeD')).toBe(true);
|
||||
expect(isCancellationError('StOpPeD')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthenticationError", () => {
|
||||
describe('isAuthenticationError', () => {
|
||||
it("should return true for 'Authentication failed' message", () => {
|
||||
expect(isAuthenticationError("Authentication failed")).toBe(true);
|
||||
expect(isAuthenticationError('Authentication failed')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for 'Invalid API key' message", () => {
|
||||
expect(isAuthenticationError("Invalid API key provided")).toBe(true);
|
||||
expect(isAuthenticationError('Invalid API key provided')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for 'authentication_failed' message", () => {
|
||||
expect(isAuthenticationError("Error: authentication_failed")).toBe(true);
|
||||
expect(isAuthenticationError('Error: authentication_failed')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for 'Fix external API key' message", () => {
|
||||
expect(isAuthenticationError("Fix external API key configuration")).toBe(true);
|
||||
expect(isAuthenticationError('Fix external API key configuration')).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-authentication errors", () => {
|
||||
expect(isAuthenticationError("Something went wrong")).toBe(false);
|
||||
expect(isAuthenticationError("Network error")).toBe(false);
|
||||
expect(isAuthenticationError("")).toBe(false);
|
||||
it('should return false for non-authentication errors', () => {
|
||||
expect(isAuthenticationError('Something went wrong')).toBe(false);
|
||||
expect(isAuthenticationError('Network error')).toBe(false);
|
||||
expect(isAuthenticationError('')).toBe(false);
|
||||
});
|
||||
|
||||
it("should be case-sensitive", () => {
|
||||
expect(isAuthenticationError("authentication failed")).toBe(false);
|
||||
expect(isAuthenticationError("AUTHENTICATION FAILED")).toBe(false);
|
||||
it('should be case-sensitive', () => {
|
||||
expect(isAuthenticationError('authentication failed')).toBe(false);
|
||||
expect(isAuthenticationError('AUTHENTICATION FAILED')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyError", () => {
|
||||
it("should classify authentication errors", () => {
|
||||
const error = new Error("Authentication failed");
|
||||
describe('classifyError', () => {
|
||||
it('should classify authentication errors', () => {
|
||||
const error = new Error('Authentication failed');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("authentication");
|
||||
expect(result.type).toBe('authentication');
|
||||
expect(result.isAuth).toBe(true);
|
||||
expect(result.isAbort).toBe(false);
|
||||
expect(result.isCancellation).toBe(false);
|
||||
expect(result.message).toBe("Authentication failed");
|
||||
expect(result.message).toBe('Authentication failed');
|
||||
expect(result.originalError).toBe(error);
|
||||
});
|
||||
|
||||
it("should classify abort errors", () => {
|
||||
const error = new Error("aborted");
|
||||
it('should classify abort errors', () => {
|
||||
const error = new Error('aborted');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("abort");
|
||||
expect(result.type).toBe('abort');
|
||||
expect(result.isAbort).toBe(true);
|
||||
expect(result.isAuth).toBe(false);
|
||||
expect(result.message).toBe("aborted");
|
||||
expect(result.message).toBe('aborted');
|
||||
});
|
||||
|
||||
it("should classify AbortError by name", () => {
|
||||
const error = new Error("Request cancelled");
|
||||
error.name = "AbortError";
|
||||
it('should classify AbortError by name', () => {
|
||||
const error = new Error('Request cancelled');
|
||||
error.name = 'AbortError';
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("abort");
|
||||
expect(result.type).toBe('abort');
|
||||
expect(result.isAbort).toBe(true);
|
||||
});
|
||||
|
||||
it("should classify cancellation errors", () => {
|
||||
const error = new Error("Operation cancelled");
|
||||
it('should classify cancellation errors', () => {
|
||||
const error = new Error('Operation cancelled');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("cancellation");
|
||||
expect(result.type).toBe('cancellation');
|
||||
expect(result.isCancellation).toBe(true);
|
||||
expect(result.isAbort).toBe(false);
|
||||
});
|
||||
|
||||
it("should classify execution errors (regular Error)", () => {
|
||||
const error = new Error("Something went wrong");
|
||||
it('should classify execution errors (regular Error)', () => {
|
||||
const error = new Error('Something went wrong');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("execution");
|
||||
expect(result.type).toBe('execution');
|
||||
expect(result.isAuth).toBe(false);
|
||||
expect(result.isAbort).toBe(false);
|
||||
expect(result.isCancellation).toBe(false);
|
||||
});
|
||||
|
||||
it("should classify unknown errors (non-Error)", () => {
|
||||
const result = classifyError("string error");
|
||||
it('should classify unknown errors (non-Error)', () => {
|
||||
const result = classifyError('string error');
|
||||
|
||||
expect(result.type).toBe("unknown");
|
||||
expect(result.message).toBe("string error");
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBe('string error');
|
||||
});
|
||||
|
||||
it("should handle null/undefined errors", () => {
|
||||
it('should handle null/undefined errors', () => {
|
||||
const result1 = classifyError(null);
|
||||
expect(result1.type).toBe("unknown");
|
||||
expect(result1.message).toBe("Unknown error");
|
||||
expect(result1.type).toBe('unknown');
|
||||
expect(result1.message).toBe('Unknown error');
|
||||
|
||||
const result2 = classifyError(undefined);
|
||||
expect(result2.type).toBe("unknown");
|
||||
expect(result2.message).toBe("Unknown error");
|
||||
expect(result2.type).toBe('unknown');
|
||||
expect(result2.message).toBe('Unknown error');
|
||||
});
|
||||
|
||||
it("should prioritize authentication over abort", () => {
|
||||
const error = new Error("Authentication failed - aborted");
|
||||
it('should prioritize authentication over abort', () => {
|
||||
const error = new Error('Authentication failed - aborted');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("authentication");
|
||||
expect(result.type).toBe('authentication');
|
||||
expect(result.isAuth).toBe(true);
|
||||
expect(result.isAbort).toBe(true); // Both flags can be true
|
||||
});
|
||||
|
||||
it("should prioritize abort over cancellation", () => {
|
||||
const error = new Error("Request cancelled");
|
||||
error.name = "AbortError";
|
||||
it('should prioritize abort over cancellation', () => {
|
||||
const error = new Error('Request cancelled');
|
||||
error.name = 'AbortError';
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("abort");
|
||||
expect(result.type).toBe('abort');
|
||||
expect(result.isAbort).toBe(true);
|
||||
expect(result.isCancellation).toBe(true); // Both flags can be true
|
||||
});
|
||||
|
||||
it("should convert object errors to string", () => {
|
||||
const result = classifyError({ code: 500, message: "Server error" });
|
||||
expect(result.message).toContain("Object");
|
||||
it('should convert object errors to string', () => {
|
||||
const result = classifyError({ code: 500, message: 'Server error' });
|
||||
expect(result.message).toContain('Object');
|
||||
});
|
||||
|
||||
it("should convert number errors to string", () => {
|
||||
it('should convert number errors to string', () => {
|
||||
const result = classifyError(404);
|
||||
expect(result.message).toBe("404");
|
||||
expect(result.type).toBe("unknown");
|
||||
expect(result.message).toBe('404');
|
||||
expect(result.type).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserFriendlyErrorMessage", () => {
|
||||
it("should return friendly message for abort errors", () => {
|
||||
const error = new Error("abort");
|
||||
describe('getUserFriendlyErrorMessage', () => {
|
||||
it('should return friendly message for abort errors', () => {
|
||||
const error = new Error('abort');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe("Operation was cancelled");
|
||||
expect(message).toBe('Operation was cancelled');
|
||||
});
|
||||
|
||||
it("should return friendly message for AbortError by name", () => {
|
||||
const error = new Error("Something");
|
||||
error.name = "AbortError";
|
||||
it('should return friendly message for AbortError by name', () => {
|
||||
const error = new Error('Something');
|
||||
error.name = 'AbortError';
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe("Operation was cancelled");
|
||||
expect(message).toBe('Operation was cancelled');
|
||||
});
|
||||
|
||||
it("should return friendly message for authentication errors", () => {
|
||||
const error = new Error("Authentication failed");
|
||||
it('should return friendly message for authentication errors', () => {
|
||||
const error = new Error('Authentication failed');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe("Authentication failed. Please check your API key.");
|
||||
expect(message).toBe('Authentication failed. Please check your API key.');
|
||||
});
|
||||
|
||||
it("should prioritize abort message over auth", () => {
|
||||
const error = new Error("Authentication failed - abort");
|
||||
it('should prioritize abort message over auth', () => {
|
||||
const error = new Error('Authentication failed - abort');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
// Auth is checked first in classifyError, but abort check happens before auth in getUserFriendlyErrorMessage
|
||||
expect(message).toBe("Operation was cancelled");
|
||||
expect(message).toBe('Operation was cancelled');
|
||||
});
|
||||
|
||||
it("should return original message for other errors", () => {
|
||||
const error = new Error("Network timeout");
|
||||
it('should return original message for other errors', () => {
|
||||
const error = new Error('Network timeout');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe("Network timeout");
|
||||
expect(message).toBe('Network timeout');
|
||||
});
|
||||
|
||||
it("should handle non-Error values", () => {
|
||||
expect(getUserFriendlyErrorMessage("string error")).toBe("string error");
|
||||
expect(getUserFriendlyErrorMessage(null)).toBe("Unknown error");
|
||||
expect(getUserFriendlyErrorMessage(undefined)).toBe("Unknown error");
|
||||
it('should handle non-Error values', () => {
|
||||
expect(getUserFriendlyErrorMessage('string error')).toBe('string error');
|
||||
expect(getUserFriendlyErrorMessage(null)).toBe('Unknown error');
|
||||
expect(getUserFriendlyErrorMessage(undefined)).toBe('Unknown error');
|
||||
});
|
||||
|
||||
it("should return original message for cancellation errors", () => {
|
||||
const error = new Error("Operation cancelled by user");
|
||||
it('should return original message for cancellation errors', () => {
|
||||
const error = new Error('Operation cancelled by user');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe("Operation cancelled by user");
|
||||
expect(message).toBe('Operation cancelled by user');
|
||||
});
|
||||
|
||||
it("should handle Error without message", () => {
|
||||
it('should handle Error without message', () => {
|
||||
const error = new Error();
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe("");
|
||||
expect(message).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { mkdirSafe, existsSafe } from "../src/fs-utils";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { mkdirSafe, existsSafe } from '../src/fs-utils';
|
||||
|
||||
describe("fs-utils.ts", () => {
|
||||
describe('fs-utils.ts', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for testing
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "fs-utils-test-"));
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-utils-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -21,9 +21,9 @@ describe("fs-utils.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("mkdirSafe", () => {
|
||||
it("should create a new directory", async () => {
|
||||
const newDir = path.join(tempDir, "new-directory");
|
||||
describe('mkdirSafe', () => {
|
||||
it('should create a new directory', async () => {
|
||||
const newDir = path.join(tempDir, 'new-directory');
|
||||
|
||||
await mkdirSafe(newDir);
|
||||
|
||||
@@ -31,8 +31,8 @@ describe("fs-utils.ts", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should create nested directories recursively", async () => {
|
||||
const nestedDir = path.join(tempDir, "level1", "level2", "level3");
|
||||
it('should create nested directories recursively', async () => {
|
||||
const nestedDir = path.join(tempDir, 'level1', 'level2', 'level3');
|
||||
|
||||
await mkdirSafe(nestedDir);
|
||||
|
||||
@@ -40,54 +40,48 @@ describe("fs-utils.ts", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should succeed when directory already exists", async () => {
|
||||
const existingDir = path.join(tempDir, "existing");
|
||||
it('should succeed when directory already exists', async () => {
|
||||
const existingDir = path.join(tempDir, 'existing');
|
||||
await fs.mkdir(existingDir);
|
||||
|
||||
await expect(mkdirSafe(existingDir)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should succeed when path is a symlink to a directory", async () => {
|
||||
const targetDir = path.join(tempDir, "target");
|
||||
const symlinkPath = path.join(tempDir, "symlink");
|
||||
it('should succeed when path is a symlink to a directory', async () => {
|
||||
const targetDir = path.join(tempDir, 'target');
|
||||
const symlinkPath = path.join(tempDir, 'symlink');
|
||||
|
||||
await fs.mkdir(targetDir);
|
||||
await fs.symlink(targetDir, symlinkPath, "dir");
|
||||
await fs.symlink(targetDir, symlinkPath, 'dir');
|
||||
|
||||
await expect(mkdirSafe(symlinkPath)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw when path exists as a file", async () => {
|
||||
const filePath = path.join(tempDir, "existing-file.txt");
|
||||
await fs.writeFile(filePath, "content");
|
||||
it('should throw when path exists as a file', async () => {
|
||||
const filePath = path.join(tempDir, 'existing-file.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
await expect(mkdirSafe(filePath)).rejects.toThrow(
|
||||
"Path exists and is not a directory"
|
||||
);
|
||||
await expect(mkdirSafe(filePath)).rejects.toThrow('Path exists and is not a directory');
|
||||
});
|
||||
|
||||
it("should resolve relative paths", async () => {
|
||||
it('should resolve relative paths', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(tempDir);
|
||||
|
||||
await mkdirSafe("relative-dir");
|
||||
await mkdirSafe('relative-dir');
|
||||
|
||||
const stats = await fs.stat(path.join(tempDir, "relative-dir"));
|
||||
const stats = await fs.stat(path.join(tempDir, 'relative-dir'));
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle concurrent creation gracefully", async () => {
|
||||
const newDir = path.join(tempDir, "concurrent");
|
||||
it('should handle concurrent creation gracefully', async () => {
|
||||
const newDir = path.join(tempDir, 'concurrent');
|
||||
|
||||
const promises = [
|
||||
mkdirSafe(newDir),
|
||||
mkdirSafe(newDir),
|
||||
mkdirSafe(newDir),
|
||||
];
|
||||
const promises = [mkdirSafe(newDir), mkdirSafe(newDir), mkdirSafe(newDir)];
|
||||
|
||||
await expect(Promise.all(promises)).resolves.not.toThrow();
|
||||
|
||||
@@ -95,8 +89,8 @@ describe("fs-utils.ts", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle paths with special characters", async () => {
|
||||
const specialDir = path.join(tempDir, "dir with spaces & special-chars");
|
||||
it('should handle paths with special characters', async () => {
|
||||
const specialDir = path.join(tempDir, 'dir with spaces & special-chars');
|
||||
|
||||
await mkdirSafe(specialDir);
|
||||
|
||||
@@ -105,9 +99,9 @@ describe("fs-utils.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("existsSafe", () => {
|
||||
it("should return true for existing directory", async () => {
|
||||
const existingDir = path.join(tempDir, "exists");
|
||||
describe('existsSafe', () => {
|
||||
it('should return true for existing directory', async () => {
|
||||
const existingDir = path.join(tempDir, 'exists');
|
||||
await fs.mkdir(existingDir);
|
||||
|
||||
const result = await existsSafe(existingDir);
|
||||
@@ -115,28 +109,28 @@ describe("fs-utils.ts", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for existing file", async () => {
|
||||
const filePath = path.join(tempDir, "file.txt");
|
||||
await fs.writeFile(filePath, "content");
|
||||
it('should return true for existing file', async () => {
|
||||
const filePath = path.join(tempDir, 'file.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const result = await existsSafe(filePath);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-existent path", async () => {
|
||||
const nonExistent = path.join(tempDir, "does-not-exist");
|
||||
it('should return false for non-existent path', async () => {
|
||||
const nonExistent = path.join(tempDir, 'does-not-exist');
|
||||
|
||||
const result = await existsSafe(nonExistent);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for symlink", async () => {
|
||||
const target = path.join(tempDir, "target.txt");
|
||||
const symlink = path.join(tempDir, "link.txt");
|
||||
it('should return true for symlink', async () => {
|
||||
const target = path.join(tempDir, 'target.txt');
|
||||
const symlink = path.join(tempDir, 'link.txt');
|
||||
|
||||
await fs.writeFile(target, "content");
|
||||
await fs.writeFile(target, 'content');
|
||||
await fs.symlink(target, symlink);
|
||||
|
||||
const result = await existsSafe(symlink);
|
||||
@@ -144,11 +138,11 @@ describe("fs-utils.ts", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for broken symlink", async () => {
|
||||
const symlink = path.join(tempDir, "broken-link");
|
||||
it('should return true for broken symlink', async () => {
|
||||
const symlink = path.join(tempDir, 'broken-link');
|
||||
|
||||
// Create symlink to non-existent target
|
||||
await fs.symlink("/non/existent/path", symlink);
|
||||
await fs.symlink('/non/existent/path', symlink);
|
||||
|
||||
const result = await existsSafe(symlink);
|
||||
|
||||
@@ -156,14 +150,14 @@ describe("fs-utils.ts", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle relative paths", async () => {
|
||||
it('should handle relative paths', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(tempDir);
|
||||
|
||||
await fs.writeFile("test.txt", "content");
|
||||
await fs.writeFile('test.txt', 'content');
|
||||
|
||||
const result = await existsSafe("test.txt");
|
||||
const result = await existsSafe('test.txt');
|
||||
|
||||
expect(result).toBe(true);
|
||||
} finally {
|
||||
@@ -171,17 +165,17 @@ describe("fs-utils.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle paths with special characters", async () => {
|
||||
const specialFile = path.join(tempDir, "file with spaces & chars.txt");
|
||||
await fs.writeFile(specialFile, "content");
|
||||
it('should handle paths with special characters', async () => {
|
||||
const specialFile = path.join(tempDir, 'file with spaces & chars.txt');
|
||||
await fs.writeFile(specialFile, 'content');
|
||||
|
||||
const result = await existsSafe(specialFile);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for parent of non-existent nested path", async () => {
|
||||
const nonExistent = path.join(tempDir, "does", "not", "exist");
|
||||
it('should return false for parent of non-existent nested path', async () => {
|
||||
const nonExistent = path.join(tempDir, 'does', 'not', 'exist');
|
||||
|
||||
const result = await existsSafe(nonExistent);
|
||||
|
||||
@@ -189,20 +183,20 @@ describe("fs-utils.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should handle permission errors in mkdirSafe", async () => {
|
||||
describe('Error handling', () => {
|
||||
it('should handle permission errors in mkdirSafe', async () => {
|
||||
// Skip on Windows where permissions work differently
|
||||
if (process.platform === "win32") {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const restrictedDir = path.join(tempDir, "restricted");
|
||||
const restrictedDir = path.join(tempDir, 'restricted');
|
||||
await fs.mkdir(restrictedDir);
|
||||
|
||||
// Make directory read-only
|
||||
await fs.chmod(restrictedDir, 0o444);
|
||||
|
||||
const newDir = path.join(restrictedDir, "new");
|
||||
const newDir = path.join(restrictedDir, 'new');
|
||||
|
||||
try {
|
||||
await expect(mkdirSafe(newDir)).rejects.toThrow();
|
||||
@@ -212,23 +206,21 @@ describe("fs-utils.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should propagate unexpected errors in existsSafe", async () => {
|
||||
const mockError = new Error("Unexpected error");
|
||||
(mockError as any).code = "EACCES";
|
||||
it('should propagate unexpected errors in existsSafe', async () => {
|
||||
const mockError = new Error('Unexpected error');
|
||||
(mockError as any).code = 'EACCES';
|
||||
|
||||
const spy = vi.spyOn(fs, "lstat").mockRejectedValueOnce(mockError);
|
||||
const spy = vi.spyOn(fs, 'lstat').mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(existsSafe("/some/path")).rejects.toThrow(
|
||||
"Unexpected error"
|
||||
);
|
||||
await expect(existsSafe('/some/path')).rejects.toThrow('Unexpected error');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration scenarios", () => {
|
||||
it("should work together: check existence then create if missing", async () => {
|
||||
const dirPath = path.join(tempDir, "check-then-create");
|
||||
describe('Integration scenarios', () => {
|
||||
it('should work together: check existence then create if missing', async () => {
|
||||
const dirPath = path.join(tempDir, 'check-then-create');
|
||||
|
||||
const existsBefore = await existsSafe(dirPath);
|
||||
expect(existsBefore).toBe(false);
|
||||
@@ -239,10 +231,10 @@ describe("fs-utils.ts", () => {
|
||||
expect(existsAfter).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle nested directory creation with existence checks", async () => {
|
||||
const level1 = path.join(tempDir, "level1");
|
||||
const level2 = path.join(level1, "level2");
|
||||
const level3 = path.join(level2, "level3");
|
||||
it('should handle nested directory creation with existence checks', async () => {
|
||||
const level1 = path.join(tempDir, 'level1');
|
||||
const level2 = path.join(level1, 'level2');
|
||||
const level3 = path.join(level2, 'level3');
|
||||
|
||||
await mkdirSafe(level3);
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import {
|
||||
getMimeTypeForImage,
|
||||
readImageAsBase64,
|
||||
convertImagesToContentBlocks,
|
||||
formatImagePathsForPrompt,
|
||||
} from "../src/image-handler";
|
||||
} from '../src/image-handler';
|
||||
|
||||
describe("image-handler.ts", () => {
|
||||
describe('image-handler.ts', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "image-handler-test-"));
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'image-handler-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -24,70 +24,70 @@ describe("image-handler.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("getMimeTypeForImage", () => {
|
||||
it("should return correct MIME type for .jpg", () => {
|
||||
expect(getMimeTypeForImage("image.jpg")).toBe("image/jpeg");
|
||||
expect(getMimeTypeForImage("/path/to/image.jpg")).toBe("image/jpeg");
|
||||
describe('getMimeTypeForImage', () => {
|
||||
it('should return correct MIME type for .jpg', () => {
|
||||
expect(getMimeTypeForImage('image.jpg')).toBe('image/jpeg');
|
||||
expect(getMimeTypeForImage('/path/to/image.jpg')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .jpeg", () => {
|
||||
expect(getMimeTypeForImage("image.jpeg")).toBe("image/jpeg");
|
||||
it('should return correct MIME type for .jpeg', () => {
|
||||
expect(getMimeTypeForImage('image.jpeg')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .png", () => {
|
||||
expect(getMimeTypeForImage("image.png")).toBe("image/png");
|
||||
it('should return correct MIME type for .png', () => {
|
||||
expect(getMimeTypeForImage('image.png')).toBe('image/png');
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .gif", () => {
|
||||
expect(getMimeTypeForImage("image.gif")).toBe("image/gif");
|
||||
it('should return correct MIME type for .gif', () => {
|
||||
expect(getMimeTypeForImage('image.gif')).toBe('image/gif');
|
||||
});
|
||||
|
||||
it("should return correct MIME type for .webp", () => {
|
||||
expect(getMimeTypeForImage("image.webp")).toBe("image/webp");
|
||||
it('should return correct MIME type for .webp', () => {
|
||||
expect(getMimeTypeForImage('image.webp')).toBe('image/webp');
|
||||
});
|
||||
|
||||
it("should be case-insensitive", () => {
|
||||
expect(getMimeTypeForImage("image.JPG")).toBe("image/jpeg");
|
||||
expect(getMimeTypeForImage("image.PNG")).toBe("image/png");
|
||||
expect(getMimeTypeForImage("image.GIF")).toBe("image/gif");
|
||||
it('should be case-insensitive', () => {
|
||||
expect(getMimeTypeForImage('image.JPG')).toBe('image/jpeg');
|
||||
expect(getMimeTypeForImage('image.PNG')).toBe('image/png');
|
||||
expect(getMimeTypeForImage('image.GIF')).toBe('image/gif');
|
||||
});
|
||||
|
||||
it("should default to image/png for unknown extensions", () => {
|
||||
expect(getMimeTypeForImage("file.xyz")).toBe("image/png");
|
||||
expect(getMimeTypeForImage("file.txt")).toBe("image/png");
|
||||
expect(getMimeTypeForImage("file")).toBe("image/png");
|
||||
it('should default to image/png for unknown extensions', () => {
|
||||
expect(getMimeTypeForImage('file.xyz')).toBe('image/png');
|
||||
expect(getMimeTypeForImage('file.txt')).toBe('image/png');
|
||||
expect(getMimeTypeForImage('file')).toBe('image/png');
|
||||
});
|
||||
|
||||
it("should handle filenames with multiple dots", () => {
|
||||
expect(getMimeTypeForImage("my.file.name.jpg")).toBe("image/jpeg");
|
||||
it('should handle filenames with multiple dots', () => {
|
||||
expect(getMimeTypeForImage('my.file.name.jpg')).toBe('image/jpeg');
|
||||
});
|
||||
});
|
||||
|
||||
describe("readImageAsBase64", () => {
|
||||
it("should read image and return base64 data", async () => {
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
const imageContent = Buffer.from("fake png data");
|
||||
describe('readImageAsBase64', () => {
|
||||
it('should read image and return base64 data', async () => {
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
const imageContent = Buffer.from('fake png data');
|
||||
await fs.writeFile(imagePath, imageContent);
|
||||
|
||||
const result = await readImageAsBase64(imagePath);
|
||||
|
||||
expect(result.base64).toBe(imageContent.toString("base64"));
|
||||
expect(result.mimeType).toBe("image/png");
|
||||
expect(result.filename).toBe("test.png");
|
||||
expect(result.base64).toBe(imageContent.toString('base64'));
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(result.filename).toBe('test.png');
|
||||
expect(result.originalPath).toBe(imagePath);
|
||||
});
|
||||
|
||||
it("should handle different image formats", async () => {
|
||||
it('should handle different image formats', async () => {
|
||||
const formats = [
|
||||
{ ext: "jpg", mime: "image/jpeg" },
|
||||
{ ext: "png", mime: "image/png" },
|
||||
{ ext: "gif", mime: "image/gif" },
|
||||
{ ext: "webp", mime: "image/webp" },
|
||||
{ ext: 'jpg', mime: 'image/jpeg' },
|
||||
{ ext: 'png', mime: 'image/png' },
|
||||
{ ext: 'gif', mime: 'image/gif' },
|
||||
{ ext: 'webp', mime: 'image/webp' },
|
||||
];
|
||||
|
||||
for (const format of formats) {
|
||||
const imagePath = path.join(tempDir, `image.${format.ext}`);
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await readImageAsBase64(imagePath);
|
||||
|
||||
@@ -97,154 +97,148 @@ describe("image-handler.ts", () => {
|
||||
});
|
||||
|
||||
it("should throw error if file doesn't exist", async () => {
|
||||
const imagePath = path.join(tempDir, "nonexistent.png");
|
||||
const imagePath = path.join(tempDir, 'nonexistent.png');
|
||||
|
||||
await expect(readImageAsBase64(imagePath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should handle binary image data correctly", async () => {
|
||||
const imagePath = path.join(tempDir, "binary.png");
|
||||
it('should handle binary image data correctly', async () => {
|
||||
const imagePath = path.join(tempDir, 'binary.png');
|
||||
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
||||
await fs.writeFile(imagePath, binaryData);
|
||||
|
||||
const result = await readImageAsBase64(imagePath);
|
||||
|
||||
expect(result.base64).toBe(binaryData.toString("base64"));
|
||||
expect(result.base64).toBe(binaryData.toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertImagesToContentBlocks", () => {
|
||||
it("should convert single image to content block", async () => {
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("image data"));
|
||||
describe('convertImagesToContentBlocks', () => {
|
||||
it('should convert single image to content block', async () => {
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('image data'));
|
||||
|
||||
const result = await convertImagesToContentBlocks([imagePath]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "image",
|
||||
type: 'image',
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
},
|
||||
});
|
||||
expect(result[0].source.data).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should convert multiple images", async () => {
|
||||
const image1 = path.join(tempDir, "image1.jpg");
|
||||
const image2 = path.join(tempDir, "image2.png");
|
||||
it('should convert multiple images', async () => {
|
||||
const image1 = path.join(tempDir, 'image1.jpg');
|
||||
const image2 = path.join(tempDir, 'image2.png');
|
||||
|
||||
await fs.writeFile(image1, Buffer.from("jpg data"));
|
||||
await fs.writeFile(image2, Buffer.from("png data"));
|
||||
await fs.writeFile(image1, Buffer.from('jpg data'));
|
||||
await fs.writeFile(image2, Buffer.from('png data'));
|
||||
|
||||
const result = await convertImagesToContentBlocks([image1, image2]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].source.media_type).toBe("image/jpeg");
|
||||
expect(result[1].source.media_type).toBe("image/png");
|
||||
expect(result[0].source.media_type).toBe('image/jpeg');
|
||||
expect(result[1].source.media_type).toBe('image/png');
|
||||
});
|
||||
|
||||
it("should resolve relative paths with workDir", async () => {
|
||||
const image = "test.png";
|
||||
it('should resolve relative paths with workDir', async () => {
|
||||
const image = 'test.png';
|
||||
const imagePath = path.join(tempDir, image);
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await convertImagesToContentBlocks([image], tempDir);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe("image");
|
||||
expect(result[0].type).toBe('image');
|
||||
});
|
||||
|
||||
it("should handle absolute paths without workDir", async () => {
|
||||
const imagePath = path.join(tempDir, "absolute.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
it('should handle absolute paths without workDir', async () => {
|
||||
const imagePath = path.join(tempDir, 'absolute.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await convertImagesToContentBlocks([imagePath]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should skip images that fail to load", async () => {
|
||||
const validImage = path.join(tempDir, "valid.png");
|
||||
const invalidImage = path.join(tempDir, "nonexistent.png");
|
||||
it('should skip images that fail to load', async () => {
|
||||
const validImage = path.join(tempDir, 'valid.png');
|
||||
const invalidImage = path.join(tempDir, 'nonexistent.png');
|
||||
|
||||
await fs.writeFile(validImage, Buffer.from("data"));
|
||||
await fs.writeFile(validImage, Buffer.from('data'));
|
||||
|
||||
const result = await convertImagesToContentBlocks([
|
||||
validImage,
|
||||
invalidImage,
|
||||
]);
|
||||
const result = await convertImagesToContentBlocks([validImage, invalidImage]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].source.media_type).toBe("image/png");
|
||||
expect(result[0].source.media_type).toBe('image/png');
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", async () => {
|
||||
it('should return empty array for empty input', async () => {
|
||||
const result = await convertImagesToContentBlocks([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should preserve order of images", async () => {
|
||||
const images = ["img1.jpg", "img2.png", "img3.gif"];
|
||||
it('should preserve order of images', async () => {
|
||||
const images = ['img1.jpg', 'img2.png', 'img3.gif'];
|
||||
|
||||
for (const img of images) {
|
||||
await fs.writeFile(path.join(tempDir, img), Buffer.from("data"));
|
||||
await fs.writeFile(path.join(tempDir, img), Buffer.from('data'));
|
||||
}
|
||||
|
||||
const result = await convertImagesToContentBlocks(images, tempDir);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].source.media_type).toBe("image/jpeg");
|
||||
expect(result[1].source.media_type).toBe("image/png");
|
||||
expect(result[2].source.media_type).toBe("image/gif");
|
||||
expect(result[0].source.media_type).toBe('image/jpeg');
|
||||
expect(result[1].source.media_type).toBe('image/png');
|
||||
expect(result[2].source.media_type).toBe('image/gif');
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatImagePathsForPrompt", () => {
|
||||
it("should return empty string for empty array", () => {
|
||||
describe('formatImagePathsForPrompt', () => {
|
||||
it('should return empty string for empty array', () => {
|
||||
const result = formatImagePathsForPrompt([]);
|
||||
expect(result).toBe("");
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it("should format single image path", () => {
|
||||
const result = formatImagePathsForPrompt(["/path/to/image.png"]);
|
||||
expect(result).toBe("\n\nAttached images:\n- /path/to/image.png\n");
|
||||
it('should format single image path', () => {
|
||||
const result = formatImagePathsForPrompt(['/path/to/image.png']);
|
||||
expect(result).toBe('\n\nAttached images:\n- /path/to/image.png\n');
|
||||
});
|
||||
|
||||
it("should format multiple image paths", () => {
|
||||
it('should format multiple image paths', () => {
|
||||
const result = formatImagePathsForPrompt([
|
||||
"/path/image1.png",
|
||||
"/path/image2.jpg",
|
||||
"/path/image3.gif",
|
||||
'/path/image1.png',
|
||||
'/path/image2.jpg',
|
||||
'/path/image3.gif',
|
||||
]);
|
||||
|
||||
expect(result).toBe(
|
||||
"\n\nAttached images:\n" +
|
||||
"- /path/image1.png\n" +
|
||||
"- /path/image2.jpg\n" +
|
||||
"- /path/image3.gif\n"
|
||||
'\n\nAttached images:\n' +
|
||||
'- /path/image1.png\n' +
|
||||
'- /path/image2.jpg\n' +
|
||||
'- /path/image3.gif\n'
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle relative paths", () => {
|
||||
const result = formatImagePathsForPrompt([
|
||||
"relative/path/image.png",
|
||||
"another/image.jpg",
|
||||
]);
|
||||
it('should handle relative paths', () => {
|
||||
const result = formatImagePathsForPrompt(['relative/path/image.png', 'another/image.jpg']);
|
||||
|
||||
expect(result).toContain("- relative/path/image.png");
|
||||
expect(result).toContain("- another/image.jpg");
|
||||
expect(result).toContain('- relative/path/image.png');
|
||||
expect(result).toContain('- another/image.jpg');
|
||||
});
|
||||
|
||||
it("should start with newlines", () => {
|
||||
const result = formatImagePathsForPrompt(["/image.png"]);
|
||||
expect(result.startsWith("\n\n")).toBe(true);
|
||||
it('should start with newlines', () => {
|
||||
const result = formatImagePathsForPrompt(['/image.png']);
|
||||
expect(result.startsWith('\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it("should include header text", () => {
|
||||
const result = formatImagePathsForPrompt(["/image.png"]);
|
||||
expect(result).toContain("Attached images:");
|
||||
it('should include header text', () => {
|
||||
const result = formatImagePathsForPrompt(['/image.png']);
|
||||
expect(result).toContain('Attached images:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
createLogger,
|
||||
LogLevel,
|
||||
getLogLevel,
|
||||
setLogLevel,
|
||||
} from "../src/logger";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createLogger, LogLevel, getLogLevel, setLogLevel } from '../src/logger';
|
||||
|
||||
describe("logger.ts", () => {
|
||||
describe('logger.ts', () => {
|
||||
let originalConsoleError: typeof console.error;
|
||||
let originalConsoleWarn: typeof console.warn;
|
||||
let originalConsoleLog: typeof console.log;
|
||||
@@ -33,85 +28,77 @@ describe("logger.ts", () => {
|
||||
setLogLevel(originalLogLevel);
|
||||
});
|
||||
|
||||
describe("createLogger", () => {
|
||||
it("should create logger with context prefix", () => {
|
||||
const logger = createLogger("TestContext");
|
||||
describe('createLogger', () => {
|
||||
it('should create logger with context prefix', () => {
|
||||
const logger = createLogger('TestContext');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
logger.info("test message");
|
||||
logger.info('test message');
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"[TestContext]",
|
||||
"test message"
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith('[TestContext]', 'test message');
|
||||
});
|
||||
|
||||
it("should handle multiple arguments", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should handle multiple arguments', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
logger.info("message", { data: 123 }, [1, 2, 3]);
|
||||
logger.info('message', { data: 123 }, [1, 2, 3]);
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"[Test]",
|
||||
"message",
|
||||
{ data: 123 },
|
||||
[1, 2, 3]
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith('[Test]', 'message', { data: 123 }, [1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Log levels", () => {
|
||||
it("should log error at ERROR level", () => {
|
||||
const logger = createLogger("Test");
|
||||
describe('Log levels', () => {
|
||||
it('should log error at ERROR level', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.ERROR);
|
||||
|
||||
logger.error("error message");
|
||||
logger.warn("warn message");
|
||||
logger.info("info message");
|
||||
logger.debug("debug message");
|
||||
logger.error('error message');
|
||||
logger.warn('warn message');
|
||||
logger.info('info message');
|
||||
logger.debug('debug message');
|
||||
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log error and warn at WARN level", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should log error and warn at WARN level', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.WARN);
|
||||
|
||||
logger.error("error message");
|
||||
logger.warn("warn message");
|
||||
logger.info("info message");
|
||||
logger.debug("debug message");
|
||||
logger.error('error message');
|
||||
logger.warn('warn message');
|
||||
logger.info('info message');
|
||||
logger.debug('debug message');
|
||||
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log error, warn, and info at INFO level", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should log error, warn, and info at INFO level', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
logger.error("error message");
|
||||
logger.warn("warn message");
|
||||
logger.info("info message");
|
||||
logger.debug("debug message");
|
||||
logger.error('error message');
|
||||
logger.warn('warn message');
|
||||
logger.info('info message');
|
||||
logger.debug('debug message');
|
||||
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(console.log).toHaveBeenCalledTimes(1); // Only info, not debug
|
||||
});
|
||||
|
||||
it("should log all messages at DEBUG level", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should log all messages at DEBUG level', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.DEBUG);
|
||||
|
||||
logger.error("error message");
|
||||
logger.warn("warn message");
|
||||
logger.info("info message");
|
||||
logger.debug("debug message");
|
||||
logger.error('error message');
|
||||
logger.warn('warn message');
|
||||
logger.info('info message');
|
||||
logger.debug('debug message');
|
||||
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
@@ -119,97 +106,90 @@ describe("logger.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("error method", () => {
|
||||
it("should use console.error", () => {
|
||||
const logger = createLogger("ErrorTest");
|
||||
describe('error method', () => {
|
||||
it('should use console.error', () => {
|
||||
const logger = createLogger('ErrorTest');
|
||||
setLogLevel(LogLevel.ERROR);
|
||||
|
||||
logger.error("error occurred", { code: 500 });
|
||||
logger.error('error occurred', { code: 500 });
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"[ErrorTest]",
|
||||
"error occurred",
|
||||
{ code: 500 }
|
||||
);
|
||||
expect(console.error).toHaveBeenCalledWith('[ErrorTest]', 'error occurred', { code: 500 });
|
||||
});
|
||||
|
||||
it("should not log when level is below ERROR", () => {
|
||||
const logger = createLogger("Test");
|
||||
setLogLevel(LogLevel.ERROR - 1 as LogLevel);
|
||||
it('should not log when level is below ERROR', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel((LogLevel.ERROR - 1) as LogLevel);
|
||||
|
||||
logger.error("should not appear");
|
||||
logger.error('should not appear');
|
||||
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("warn method", () => {
|
||||
it("should use console.warn", () => {
|
||||
const logger = createLogger("WarnTest");
|
||||
describe('warn method', () => {
|
||||
it('should use console.warn', () => {
|
||||
const logger = createLogger('WarnTest');
|
||||
setLogLevel(LogLevel.WARN);
|
||||
|
||||
logger.warn("warning message");
|
||||
logger.warn('warning message');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith("[WarnTest]", "warning message");
|
||||
expect(console.warn).toHaveBeenCalledWith('[WarnTest]', 'warning message');
|
||||
});
|
||||
|
||||
it("should not log when level is below WARN", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should not log when level is below WARN', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.ERROR);
|
||||
|
||||
logger.warn("should not appear");
|
||||
logger.warn('should not appear');
|
||||
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("info method", () => {
|
||||
it("should use console.log", () => {
|
||||
const logger = createLogger("InfoTest");
|
||||
describe('info method', () => {
|
||||
it('should use console.log', () => {
|
||||
const logger = createLogger('InfoTest');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
logger.info("info message");
|
||||
logger.info('info message');
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("[InfoTest]", "info message");
|
||||
expect(console.log).toHaveBeenCalledWith('[InfoTest]', 'info message');
|
||||
});
|
||||
|
||||
it("should not log when level is below INFO", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should not log when level is below INFO', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.WARN);
|
||||
|
||||
logger.info("should not appear");
|
||||
logger.info('should not appear');
|
||||
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("debug method", () => {
|
||||
it("should use console.log with DEBUG prefix", () => {
|
||||
const logger = createLogger("DebugTest");
|
||||
describe('debug method', () => {
|
||||
it('should use console.log with DEBUG prefix', () => {
|
||||
const logger = createLogger('DebugTest');
|
||||
setLogLevel(LogLevel.DEBUG);
|
||||
|
||||
logger.debug("debug details", { trace: "..." });
|
||||
logger.debug('debug details', { trace: '...' });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"[DebugTest]",
|
||||
"[DEBUG]",
|
||||
"debug details",
|
||||
{ trace: "..." }
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith('[DebugTest]', '[DEBUG]', 'debug details', {
|
||||
trace: '...',
|
||||
});
|
||||
});
|
||||
|
||||
it("should not log when level is below DEBUG", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should not log when level is below DEBUG', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
logger.debug("should not appear");
|
||||
logger.debug('should not appear');
|
||||
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLogLevel", () => {
|
||||
it("should return current log level", () => {
|
||||
describe('getLogLevel', () => {
|
||||
it('should return current log level', () => {
|
||||
setLogLevel(LogLevel.DEBUG);
|
||||
expect(getLogLevel()).toBe(LogLevel.DEBUG);
|
||||
|
||||
@@ -218,8 +198,8 @@ describe("logger.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setLogLevel", () => {
|
||||
it("should change log level", () => {
|
||||
describe('setLogLevel', () => {
|
||||
it('should change log level', () => {
|
||||
setLogLevel(LogLevel.WARN);
|
||||
expect(getLogLevel()).toBe(LogLevel.WARN);
|
||||
|
||||
@@ -227,86 +207,75 @@ describe("logger.ts", () => {
|
||||
expect(getLogLevel()).toBe(LogLevel.DEBUG);
|
||||
});
|
||||
|
||||
it("should affect subsequent logging", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should affect subsequent logging', () => {
|
||||
const logger = createLogger('Test');
|
||||
|
||||
setLogLevel(LogLevel.ERROR);
|
||||
logger.info("should not log");
|
||||
logger.info('should not log');
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
|
||||
setLogLevel(LogLevel.INFO);
|
||||
logger.info("should log");
|
||||
expect(console.log).toHaveBeenCalledWith("[Test]", "should log");
|
||||
logger.info('should log');
|
||||
expect(console.log).toHaveBeenCalledWith('[Test]', 'should log');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple logger instances", () => {
|
||||
it("should maintain separate contexts", () => {
|
||||
const logger1 = createLogger("Service1");
|
||||
const logger2 = createLogger("Service2");
|
||||
describe('Multiple logger instances', () => {
|
||||
it('should maintain separate contexts', () => {
|
||||
const logger1 = createLogger('Service1');
|
||||
const logger2 = createLogger('Service2');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
logger1.info("from service 1");
|
||||
logger2.info("from service 2");
|
||||
logger1.info('from service 1');
|
||||
logger2.info('from service 2');
|
||||
|
||||
expect(console.log).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"[Service1]",
|
||||
"from service 1"
|
||||
);
|
||||
expect(console.log).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"[Service2]",
|
||||
"from service 2"
|
||||
);
|
||||
expect(console.log).toHaveBeenNthCalledWith(1, '[Service1]', 'from service 1');
|
||||
expect(console.log).toHaveBeenNthCalledWith(2, '[Service2]', 'from service 2');
|
||||
});
|
||||
|
||||
it("should share log level setting", () => {
|
||||
const logger1 = createLogger("Service1");
|
||||
const logger2 = createLogger("Service2");
|
||||
it('should share log level setting', () => {
|
||||
const logger1 = createLogger('Service1');
|
||||
const logger2 = createLogger('Service2');
|
||||
|
||||
setLogLevel(LogLevel.ERROR);
|
||||
|
||||
logger1.info("should not log");
|
||||
logger2.info("should not log");
|
||||
logger1.info('should not log');
|
||||
logger2.info('should not log');
|
||||
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty context string", () => {
|
||||
const logger = createLogger("");
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty context string', () => {
|
||||
const logger = createLogger('');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
logger.info("message");
|
||||
logger.info('message');
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("[]", "message");
|
||||
expect(console.log).toHaveBeenCalledWith('[]', 'message');
|
||||
});
|
||||
|
||||
it("should handle context with special characters", () => {
|
||||
const logger = createLogger("Test-Service_v2.0");
|
||||
it('should handle context with special characters', () => {
|
||||
const logger = createLogger('Test-Service_v2.0');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
logger.info("message");
|
||||
logger.info('message');
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"[Test-Service_v2.0]",
|
||||
"message"
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith('[Test-Service_v2.0]', 'message');
|
||||
});
|
||||
|
||||
it("should handle no arguments to log methods", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should handle no arguments to log methods', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
logger.info();
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("[Test]");
|
||||
expect(console.log).toHaveBeenCalledWith('[Test]');
|
||||
});
|
||||
|
||||
it("should handle complex object arguments", () => {
|
||||
const logger = createLogger("Test");
|
||||
it('should handle complex object arguments', () => {
|
||||
const logger = createLogger('Test');
|
||||
setLogLevel(LogLevel.INFO);
|
||||
|
||||
const complexObj = {
|
||||
@@ -315,9 +284,9 @@ describe("logger.ts", () => {
|
||||
fn: () => {},
|
||||
};
|
||||
|
||||
logger.info("complex", complexObj);
|
||||
logger.info('complex', complexObj);
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("[Test]", "complex", complexObj);
|
||||
expect(console.log).toHaveBeenCalledWith('[Test]', 'complex', complexObj);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { buildPromptWithImages } from "../src/prompt-builder";
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { buildPromptWithImages } from '../src/prompt-builder';
|
||||
|
||||
describe("prompt-builder.ts", () => {
|
||||
describe('prompt-builder.ts', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "prompt-builder-test-"));
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prompt-builder-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -19,43 +19,40 @@ describe("prompt-builder.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("buildPromptWithImages - no images", () => {
|
||||
it("should return plain text when no images provided", async () => {
|
||||
const basePrompt = "Hello, world!";
|
||||
describe('buildPromptWithImages - no images', () => {
|
||||
it('should return plain text when no images provided', async () => {
|
||||
const basePrompt = 'Hello, world!';
|
||||
|
||||
const result = await buildPromptWithImages(basePrompt);
|
||||
|
||||
expect(result.content).toBe("Hello, world!");
|
||||
expect(result.content).toBe('Hello, world!');
|
||||
expect(result.hasImages).toBe(false);
|
||||
});
|
||||
|
||||
it("should return plain text when empty image array provided", async () => {
|
||||
const basePrompt = "Test prompt";
|
||||
it('should return plain text when empty image array provided', async () => {
|
||||
const basePrompt = 'Test prompt';
|
||||
|
||||
const result = await buildPromptWithImages(basePrompt, []);
|
||||
|
||||
expect(result.content).toBe("Test prompt");
|
||||
expect(result.content).toBe('Test prompt');
|
||||
expect(result.hasImages).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle multiline prompts", async () => {
|
||||
const basePrompt = "Line 1\nLine 2\nLine 3";
|
||||
it('should handle multiline prompts', async () => {
|
||||
const basePrompt = 'Line 1\nLine 2\nLine 3';
|
||||
|
||||
const result = await buildPromptWithImages(basePrompt);
|
||||
|
||||
expect(result.content).toBe("Line 1\nLine 2\nLine 3");
|
||||
expect(result.content).toBe('Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPromptWithImages - with images", () => {
|
||||
it("should build content blocks with single image", async () => {
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("image data"));
|
||||
describe('buildPromptWithImages - with images', () => {
|
||||
it('should build content blocks with single image', async () => {
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('image data'));
|
||||
|
||||
const result = await buildPromptWithImages(
|
||||
"Check this image",
|
||||
[imagePath]
|
||||
);
|
||||
const result = await buildPromptWithImages('Check this image', [imagePath]);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
@@ -67,25 +64,22 @@ describe("prompt-builder.ts", () => {
|
||||
}>;
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Check this image",
|
||||
type: 'text',
|
||||
text: 'Check this image',
|
||||
});
|
||||
expect(blocks[1]).toMatchObject({
|
||||
type: "image",
|
||||
type: 'image',
|
||||
});
|
||||
});
|
||||
|
||||
it("should build content blocks with multiple images", async () => {
|
||||
const image1 = path.join(tempDir, "img1.jpg");
|
||||
const image2 = path.join(tempDir, "img2.png");
|
||||
it('should build content blocks with multiple images', async () => {
|
||||
const image1 = path.join(tempDir, 'img1.jpg');
|
||||
const image2 = path.join(tempDir, 'img2.png');
|
||||
|
||||
await fs.writeFile(image1, Buffer.from("jpg data"));
|
||||
await fs.writeFile(image2, Buffer.from("png data"));
|
||||
await fs.writeFile(image1, Buffer.from('jpg data'));
|
||||
await fs.writeFile(image2, Buffer.from('png data'));
|
||||
|
||||
const result = await buildPromptWithImages("Two images", [
|
||||
image1,
|
||||
image2,
|
||||
]);
|
||||
const result = await buildPromptWithImages('Two images', [image1, image2]);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
|
||||
@@ -95,107 +89,93 @@ describe("prompt-builder.ts", () => {
|
||||
source?: object;
|
||||
}>;
|
||||
expect(blocks).toHaveLength(3); // 1 text + 2 images
|
||||
expect(blocks[0].type).toBe("text");
|
||||
expect(blocks[1].type).toBe("image");
|
||||
expect(blocks[2].type).toBe("image");
|
||||
expect(blocks[0].type).toBe('text');
|
||||
expect(blocks[1].type).toBe('image');
|
||||
expect(blocks[2].type).toBe('image');
|
||||
});
|
||||
|
||||
it("should resolve relative paths with workDir", async () => {
|
||||
const imagePath = "test.png";
|
||||
it('should resolve relative paths with workDir', async () => {
|
||||
const imagePath = 'test.png';
|
||||
const fullPath = path.join(tempDir, imagePath);
|
||||
await fs.writeFile(fullPath, Buffer.from("data"));
|
||||
await fs.writeFile(fullPath, Buffer.from('data'));
|
||||
|
||||
const result = await buildPromptWithImages(
|
||||
"Test",
|
||||
[imagePath],
|
||||
tempDir
|
||||
);
|
||||
const result = await buildPromptWithImages('Test', [imagePath], tempDir);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle absolute paths without workDir", async () => {
|
||||
const imagePath = path.join(tempDir, "absolute.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
it('should handle absolute paths without workDir', async () => {
|
||||
const imagePath = path.join(tempDir, 'absolute.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await buildPromptWithImages("Test", [imagePath]);
|
||||
const result = await buildPromptWithImages('Test', [imagePath]);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPromptWithImages - includeImagePaths option", () => {
|
||||
it("should not include image paths by default", async () => {
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
describe('buildPromptWithImages - includeImagePaths option', () => {
|
||||
it('should not include image paths by default', async () => {
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await buildPromptWithImages("Prompt", [imagePath]);
|
||||
const result = await buildPromptWithImages('Prompt', [imagePath]);
|
||||
|
||||
const blocks = result.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
const textBlock = blocks.find((b) => b.type === "text");
|
||||
const textBlock = blocks.find((b) => b.type === 'text');
|
||||
|
||||
expect(textBlock?.text).not.toContain("Attached images:");
|
||||
expect(textBlock?.text).toBe("Prompt");
|
||||
expect(textBlock?.text).not.toContain('Attached images:');
|
||||
expect(textBlock?.text).toBe('Prompt');
|
||||
});
|
||||
|
||||
it("should include image paths when requested", async () => {
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
it('should include image paths when requested', async () => {
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await buildPromptWithImages(
|
||||
"Prompt",
|
||||
[imagePath],
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
const result = await buildPromptWithImages('Prompt', [imagePath], undefined, true);
|
||||
|
||||
const blocks = result.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
const textBlock = blocks.find((b) => b.type === "text");
|
||||
const textBlock = blocks.find((b) => b.type === 'text');
|
||||
|
||||
expect(textBlock?.text).toContain("Prompt");
|
||||
expect(textBlock?.text).toContain("Attached images:");
|
||||
expect(textBlock?.text).toContain('Prompt');
|
||||
expect(textBlock?.text).toContain('Attached images:');
|
||||
expect(textBlock?.text).toContain(imagePath);
|
||||
});
|
||||
|
||||
it("should format multiple image paths when included", async () => {
|
||||
const img1 = path.join(tempDir, "img1.png");
|
||||
const img2 = path.join(tempDir, "img2.jpg");
|
||||
it('should format multiple image paths when included', async () => {
|
||||
const img1 = path.join(tempDir, 'img1.png');
|
||||
const img2 = path.join(tempDir, 'img2.jpg');
|
||||
|
||||
await fs.writeFile(img1, Buffer.from("data1"));
|
||||
await fs.writeFile(img2, Buffer.from("data2"));
|
||||
await fs.writeFile(img1, Buffer.from('data1'));
|
||||
await fs.writeFile(img2, Buffer.from('data2'));
|
||||
|
||||
const result = await buildPromptWithImages(
|
||||
"Test",
|
||||
[img1, img2],
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
const result = await buildPromptWithImages('Test', [img1, img2], undefined, true);
|
||||
|
||||
const blocks = result.content as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
const textBlock = blocks.find((b) => b.type === "text");
|
||||
const textBlock = blocks.find((b) => b.type === 'text');
|
||||
|
||||
expect(textBlock?.text).toContain("Attached images:");
|
||||
expect(textBlock?.text).toContain('Attached images:');
|
||||
expect(textBlock?.text).toContain(img1);
|
||||
expect(textBlock?.text).toContain(img2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPromptWithImages - edge cases", () => {
|
||||
it("should handle empty prompt with images", async () => {
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
describe('buildPromptWithImages - edge cases', () => {
|
||||
it('should handle empty prompt with images', async () => {
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await buildPromptWithImages("", [imagePath]);
|
||||
const result = await buildPromptWithImages('', [imagePath]);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
|
||||
@@ -206,14 +186,14 @@ describe("prompt-builder.ts", () => {
|
||||
}>;
|
||||
// Should only have image block, no text block for empty string
|
||||
expect(blocks.length).toBeGreaterThan(0);
|
||||
expect(blocks.some((b) => b.type === "image")).toBe(true);
|
||||
expect(blocks.some((b) => b.type === 'image')).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle whitespace-only prompt with images", async () => {
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
it('should handle whitespace-only prompt with images', async () => {
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await buildPromptWithImages(" ", [imagePath]);
|
||||
const result = await buildPromptWithImages(' ', [imagePath]);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
|
||||
@@ -223,19 +203,16 @@ describe("prompt-builder.ts", () => {
|
||||
source?: object;
|
||||
}>;
|
||||
// Whitespace-only is trimmed, so no text block should be added
|
||||
expect(blocks.every((b) => b.type !== "text")).toBe(true);
|
||||
expect(blocks.every((b) => b.type !== 'text')).toBe(true);
|
||||
});
|
||||
|
||||
it("should skip failed image loads", async () => {
|
||||
const validImage = path.join(tempDir, "valid.png");
|
||||
const invalidImage = path.join(tempDir, "nonexistent.png");
|
||||
it('should skip failed image loads', async () => {
|
||||
const validImage = path.join(tempDir, 'valid.png');
|
||||
const invalidImage = path.join(tempDir, 'nonexistent.png');
|
||||
|
||||
await fs.writeFile(validImage, Buffer.from("data"));
|
||||
await fs.writeFile(validImage, Buffer.from('data'));
|
||||
|
||||
const result = await buildPromptWithImages("Test", [
|
||||
validImage,
|
||||
invalidImage,
|
||||
]);
|
||||
const result = await buildPromptWithImages('Test', [validImage, invalidImage]);
|
||||
|
||||
expect(result.hasImages).toBe(true);
|
||||
|
||||
@@ -244,28 +221,18 @@ describe("prompt-builder.ts", () => {
|
||||
text?: string;
|
||||
source?: object;
|
||||
}>;
|
||||
const imageBlocks = blocks.filter((b) => b.type === "image");
|
||||
const imageBlocks = blocks.filter((b) => b.type === 'image');
|
||||
|
||||
// Only valid image should be included
|
||||
expect(imageBlocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should handle mixed case in includeImagePaths parameter", async () => {
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
it('should handle mixed case in includeImagePaths parameter', async () => {
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const resultFalse = await buildPromptWithImages(
|
||||
"Test",
|
||||
[imagePath],
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
const resultTrue = await buildPromptWithImages(
|
||||
"Test",
|
||||
[imagePath],
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
const resultFalse = await buildPromptWithImages('Test', [imagePath], undefined, false);
|
||||
const resultTrue = await buildPromptWithImages('Test', [imagePath], undefined, true);
|
||||
|
||||
const blocksFalse = resultFalse.content as Array<{
|
||||
type: string;
|
||||
@@ -276,31 +243,31 @@ describe("prompt-builder.ts", () => {
|
||||
text?: string;
|
||||
}>;
|
||||
|
||||
expect(blocksFalse[0].text).not.toContain("Attached images:");
|
||||
expect(blocksTrue[0].text).toContain("Attached images:");
|
||||
expect(blocksFalse[0].text).not.toContain('Attached images:');
|
||||
expect(blocksTrue[0].text).toContain('Attached images:');
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPromptWithImages - content format", () => {
|
||||
it("should return string when only text and includeImagePaths false", async () => {
|
||||
const result = await buildPromptWithImages("Just text", undefined);
|
||||
describe('buildPromptWithImages - content format', () => {
|
||||
it('should return string when only text and includeImagePaths false', async () => {
|
||||
const result = await buildPromptWithImages('Just text', undefined);
|
||||
|
||||
expect(typeof result.content).toBe("string");
|
||||
expect(typeof result.content).toBe('string');
|
||||
});
|
||||
|
||||
it("should return array when has images", async () => {
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
it('should return array when has images', async () => {
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await buildPromptWithImages("Text", [imagePath]);
|
||||
const result = await buildPromptWithImages('Text', [imagePath]);
|
||||
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
});
|
||||
|
||||
it("should preserve prompt formatting", async () => {
|
||||
const basePrompt = "Line 1\n\nLine 2\n Indented line";
|
||||
const imagePath = path.join(tempDir, "test.png");
|
||||
await fs.writeFile(imagePath, Buffer.from("data"));
|
||||
it('should preserve prompt formatting', async () => {
|
||||
const basePrompt = 'Line 1\n\nLine 2\n Indented line';
|
||||
const imagePath = path.join(tempDir, 'test.png');
|
||||
await fs.writeFile(imagePath, Buffer.from('data'));
|
||||
|
||||
const result = await buildPromptWithImages(basePrompt, [imagePath]);
|
||||
|
||||
@@ -308,7 +275,7 @@ describe("prompt-builder.ts", () => {
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
const textBlock = blocks.find((b) => b.type === "text");
|
||||
const textBlock = blocks.find((b) => b.type === 'text');
|
||||
|
||||
expect(textBlock?.text).toBe(basePrompt);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.d.ts", "src/index.ts"],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', 'src/index.ts'],
|
||||
thresholds: {
|
||||
// Excellent coverage: 94.3% stmts, 89.77% branches, 100% funcs, 94.21% lines
|
||||
// All files now have comprehensive tests
|
||||
|
||||
Reference in New Issue
Block a user