@omnidev-ai/core 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +600 -664
- package/dist/index.js +1841 -1915
- package/dist/shared/chunk-1dqs11h6.js +20 -0
- package/dist/test-utils/index.d.ts +97 -101
- package/dist/test-utils/index.js +203 -234
- package/package.json +5 -3
- package/src/capability/AGENTS.md +58 -0
- package/src/capability/commands.ts +72 -0
- package/src/capability/docs.ts +48 -0
- package/src/capability/index.ts +20 -0
- package/src/capability/loader.ts +431 -0
- package/src/capability/registry.ts +55 -0
- package/src/capability/rules.ts +135 -0
- package/src/capability/skills.ts +58 -0
- package/src/capability/sources.ts +998 -0
- package/src/capability/subagents.ts +105 -0
- package/src/capability/yaml-parser.ts +81 -0
- package/src/config/AGENTS.md +46 -0
- package/src/config/capabilities.ts +54 -0
- package/src/config/env.ts +96 -0
- package/src/config/index.ts +6 -0
- package/src/config/loader.ts +207 -0
- package/src/config/parser.ts +55 -0
- package/src/config/profiles.ts +75 -0
- package/src/config/provider.ts +55 -0
- package/src/debug.ts +20 -0
- package/src/index.ts +37 -0
- package/src/mcp-json/index.ts +1 -0
- package/src/mcp-json/manager.ts +106 -0
- package/src/state/active-profile.ts +41 -0
- package/src/state/index.ts +3 -0
- package/src/state/manifest.ts +137 -0
- package/src/state/providers.ts +69 -0
- package/src/sync.ts +288 -0
- package/src/templates/agents.ts +14 -0
- package/src/templates/claude.ts +57 -0
- package/src/test-utils/helpers.ts +289 -0
- package/src/test-utils/index.ts +34 -0
- package/src/test-utils/mocks.ts +101 -0
- package/src/types/capability-export.ts +157 -0
- package/src/types/index.ts +314 -0
package/src/sync.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { buildCapabilityRegistry } from "./capability/registry";
|
|
4
|
+
import { writeRules } from "./capability/rules";
|
|
5
|
+
import { fetchAllCapabilitySources } from "./capability/sources";
|
|
6
|
+
import { loadConfig } from "./config/loader";
|
|
7
|
+
import { syncMcpJson } from "./mcp-json/manager";
|
|
8
|
+
import {
|
|
9
|
+
buildManifestFromCapabilities,
|
|
10
|
+
cleanupStaleResources,
|
|
11
|
+
loadManifest,
|
|
12
|
+
saveManifest,
|
|
13
|
+
} from "./state/manifest";
|
|
14
|
+
import type { ProviderAdapter, ProviderContext, SyncBundle } from "./types";
|
|
15
|
+
|
|
16
|
+
export interface SyncResult {
|
|
17
|
+
capabilities: string[];
|
|
18
|
+
skillCount: number;
|
|
19
|
+
ruleCount: number;
|
|
20
|
+
docCount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SyncOptions {
|
|
24
|
+
silent?: boolean;
|
|
25
|
+
/** Optional list of adapters to run. If not provided, adapters are not run. */
|
|
26
|
+
adapters?: ProviderAdapter[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Install dependencies for capabilities in .omni/capabilities/
|
|
31
|
+
* Only installs for capabilities that have a package.json
|
|
32
|
+
*/
|
|
33
|
+
export async function installCapabilityDependencies(silent: boolean): Promise<void> {
|
|
34
|
+
const { existsSync, readdirSync } = await import("node:fs");
|
|
35
|
+
const { join } = await import("node:path");
|
|
36
|
+
|
|
37
|
+
const capabilitiesDir = ".omni/capabilities";
|
|
38
|
+
|
|
39
|
+
// Check if .omni/capabilities exists
|
|
40
|
+
if (!existsSync(capabilitiesDir)) {
|
|
41
|
+
return; // Nothing to install
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const entries = readdirSync(capabilitiesDir, { withFileTypes: true });
|
|
45
|
+
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (!entry.isDirectory()) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const capabilityPath = join(capabilitiesDir, entry.name);
|
|
52
|
+
const packageJsonPath = join(capabilityPath, "package.json");
|
|
53
|
+
|
|
54
|
+
// Skip if no package.json
|
|
55
|
+
if (!existsSync(packageJsonPath)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!silent) {
|
|
60
|
+
console.log(`Installing dependencies for ${capabilityPath}...`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Run bun install in the capability directory
|
|
64
|
+
await new Promise<void>((resolve, reject) => {
|
|
65
|
+
const proc = spawn("bun", ["install"], {
|
|
66
|
+
cwd: capabilityPath,
|
|
67
|
+
stdio: silent ? "ignore" : "inherit",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
proc.on("close", (code) => {
|
|
71
|
+
if (code === 0) {
|
|
72
|
+
resolve();
|
|
73
|
+
} else {
|
|
74
|
+
reject(new Error(`Failed to install dependencies for ${capabilityPath}`));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
proc.on("error", (error) => {
|
|
79
|
+
reject(error);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a provider-agnostic SyncBundle from the capability registry.
|
|
87
|
+
* This bundle can then be passed to adapters for provider-specific materialization.
|
|
88
|
+
*/
|
|
89
|
+
export async function buildSyncBundle(options?: {
|
|
90
|
+
silent?: boolean;
|
|
91
|
+
}): Promise<{ bundle: SyncBundle }> {
|
|
92
|
+
const silent = options?.silent ?? false;
|
|
93
|
+
|
|
94
|
+
// Fetch capability sources from git repos FIRST (before discovery)
|
|
95
|
+
const config = await loadConfig();
|
|
96
|
+
await fetchAllCapabilitySources(config, { silent });
|
|
97
|
+
|
|
98
|
+
// Install capability dependencies before building registry
|
|
99
|
+
await installCapabilityDependencies(silent);
|
|
100
|
+
|
|
101
|
+
// Build registry
|
|
102
|
+
const registry = await buildCapabilityRegistry();
|
|
103
|
+
const capabilities = registry.getAllCapabilities();
|
|
104
|
+
const skills = registry.getAllSkills();
|
|
105
|
+
const rules = registry.getAllRules();
|
|
106
|
+
const docs = registry.getAllDocs();
|
|
107
|
+
const commands = capabilities.flatMap((c) => c.commands);
|
|
108
|
+
const subagents = capabilities.flatMap((c) => c.subagents);
|
|
109
|
+
|
|
110
|
+
// Generate instructions content
|
|
111
|
+
const instructionsContent = generateInstructionsContent(rules, docs);
|
|
112
|
+
|
|
113
|
+
const bundle: SyncBundle = {
|
|
114
|
+
capabilities,
|
|
115
|
+
skills,
|
|
116
|
+
rules,
|
|
117
|
+
docs,
|
|
118
|
+
commands,
|
|
119
|
+
subagents,
|
|
120
|
+
instructionsPath: ".omni/instructions.md",
|
|
121
|
+
instructionsContent,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return { bundle };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Central sync function that regenerates all agent configuration files.
|
|
129
|
+
* Called automatically after any config change (init, capability enable/disable, profile change).
|
|
130
|
+
*
|
|
131
|
+
* If adapters are provided, they will be called after core sync to write provider-specific files.
|
|
132
|
+
*/
|
|
133
|
+
export async function syncAgentConfiguration(options?: SyncOptions): Promise<SyncResult> {
|
|
134
|
+
const silent = options?.silent ?? false;
|
|
135
|
+
const adapters = options?.adapters ?? [];
|
|
136
|
+
|
|
137
|
+
if (!silent) {
|
|
138
|
+
console.log("Syncing agent configuration...");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const { bundle } = await buildSyncBundle({ silent });
|
|
142
|
+
const capabilities = bundle.capabilities;
|
|
143
|
+
|
|
144
|
+
// Load previous manifest and cleanup stale resources from disabled capabilities
|
|
145
|
+
const previousManifest = await loadManifest();
|
|
146
|
+
const currentCapabilityIds = new Set(capabilities.map((c) => c.id));
|
|
147
|
+
|
|
148
|
+
const cleanupResult = await cleanupStaleResources(previousManifest, currentCapabilityIds);
|
|
149
|
+
|
|
150
|
+
if (
|
|
151
|
+
!silent &&
|
|
152
|
+
(cleanupResult.deletedSkills.length > 0 || cleanupResult.deletedRules.length > 0)
|
|
153
|
+
) {
|
|
154
|
+
console.log("Cleaned up stale resources:");
|
|
155
|
+
if (cleanupResult.deletedSkills.length > 0) {
|
|
156
|
+
console.log(
|
|
157
|
+
` - Removed ${cleanupResult.deletedSkills.length} skill(s): ${cleanupResult.deletedSkills.join(", ")}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (cleanupResult.deletedRules.length > 0) {
|
|
161
|
+
console.log(
|
|
162
|
+
` - Removed ${cleanupResult.deletedRules.length} rule(s): ${cleanupResult.deletedRules.join(", ")}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Call sync hooks for capabilities that have them
|
|
168
|
+
for (const capability of capabilities) {
|
|
169
|
+
// Check for structured export sync function first (new approach)
|
|
170
|
+
// biome-ignore lint/suspicious/noExplicitAny: Dynamic module exports need runtime type checking
|
|
171
|
+
const defaultExport = (capability.exports as any).default;
|
|
172
|
+
if (defaultExport && typeof defaultExport.sync === "function") {
|
|
173
|
+
try {
|
|
174
|
+
await defaultExport.sync();
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error(`Error running sync hook for ${capability.id}:`, error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Fall back to TOML-based sync hook (legacy approach)
|
|
180
|
+
else if (capability.config.sync?.on_sync) {
|
|
181
|
+
const syncFnName = capability.config.sync.on_sync;
|
|
182
|
+
const syncFn = capability.exports[syncFnName];
|
|
183
|
+
|
|
184
|
+
if (typeof syncFn === "function") {
|
|
185
|
+
try {
|
|
186
|
+
await syncFn();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`Error running sync hook for ${capability.id}:`, error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Ensure core directories exist
|
|
195
|
+
mkdirSync(".omni", { recursive: true });
|
|
196
|
+
|
|
197
|
+
// Write rules and docs to .omni/instructions.md (provider-agnostic)
|
|
198
|
+
await writeRules(bundle.rules, bundle.docs);
|
|
199
|
+
|
|
200
|
+
// Sync .mcp.json with capability MCP servers (before saving manifest)
|
|
201
|
+
await syncMcpJson(capabilities, previousManifest, { silent });
|
|
202
|
+
|
|
203
|
+
// Save updated manifest for future cleanup
|
|
204
|
+
const newManifest = buildManifestFromCapabilities(capabilities);
|
|
205
|
+
await saveManifest(newManifest);
|
|
206
|
+
|
|
207
|
+
// Run enabled adapters to write provider-specific files
|
|
208
|
+
if (adapters.length > 0) {
|
|
209
|
+
const config = await loadConfig();
|
|
210
|
+
const ctx: ProviderContext = {
|
|
211
|
+
projectRoot: process.cwd(),
|
|
212
|
+
config,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
for (const adapter of adapters) {
|
|
216
|
+
try {
|
|
217
|
+
const result = await adapter.sync(bundle, ctx);
|
|
218
|
+
if (!silent && result.filesWritten.length > 0) {
|
|
219
|
+
console.log(` - ${adapter.displayName}: ${result.filesWritten.length} files`);
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error(`Error running ${adapter.displayName} adapter:`, error);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!silent) {
|
|
228
|
+
console.log("✓ Synced:");
|
|
229
|
+
console.log(
|
|
230
|
+
` - .omni/instructions.md (${bundle.docs.length} docs, ${bundle.rules.length} rules)`,
|
|
231
|
+
);
|
|
232
|
+
if (adapters.length > 0) {
|
|
233
|
+
console.log(` - Provider adapters: ${adapters.map((a) => a.displayName).join(", ")}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
capabilities: capabilities.map((c) => c.id),
|
|
239
|
+
skillCount: bundle.skills.length,
|
|
240
|
+
ruleCount: bundle.rules.length,
|
|
241
|
+
docCount: bundle.docs.length,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Generate instructions.md content from rules and docs.
|
|
247
|
+
*/
|
|
248
|
+
function generateInstructionsContent(rules: SyncBundle["rules"], docs: SyncBundle["docs"]): string {
|
|
249
|
+
if (rules.length === 0 && docs.length === 0) {
|
|
250
|
+
return `## Capabilities
|
|
251
|
+
|
|
252
|
+
No capabilities enabled yet. Run \`omnidev capability enable <name>\` to enable capabilities.`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let content = `## Capabilities
|
|
256
|
+
|
|
257
|
+
`;
|
|
258
|
+
|
|
259
|
+
// Add documentation section if there are docs
|
|
260
|
+
if (docs.length > 0) {
|
|
261
|
+
content += `### Documentation
|
|
262
|
+
|
|
263
|
+
`;
|
|
264
|
+
for (const doc of docs) {
|
|
265
|
+
content += `#### ${doc.name} (from ${doc.capabilityId})
|
|
266
|
+
|
|
267
|
+
${doc.content}
|
|
268
|
+
|
|
269
|
+
`;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Add rules section if there are rules
|
|
274
|
+
if (rules.length > 0) {
|
|
275
|
+
content += `### Rules
|
|
276
|
+
|
|
277
|
+
`;
|
|
278
|
+
for (const rule of rules) {
|
|
279
|
+
content += `#### ${rule.name} (from ${rule.capabilityId})
|
|
280
|
+
|
|
281
|
+
${rule.content}
|
|
282
|
+
|
|
283
|
+
`;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return content.trim();
|
|
288
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template for AGENTS.md (Codex provider)
|
|
3
|
+
* Creates a minimal file with reference to OmniDev instructions
|
|
4
|
+
*/
|
|
5
|
+
export function generateAgentsTemplate(): string {
|
|
6
|
+
return `# Project Instructions
|
|
7
|
+
|
|
8
|
+
<!-- Add your project-specific instructions here -->
|
|
9
|
+
|
|
10
|
+
## OmniDev
|
|
11
|
+
|
|
12
|
+
@import .omni/instructions.md
|
|
13
|
+
`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template for CLAUDE.md (Claude provider)
|
|
3
|
+
* Creates a minimal file with reference to OmniDev instructions
|
|
4
|
+
*/
|
|
5
|
+
export function generateClaudeTemplate(): string {
|
|
6
|
+
return `# Project Instructions
|
|
7
|
+
|
|
8
|
+
<!-- Add your project-specific instructions here -->
|
|
9
|
+
|
|
10
|
+
## OmniDev
|
|
11
|
+
|
|
12
|
+
@import .omni/instructions.md
|
|
13
|
+
`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Template for .omni/instructions.md
|
|
18
|
+
* Contains OmniDev-specific instructions and capability rules
|
|
19
|
+
*/
|
|
20
|
+
export function generateInstructionsTemplate(): string {
|
|
21
|
+
return `# OmniDev Instructions
|
|
22
|
+
|
|
23
|
+
## Project Description
|
|
24
|
+
<!-- TODO: Add 2-3 sentences describing your project -->
|
|
25
|
+
[Describe what this project does and its main purpose]
|
|
26
|
+
|
|
27
|
+
## How OmniDev Works
|
|
28
|
+
|
|
29
|
+
OmniDev manages capability content for your project. Capabilities can provide:
|
|
30
|
+
|
|
31
|
+
- Skills (for agent workflows)
|
|
32
|
+
- Rules (for guardrails and conventions)
|
|
33
|
+
- Docs (reference material)
|
|
34
|
+
- Commands and subagents (optional)
|
|
35
|
+
|
|
36
|
+
Enable capabilities with:
|
|
37
|
+
|
|
38
|
+
\`\`\`
|
|
39
|
+
omnidev capability enable <capability-id>
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
OmniDev will automatically sync enabled capabilities into your workspace. If you want to force a refresh:
|
|
43
|
+
|
|
44
|
+
\`\`\`
|
|
45
|
+
omnidev sync
|
|
46
|
+
\`\`\`
|
|
47
|
+
|
|
48
|
+
<!-- BEGIN OMNIDEV GENERATED CONTENT - DO NOT EDIT BELOW THIS LINE -->
|
|
49
|
+
<!-- This section is automatically updated by 'omnidev agents sync' -->
|
|
50
|
+
|
|
51
|
+
## Capabilities
|
|
52
|
+
|
|
53
|
+
No capabilities enabled yet. Run \`omnidev capability enable <name>\` to enable capabilities.
|
|
54
|
+
|
|
55
|
+
<!-- END OMNIDEV GENERATED CONTENT -->
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper functions for testing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, expect } from "bun:test";
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir as osTmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Expects an async function to throw an error
|
|
12
|
+
* @param fn - Async function that should throw
|
|
13
|
+
* @param errorMatch - Optional string or regex to match against error message
|
|
14
|
+
* @throws If the function doesn't throw
|
|
15
|
+
*/
|
|
16
|
+
export async function expectToThrowAsync(
|
|
17
|
+
fn: () => Promise<unknown>,
|
|
18
|
+
errorMatch?: string | RegExp,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
let threw = false;
|
|
21
|
+
let caughtError: Error | undefined;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await fn();
|
|
25
|
+
} catch (e) {
|
|
26
|
+
threw = true;
|
|
27
|
+
caughtError = e as Error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
expect(threw).toBe(true);
|
|
31
|
+
|
|
32
|
+
if (errorMatch && caughtError) {
|
|
33
|
+
if (typeof errorMatch === "string") {
|
|
34
|
+
expect(caughtError.message).toContain(errorMatch);
|
|
35
|
+
} else {
|
|
36
|
+
expect(caughtError.message).toMatch(errorMatch);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Waits for a condition to be true
|
|
43
|
+
* @param condition - Function that returns true when condition is met
|
|
44
|
+
* @param timeout - Maximum time to wait in milliseconds (default: 1000)
|
|
45
|
+
* @param interval - Check interval in milliseconds (default: 50)
|
|
46
|
+
* @throws If timeout is reached before condition is met
|
|
47
|
+
*/
|
|
48
|
+
export async function waitForCondition(
|
|
49
|
+
condition: () => boolean | Promise<boolean>,
|
|
50
|
+
timeout = 1000,
|
|
51
|
+
interval = 50,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const startTime = Date.now();
|
|
54
|
+
|
|
55
|
+
while (Date.now() - startTime < timeout) {
|
|
56
|
+
const result = await condition();
|
|
57
|
+
if (result) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await delay(interval);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error(`Condition not met within ${timeout}ms`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Delays execution for a specified amount of time
|
|
68
|
+
* @param ms - Milliseconds to delay
|
|
69
|
+
*/
|
|
70
|
+
export function delay(ms: number): Promise<void> {
|
|
71
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Creates a spy function that records calls and arguments
|
|
76
|
+
* @returns Spy function with call tracking
|
|
77
|
+
*/
|
|
78
|
+
export function createSpy<TArgs extends unknown[], TReturn>(
|
|
79
|
+
implementation?: (...args: TArgs) => TReturn,
|
|
80
|
+
): {
|
|
81
|
+
(...args: TArgs): TReturn;
|
|
82
|
+
calls: TArgs[];
|
|
83
|
+
callCount: number;
|
|
84
|
+
reset: () => void;
|
|
85
|
+
} {
|
|
86
|
+
const calls: TArgs[] = [];
|
|
87
|
+
|
|
88
|
+
const spy = ((...args: TArgs) => {
|
|
89
|
+
calls.push(args);
|
|
90
|
+
if (implementation) {
|
|
91
|
+
return implementation(...args);
|
|
92
|
+
}
|
|
93
|
+
return undefined as TReturn;
|
|
94
|
+
}) as {
|
|
95
|
+
(...args: TArgs): TReturn;
|
|
96
|
+
calls: TArgs[];
|
|
97
|
+
callCount: number;
|
|
98
|
+
reset: () => void;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
Object.defineProperty(spy, "calls", {
|
|
102
|
+
get: () => calls,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
Object.defineProperty(spy, "callCount", {
|
|
106
|
+
get: () => calls.length,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
spy.reset = () => {
|
|
110
|
+
calls.length = 0;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return spy;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Creates a mock function that returns predefined values
|
|
118
|
+
* @param returnValues - Array of values to return on consecutive calls
|
|
119
|
+
* @returns Mock function
|
|
120
|
+
*/
|
|
121
|
+
export function createMockFn<T>(...returnValues: T[]): () => T {
|
|
122
|
+
let callIndex = 0;
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
if (callIndex >= returnValues.length) {
|
|
126
|
+
throw new Error("Mock function called more times than return values provided");
|
|
127
|
+
}
|
|
128
|
+
const value = returnValues[callIndex++];
|
|
129
|
+
if (value === undefined) {
|
|
130
|
+
throw new Error("Mock function returned undefined");
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Creates a mock promise that can be resolved or rejected manually
|
|
138
|
+
* @returns Object with promise and resolve/reject functions
|
|
139
|
+
*/
|
|
140
|
+
export function createDeferredPromise<T>(): {
|
|
141
|
+
promise: Promise<T>;
|
|
142
|
+
resolve: (value: T) => void;
|
|
143
|
+
reject: (reason?: unknown) => void;
|
|
144
|
+
} {
|
|
145
|
+
let resolveRef!: (value: T) => void;
|
|
146
|
+
let rejectRef!: (reason?: unknown) => void;
|
|
147
|
+
|
|
148
|
+
const promise = new Promise<T>((res, rej) => {
|
|
149
|
+
resolveRef = res;
|
|
150
|
+
rejectRef = rej;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
promise: promise,
|
|
155
|
+
resolve: resolveRef,
|
|
156
|
+
reject: rejectRef,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Captures console output during test execution
|
|
162
|
+
* @param fn - Function to execute while capturing output
|
|
163
|
+
* @returns Object with stdout and stderr arrays
|
|
164
|
+
*/
|
|
165
|
+
export async function captureConsole<T>(
|
|
166
|
+
fn: () => Promise<T> | T,
|
|
167
|
+
): Promise<{ stdout: string[]; stderr: string[]; result: T }> {
|
|
168
|
+
const stdout: string[] = [];
|
|
169
|
+
const stderr: string[] = [];
|
|
170
|
+
|
|
171
|
+
const originalLog = console.log;
|
|
172
|
+
const originalError = console.error;
|
|
173
|
+
const originalWarn = console.warn;
|
|
174
|
+
|
|
175
|
+
console.log = (...args: unknown[]) => {
|
|
176
|
+
stdout.push(args.map(String).join(" "));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
console.error = (...args: unknown[]) => {
|
|
180
|
+
stderr.push(args.map(String).join(" "));
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
console.warn = (...args: unknown[]) => {
|
|
184
|
+
stderr.push(args.map(String).join(" "));
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const result = await fn();
|
|
189
|
+
return { stdout, stderr, result };
|
|
190
|
+
} finally {
|
|
191
|
+
console.log = originalLog;
|
|
192
|
+
console.error = originalError;
|
|
193
|
+
console.warn = originalWarn;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Creates a unique temporary directory for tests in /tmp
|
|
199
|
+
* @param prefix - Optional prefix for the directory name (default: "omnidev-test-")
|
|
200
|
+
* @returns Path to the created temporary directory
|
|
201
|
+
*/
|
|
202
|
+
export function tmpdir(prefix = "omnidev-test-"): string {
|
|
203
|
+
return mkdtempSync(join(osTmpdir(), prefix));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type TestDirOptions = {
|
|
207
|
+
chdir?: boolean;
|
|
208
|
+
createOmniDir?: boolean;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export type TestDirController = {
|
|
212
|
+
readonly path: string;
|
|
213
|
+
readonly originalCwd: string;
|
|
214
|
+
setPath: (path: string, options?: TestDirOptions) => void;
|
|
215
|
+
reset: (prefix?: string, options?: TestDirOptions & { cleanupPrevious?: boolean }) => string;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Sets up a temporary directory for each test and cleans it up automatically.
|
|
220
|
+
* Registers beforeEach/afterEach hooks on call.
|
|
221
|
+
*/
|
|
222
|
+
export function setupTestDir(
|
|
223
|
+
prefix = "omnidev-test-",
|
|
224
|
+
options: TestDirOptions = {},
|
|
225
|
+
): TestDirController {
|
|
226
|
+
let currentDir = "";
|
|
227
|
+
let originalCwd = "";
|
|
228
|
+
let shouldChdir = options.chdir ?? false;
|
|
229
|
+
let shouldCreateOmniDir = options.createOmniDir ?? false;
|
|
230
|
+
|
|
231
|
+
const applyOptions = (dir: string, nextOptions?: TestDirOptions) => {
|
|
232
|
+
if (nextOptions) {
|
|
233
|
+
if (typeof nextOptions.chdir === "boolean") {
|
|
234
|
+
shouldChdir = nextOptions.chdir;
|
|
235
|
+
}
|
|
236
|
+
if (typeof nextOptions.createOmniDir === "boolean") {
|
|
237
|
+
shouldCreateOmniDir = nextOptions.createOmniDir;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (shouldCreateOmniDir) {
|
|
242
|
+
mkdirSync(join(dir, ".omni"), { recursive: true });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (shouldChdir) {
|
|
246
|
+
process.chdir(dir);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
originalCwd = process.cwd();
|
|
252
|
+
currentDir = tmpdir(prefix);
|
|
253
|
+
applyOptions(currentDir);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
afterEach(() => {
|
|
257
|
+
if (shouldChdir) {
|
|
258
|
+
process.chdir(originalCwd);
|
|
259
|
+
}
|
|
260
|
+
if (currentDir && existsSync(currentDir)) {
|
|
261
|
+
rmSync(currentDir, { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
get path() {
|
|
267
|
+
return currentDir;
|
|
268
|
+
},
|
|
269
|
+
get originalCwd() {
|
|
270
|
+
return originalCwd;
|
|
271
|
+
},
|
|
272
|
+
setPath(path: string, nextOptions?: TestDirOptions) {
|
|
273
|
+
currentDir = path;
|
|
274
|
+
applyOptions(currentDir, nextOptions);
|
|
275
|
+
},
|
|
276
|
+
reset(nextPrefix = prefix, nextOptions?: TestDirOptions & { cleanupPrevious?: boolean }) {
|
|
277
|
+
const cleanupPrevious = nextOptions?.cleanupPrevious ?? true;
|
|
278
|
+
if (cleanupPrevious && currentDir && existsSync(currentDir)) {
|
|
279
|
+
if (shouldChdir) {
|
|
280
|
+
process.chdir(originalCwd);
|
|
281
|
+
}
|
|
282
|
+
rmSync(currentDir, { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
currentDir = tmpdir(nextPrefix);
|
|
285
|
+
applyOptions(currentDir, nextOptions);
|
|
286
|
+
return currentDir;
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test utilities for OmniDev
|
|
3
|
+
*
|
|
4
|
+
* This module provides shared test utilities including:
|
|
5
|
+
* - Mock factories for creating test data
|
|
6
|
+
* - Helper functions for async testing
|
|
7
|
+
* - Spy and mock function utilities
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Re-export all helper functions
|
|
11
|
+
export {
|
|
12
|
+
captureConsole,
|
|
13
|
+
createDeferredPromise,
|
|
14
|
+
createMockFn,
|
|
15
|
+
createSpy,
|
|
16
|
+
delay,
|
|
17
|
+
expectToThrowAsync,
|
|
18
|
+
setupTestDir,
|
|
19
|
+
type TestDirController,
|
|
20
|
+
type TestDirOptions,
|
|
21
|
+
tmpdir,
|
|
22
|
+
waitForCondition,
|
|
23
|
+
} from "./helpers";
|
|
24
|
+
// Re-export all mock factories
|
|
25
|
+
export {
|
|
26
|
+
createMockCapability,
|
|
27
|
+
createMockConfig,
|
|
28
|
+
createMockRule,
|
|
29
|
+
createMockSkill,
|
|
30
|
+
type MockCapability,
|
|
31
|
+
type MockConfig,
|
|
32
|
+
type MockRule,
|
|
33
|
+
type MockSkill,
|
|
34
|
+
} from "./mocks";
|