@omnidev-ai/core 0.1.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.
Files changed (59) hide show
  1. package/package.json +31 -0
  2. package/src/capability/AGENTS.md +58 -0
  3. package/src/capability/commands.test.ts +414 -0
  4. package/src/capability/commands.ts +70 -0
  5. package/src/capability/docs.test.ts +199 -0
  6. package/src/capability/docs.ts +46 -0
  7. package/src/capability/index.ts +20 -0
  8. package/src/capability/loader.test.ts +815 -0
  9. package/src/capability/loader.ts +492 -0
  10. package/src/capability/registry.test.ts +473 -0
  11. package/src/capability/registry.ts +55 -0
  12. package/src/capability/rules.test.ts +145 -0
  13. package/src/capability/rules.ts +133 -0
  14. package/src/capability/skills.test.ts +316 -0
  15. package/src/capability/skills.ts +56 -0
  16. package/src/capability/sources.test.ts +338 -0
  17. package/src/capability/sources.ts +966 -0
  18. package/src/capability/subagents.test.ts +478 -0
  19. package/src/capability/subagents.ts +103 -0
  20. package/src/capability/yaml-parser.ts +81 -0
  21. package/src/config/AGENTS.md +46 -0
  22. package/src/config/capabilities.ts +82 -0
  23. package/src/config/env.test.ts +286 -0
  24. package/src/config/env.ts +96 -0
  25. package/src/config/index.ts +6 -0
  26. package/src/config/loader.test.ts +282 -0
  27. package/src/config/loader.ts +137 -0
  28. package/src/config/parser.test.ts +281 -0
  29. package/src/config/parser.ts +55 -0
  30. package/src/config/profiles.test.ts +259 -0
  31. package/src/config/profiles.ts +75 -0
  32. package/src/config/provider.test.ts +79 -0
  33. package/src/config/provider.ts +55 -0
  34. package/src/debug.ts +20 -0
  35. package/src/gitignore/manager.test.ts +219 -0
  36. package/src/gitignore/manager.ts +167 -0
  37. package/src/index.test.ts +26 -0
  38. package/src/index.ts +39 -0
  39. package/src/mcp-json/index.ts +1 -0
  40. package/src/mcp-json/manager.test.ts +415 -0
  41. package/src/mcp-json/manager.ts +118 -0
  42. package/src/state/active-profile.test.ts +131 -0
  43. package/src/state/active-profile.ts +41 -0
  44. package/src/state/index.ts +2 -0
  45. package/src/state/manifest.test.ts +548 -0
  46. package/src/state/manifest.ts +164 -0
  47. package/src/sync.ts +213 -0
  48. package/src/templates/agents.test.ts +23 -0
  49. package/src/templates/agents.ts +14 -0
  50. package/src/templates/claude.test.ts +48 -0
  51. package/src/templates/claude.ts +122 -0
  52. package/src/test-utils/helpers.test.ts +196 -0
  53. package/src/test-utils/helpers.ts +187 -0
  54. package/src/test-utils/index.ts +30 -0
  55. package/src/test-utils/mocks.test.ts +83 -0
  56. package/src/test-utils/mocks.ts +101 -0
  57. package/src/types/capability-export.ts +234 -0
  58. package/src/types/index.test.ts +28 -0
  59. package/src/types/index.ts +270 -0
package/src/sync.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { buildCapabilityRegistry } from "./capability/registry";
5
+ import { writeRules } from "./capability/rules";
6
+ import { fetchAllCapabilitySources } from "./capability/sources";
7
+ import { loadConfig } from "./config/loader";
8
+ import { rebuildGitignore } from "./gitignore/manager";
9
+ import { syncMcpJson } from "./mcp-json/manager";
10
+ import {
11
+ buildManifestFromCapabilities,
12
+ cleanupStaleResources,
13
+ loadManifest,
14
+ saveManifest,
15
+ } from "./state/manifest";
16
+
17
+ export interface SyncResult {
18
+ capabilities: string[];
19
+ skillCount: number;
20
+ ruleCount: number;
21
+ docCount: number;
22
+ }
23
+
24
+ /**
25
+ * Install dependencies for capabilities in .omni/capabilities/
26
+ * Only installs for capabilities that have a package.json
27
+ */
28
+ export async function installCapabilityDependencies(silent: boolean): Promise<void> {
29
+ const { existsSync, readdirSync } = await import("node:fs");
30
+ const { join } = await import("node:path");
31
+
32
+ const capabilitiesDir = ".omni/capabilities";
33
+
34
+ // Check if .omni/capabilities exists
35
+ if (!existsSync(capabilitiesDir)) {
36
+ return; // Nothing to install
37
+ }
38
+
39
+ const entries = readdirSync(capabilitiesDir, { withFileTypes: true });
40
+
41
+ for (const entry of entries) {
42
+ if (!entry.isDirectory()) {
43
+ continue;
44
+ }
45
+
46
+ const capabilityPath = join(capabilitiesDir, entry.name);
47
+ const packageJsonPath = join(capabilityPath, "package.json");
48
+
49
+ // Skip if no package.json
50
+ if (!existsSync(packageJsonPath)) {
51
+ continue;
52
+ }
53
+
54
+ if (!silent) {
55
+ console.log(`Installing dependencies for ${capabilityPath}...`);
56
+ }
57
+
58
+ // Run bun install in the capability directory
59
+ await new Promise<void>((resolve, reject) => {
60
+ const proc = spawn("bun", ["install"], {
61
+ cwd: capabilityPath,
62
+ stdio: silent ? "ignore" : "inherit",
63
+ });
64
+
65
+ proc.on("close", (code) => {
66
+ if (code === 0) {
67
+ resolve();
68
+ } else {
69
+ reject(new Error(`Failed to install dependencies for ${capabilityPath}`));
70
+ }
71
+ });
72
+
73
+ proc.on("error", (error) => {
74
+ reject(error);
75
+ });
76
+ });
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Central sync function that regenerates all agent configuration files
82
+ * Called automatically after any config change (init, capability enable/disable, profile change)
83
+ */
84
+ export async function syncAgentConfiguration(options?: { silent?: boolean }): Promise<SyncResult> {
85
+ const silent = options?.silent ?? false;
86
+
87
+ if (!silent) {
88
+ console.log("Syncing agent configuration...");
89
+ }
90
+
91
+ // Fetch capability sources from git repos FIRST (before discovery)
92
+ const config = await loadConfig();
93
+ await fetchAllCapabilitySources(config, { silent });
94
+
95
+ // Install capability dependencies before building registry
96
+ await installCapabilityDependencies(silent);
97
+
98
+ // Build registry
99
+ const registry = await buildCapabilityRegistry();
100
+ const capabilities = registry.getAllCapabilities();
101
+ const skills = registry.getAllSkills();
102
+ const rules = registry.getAllRules();
103
+ const docs = registry.getAllDocs();
104
+
105
+ // Load previous manifest and cleanup stale resources from disabled capabilities
106
+ const previousManifest = await loadManifest();
107
+ const currentCapabilityIds = new Set(capabilities.map((c) => c.id));
108
+
109
+ const cleanupResult = await cleanupStaleResources(previousManifest, currentCapabilityIds);
110
+
111
+ if (
112
+ !silent &&
113
+ (cleanupResult.deletedSkills.length > 0 || cleanupResult.deletedRules.length > 0)
114
+ ) {
115
+ console.log("Cleaned up stale resources:");
116
+ if (cleanupResult.deletedSkills.length > 0) {
117
+ console.log(
118
+ ` - Removed ${cleanupResult.deletedSkills.length} skill(s): ${cleanupResult.deletedSkills.join(", ")}`,
119
+ );
120
+ }
121
+ if (cleanupResult.deletedRules.length > 0) {
122
+ console.log(
123
+ ` - Removed ${cleanupResult.deletedRules.length} rule(s): ${cleanupResult.deletedRules.join(", ")}`,
124
+ );
125
+ }
126
+ }
127
+
128
+ // Rebuild .omni/.gitignore with all enabled capability patterns
129
+ const gitignorePatterns = new Map<string, string[]>();
130
+ for (const capability of capabilities) {
131
+ if (capability.gitignore && capability.gitignore.length > 0) {
132
+ gitignorePatterns.set(capability.id, capability.gitignore);
133
+ }
134
+ }
135
+ await rebuildGitignore(gitignorePatterns);
136
+
137
+ // Call sync hooks for capabilities that have them
138
+ for (const capability of capabilities) {
139
+ // Check for structured export sync function first (new approach)
140
+ // biome-ignore lint/suspicious/noExplicitAny: Dynamic module exports need runtime type checking
141
+ const defaultExport = (capability.exports as any).default;
142
+ if (defaultExport && typeof defaultExport.sync === "function") {
143
+ try {
144
+ await defaultExport.sync();
145
+ } catch (error) {
146
+ console.error(`Error running sync hook for ${capability.id}:`, error);
147
+ }
148
+ }
149
+ // Fall back to TOML-based sync hook (legacy approach)
150
+ else if (capability.config.sync?.on_sync) {
151
+ const syncFnName = capability.config.sync.on_sync;
152
+ const syncFn = capability.exports[syncFnName];
153
+
154
+ if (typeof syncFn === "function") {
155
+ try {
156
+ await syncFn();
157
+ } catch (error) {
158
+ console.error(`Error running sync hook for ${capability.id}:`, error);
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // Ensure directories exist
165
+ mkdirSync(".claude/skills", { recursive: true });
166
+ mkdirSync(".cursor/rules", { recursive: true });
167
+
168
+ // Write rules and docs to .omni/instructions.md
169
+ await writeRules(rules, docs);
170
+
171
+ // Write skills to .claude/skills/
172
+ for (const skill of skills) {
173
+ const skillDir = `.claude/skills/${skill.name}`;
174
+ mkdirSync(skillDir, { recursive: true });
175
+ await Bun.write(
176
+ join(skillDir, "SKILL.md"),
177
+ `---
178
+ name: ${skill.name}
179
+ description: "${skill.description}"
180
+ ---
181
+
182
+ ${skill.instructions}`,
183
+ );
184
+ }
185
+
186
+ // Write rules to .cursor/rules/
187
+ for (const rule of rules) {
188
+ await Bun.write(`.cursor/rules/omnidev-${rule.name}.mdc`, rule.content);
189
+ }
190
+
191
+ // Save updated manifest for future cleanup
192
+ const newManifest = buildManifestFromCapabilities(capabilities);
193
+ await saveManifest(newManifest);
194
+
195
+ // Sync .mcp.json based on sandbox mode
196
+ const sandboxEnabled = config.sandbox_enabled !== false;
197
+ await syncMcpJson(capabilities, sandboxEnabled, { silent });
198
+
199
+ if (!silent) {
200
+ console.log("✓ Synced:");
201
+ console.log(" - .omni/.gitignore (capability patterns)");
202
+ console.log(` - .omni/instructions.md (${docs.length} docs, ${rules.length} rules)`);
203
+ console.log(` - .claude/skills/ (${skills.length} skills)`);
204
+ console.log(` - .cursor/rules/ (${rules.length} rules)`);
205
+ }
206
+
207
+ return {
208
+ capabilities: capabilities.map((c) => c.id),
209
+ skillCount: skills.length,
210
+ ruleCount: rules.length,
211
+ docCount: docs.length,
212
+ };
213
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateAgentsTemplate } from "./agents";
3
+
4
+ describe("generateAgentsTemplate", () => {
5
+ test("generates AGENTS.md template with reference to instructions", () => {
6
+ const template = generateAgentsTemplate();
7
+
8
+ expect(template).toContain("# Project Instructions");
9
+ expect(template).toContain("@import .omni/instructions.md");
10
+ });
11
+
12
+ test("includes placeholder for project-specific instructions", () => {
13
+ const template = generateAgentsTemplate();
14
+
15
+ expect(template).toContain("<!-- Add your project-specific instructions here -->");
16
+ });
17
+
18
+ test("includes OmniDev section", () => {
19
+ const template = generateAgentsTemplate();
20
+
21
+ expect(template).toContain("## OmniDev");
22
+ });
23
+ });
@@ -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,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateClaudeTemplate, generateInstructionsTemplate } from "./claude";
3
+
4
+ describe("generateClaudeTemplate", () => {
5
+ test("generates CLAUDE.md template with reference to instructions", () => {
6
+ const template = generateClaudeTemplate();
7
+
8
+ expect(template).toContain("# Project Instructions");
9
+ expect(template).toContain("@import .omni/instructions.md");
10
+ });
11
+
12
+ test("includes placeholder for project-specific instructions", () => {
13
+ const template = generateClaudeTemplate();
14
+
15
+ expect(template).toContain("<!-- Add your project-specific instructions here -->");
16
+ });
17
+
18
+ test("includes OmniDev section", () => {
19
+ const template = generateClaudeTemplate();
20
+
21
+ expect(template).toContain("## OmniDev");
22
+ });
23
+ });
24
+
25
+ describe("generateInstructionsTemplate", () => {
26
+ test("generates instructions with project description placeholder", () => {
27
+ const template = generateInstructionsTemplate();
28
+
29
+ expect(template).toContain("# OmniDev Instructions");
30
+ expect(template).toContain("## Project Description");
31
+ expect(template).toContain("<!-- TODO: Add 2-3 sentences describing your project -->");
32
+ expect(template).toContain("[Describe what this project does and its main purpose]");
33
+ });
34
+
35
+ test("includes capabilities section with placeholder", () => {
36
+ const template = generateInstructionsTemplate();
37
+
38
+ expect(template).toContain("## Capabilities");
39
+ expect(template).toContain("No capabilities enabled yet");
40
+ });
41
+
42
+ test("includes BEGIN/END markers for generated content", () => {
43
+ const template = generateInstructionsTemplate();
44
+
45
+ expect(template).toContain("BEGIN OMNIDEV GENERATED CONTENT");
46
+ expect(template).toContain("END OMNIDEV GENERATED CONTENT");
47
+ });
48
+ });
@@ -0,0 +1,122 @@
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 provides **three MCP tools** that give you programmatic access to capabilities:
30
+
31
+ ### \`omni_query\` - Search and Discovery
32
+
33
+ Search across enabled capabilities, documentation, skills, and rules.
34
+
35
+ \`\`\`json
36
+ {
37
+ "query": "search query" // Empty query returns summary of enabled capabilities
38
+ }
39
+ \`\`\`
40
+
41
+ Returns short snippets with source tags. Use for finding relevant capabilities and documentation.
42
+
43
+ ### \`omni_sandbox_environment\` - Tool Introspection
44
+
45
+ Discover available sandbox tools with three levels of detail:
46
+
47
+ \`\`\`json
48
+ // Level 1: Overview of all modules
49
+ {}
50
+
51
+ // Level 2: Module details with schemas
52
+ { "capability": "my-capability" }
53
+
54
+ // Level 3: Full tool specification
55
+ { "capability": "my-capability", "tool": "myTool" }
56
+ \`\`\`
57
+
58
+ Use this to discover what tools are available and how to call them.
59
+
60
+ ### \`omni_execute\` - Programmatic Execution
61
+
62
+ Execute TypeScript code in a sandboxed environment with access to all enabled capabilities.
63
+
64
+ \`\`\`json
65
+ {
66
+ "code": "full contents of main.ts file"
67
+ }
68
+ \`\`\`
69
+
70
+ Write complete TypeScript programs that import capability modules:
71
+
72
+ \`\`\`typescript
73
+ import * as myCapability from 'my-capability';
74
+ import * as fs from 'fs';
75
+
76
+ export async function main(): Promise<number> {
77
+ // Your code here
78
+ console.log('Hello from OmniDev sandbox!');
79
+
80
+ return 0; // Success
81
+ }
82
+ \`\`\`
83
+
84
+ **Response includes:**
85
+ - \`stdout\` - Standard output
86
+ - \`stderr\` - Standard error
87
+ - \`exit_code\` - Exit code (0 = success)
88
+ - \`changed_files\` - List of files modified
89
+ - \`diff_stat\` - Summary of changes
90
+
91
+ ## The Sandbox
92
+
93
+ The sandbox is a TypeScript execution environment located in \`.omni/sandbox/\`. It provides:
94
+
95
+ 1. **Isolated Execution** - Code runs in a controlled environment
96
+ 2. **Capability Access** - Import enabled capabilities as TypeScript modules
97
+ 3. **File System Access** - Read and write files in the project
98
+ 4. **Type Safety** - Full TypeScript support with IntelliSense
99
+
100
+ **Key features:**
101
+ - Runs using Bun runtime for speed
102
+ - Auto-generated module wrappers for MCP capabilities
103
+ - Direct symlinks to native capability code
104
+ - All capability tools are available as typed functions
105
+
106
+ **Example workflow:**
107
+ 1. Use \`omni_query\` (empty query) to see what capabilities are enabled
108
+ 2. Use \`omni_sandbox_environment\` to discover available tools and their schemas
109
+ 3. Use \`omni_sandbox_environment\` with capability + tool params for detailed specs
110
+ 4. Write TypeScript code that imports and uses capability tools
111
+ 5. Execute with \`omni_execute\`
112
+
113
+ <!-- BEGIN OMNIDEV GENERATED CONTENT - DO NOT EDIT BELOW THIS LINE -->
114
+ <!-- This section is automatically updated by 'omnidev agents sync' -->
115
+
116
+ ## Capabilities
117
+
118
+ No capabilities enabled yet. Run \`omnidev capability enable <name>\` to enable capabilities.
119
+
120
+ <!-- END OMNIDEV GENERATED CONTENT -->
121
+ `;
122
+ }
@@ -0,0 +1,196 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ captureConsole,
4
+ createDeferredPromise,
5
+ createMockFn,
6
+ createSpy,
7
+ delay,
8
+ expectToThrowAsync,
9
+ waitForCondition,
10
+ } from "./helpers";
11
+
12
+ describe("expectToThrowAsync", () => {
13
+ test("should pass when function throws", async () => {
14
+ await expectToThrowAsync(async () => {
15
+ throw new Error("Test error");
16
+ });
17
+ });
18
+
19
+ test("should match error message with string", async () => {
20
+ await expectToThrowAsync(async () => {
21
+ throw new Error("Test error message");
22
+ }, "Test error");
23
+ });
24
+
25
+ test("should match error message with regex", async () => {
26
+ await expectToThrowAsync(async () => {
27
+ throw new Error("Test error message");
28
+ }, /error/);
29
+ });
30
+
31
+ test("should fail when function does not throw", async () => {
32
+ let failed = false;
33
+ try {
34
+ await expectToThrowAsync(async () => {
35
+ // Does not throw
36
+ });
37
+ } catch {
38
+ failed = true;
39
+ }
40
+ expect(failed).toBe(true);
41
+ });
42
+ });
43
+
44
+ describe("waitForCondition", () => {
45
+ test("should resolve when condition is met immediately", async () => {
46
+ await waitForCondition(() => true);
47
+ });
48
+
49
+ test("should resolve when condition becomes true", async () => {
50
+ let counter = 0;
51
+ await waitForCondition(() => {
52
+ counter++;
53
+ return counter > 2;
54
+ });
55
+ expect(counter).toBeGreaterThan(2);
56
+ });
57
+
58
+ test("should timeout when condition is never met", async () => {
59
+ await expectToThrowAsync(async () => {
60
+ await waitForCondition(() => false, 100);
61
+ }, "Condition not met within 100ms");
62
+ });
63
+ });
64
+
65
+ describe("delay", () => {
66
+ test("should delay execution", async () => {
67
+ const start = Date.now();
68
+ await delay(50);
69
+ const elapsed = Date.now() - start;
70
+ expect(elapsed).toBeGreaterThanOrEqual(45); // Small tolerance
71
+ });
72
+ });
73
+
74
+ describe("createSpy", () => {
75
+ test("should track function calls", () => {
76
+ const spy = createSpy<[number, string], void>();
77
+ spy(1, "test");
78
+ spy(2, "test2");
79
+
80
+ expect(spy.callCount).toBe(2);
81
+ expect(spy.calls).toEqual([
82
+ [1, "test"],
83
+ [2, "test2"],
84
+ ]);
85
+ });
86
+
87
+ test("should support custom implementation", () => {
88
+ const spy = createSpy((a: number, b: number) => a + b);
89
+ const result = spy(1, 2);
90
+
91
+ expect(result).toBe(3);
92
+ expect(spy.callCount).toBe(1);
93
+ });
94
+
95
+ test("should support reset", () => {
96
+ const spy = createSpy<[number], void>();
97
+ spy(1);
98
+ spy(2);
99
+ expect(spy.callCount).toBe(2);
100
+
101
+ spy.reset();
102
+ expect(spy.callCount).toBe(0);
103
+ expect(spy.calls).toEqual([]);
104
+ });
105
+ });
106
+
107
+ describe("createMockFn", () => {
108
+ test("should return values in order", () => {
109
+ const mock = createMockFn("first", "second", "third");
110
+ expect(mock()).toBe("first");
111
+ expect(mock()).toBe("second");
112
+ expect(mock()).toBe("third");
113
+ });
114
+
115
+ test("should throw when called more times than values", () => {
116
+ const mock = createMockFn("only");
117
+ mock();
118
+ expect(() => mock()).toThrow("Mock function called more times than return values provided");
119
+ });
120
+ });
121
+
122
+ describe("createDeferredPromise", () => {
123
+ test("should allow manual resolution", async () => {
124
+ const deferred = createDeferredPromise<string>();
125
+ deferred.resolve("test");
126
+ const result = await deferred.promise;
127
+ expect(result).toBe("test");
128
+ });
129
+
130
+ test("should allow manual rejection", async () => {
131
+ const deferred = createDeferredPromise<string>();
132
+ deferred.reject(new Error("test error"));
133
+ await expectToThrowAsync(async () => {
134
+ await deferred.promise;
135
+ }, "test error");
136
+ });
137
+ });
138
+
139
+ describe("captureConsole", () => {
140
+ test("should capture console.log output", async () => {
141
+ const { stdout, result } = await captureConsole(() => {
142
+ console.log("test message");
143
+ return "return value";
144
+ });
145
+
146
+ expect(stdout).toEqual(["test message"]);
147
+ expect(result).toBe("return value");
148
+ });
149
+
150
+ test("should capture console.error output", async () => {
151
+ const { stderr } = await captureConsole(() => {
152
+ console.error("error message");
153
+ });
154
+
155
+ expect(stderr).toEqual(["error message"]);
156
+ });
157
+
158
+ test("should capture console.warn output", async () => {
159
+ const { stderr } = await captureConsole(() => {
160
+ console.warn("warning message");
161
+ });
162
+
163
+ expect(stderr).toEqual(["warning message"]);
164
+ });
165
+
166
+ test("should work with async functions", async () => {
167
+ const { stdout, result } = await captureConsole(async () => {
168
+ await delay(10);
169
+ console.log("async message");
170
+ return 42;
171
+ });
172
+
173
+ expect(stdout).toEqual(["async message"]);
174
+ expect(result).toBe(42);
175
+ });
176
+
177
+ test("should restore console after execution", async () => {
178
+ const originalLog = console.log;
179
+ await captureConsole(() => {
180
+ console.log("test");
181
+ });
182
+ expect(console.log).toBe(originalLog);
183
+ });
184
+
185
+ test("should restore console even if function throws", async () => {
186
+ const originalLog = console.log;
187
+ try {
188
+ await captureConsole(() => {
189
+ throw new Error("test");
190
+ });
191
+ } catch {
192
+ // Expected
193
+ }
194
+ expect(console.log).toBe(originalLog);
195
+ });
196
+ });