Skip to content

Commit 43a2bd1

Browse files
xesrevinuGit Agent
andcommitted
test(tests): run tests concurrently
- Enable cli smoke tests to run concurrently - Add new test suites for commit and scope services Adjust test suite to run concurrently where supported and add new tests for commit and scope services to cover additional behavior. Co-Authored-By: Git Agent <noreply@git-agent.dev>
1 parent cf4ef3f commit 43a2bd1

7 files changed

Lines changed: 308 additions & 54 deletions

tests/cli-smoke.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ afterEach(() => {
4141
}
4242
});
4343

44-
describe("cli smoke", () => {
44+
describe.concurrent("cli smoke", () => {
4545
it("prints the package version", () => {
4646
const result = runCli(["version"]);
4747

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, layer } from "@effect/vitest";
2+
import { Effect, Fiber, Layer } from "effect";
3+
import { TestClock } from "effect/testing";
4+
import { emptyProjectConfig } from "../src/domain/project.ts";
5+
import {
6+
CommitMessageService,
7+
CommitMessageServiceLive,
8+
type GenerateCommitMessageInput,
9+
} from "../src/services/commit-service.ts";
10+
import { LlmClient } from "../src/services/openai-client.ts";
11+
12+
const makeInput = (
13+
overrides: Partial<GenerateCommitMessageInput> = {},
14+
): GenerateCommitMessageInput => ({
15+
provider: {
16+
apiKey: "test-key",
17+
baseUrl: "https://example.test/v1",
18+
model: "test-model",
19+
noGitAgentCoAuthor: false,
20+
noModelCoAuthor: false,
21+
},
22+
diff: {
23+
files: ["src/app.ts"],
24+
content: "diff --git a/src/app.ts b/src/app.ts\n+export const value = 1;\n",
25+
lines: 2,
26+
},
27+
intent: undefined,
28+
config: emptyProjectConfig(),
29+
hookFeedback: undefined,
30+
previousMessage: undefined,
31+
...overrides,
32+
});
33+
34+
describe.concurrent("CommitMessageService", () => {
35+
layer(Layer.empty)((it) => {
36+
it.effect(
37+
"retries invalid model output and parses the next valid response",
38+
Effect.fn(function* () {
39+
let callCount = 0;
40+
41+
const llmLayer = Layer.mock(LlmClient, {
42+
call: () =>
43+
Effect.sync(() => {
44+
callCount += 1;
45+
return callCount === 1
46+
? "not-json"
47+
: JSON.stringify({
48+
title: "feat(core): add flow",
49+
bullets: ["Add flow"],
50+
explanation: "Adds flow.",
51+
});
52+
}),
53+
});
54+
55+
const messageFiber = yield* Effect.gen(function* () {
56+
const service = yield* CommitMessageService;
57+
return yield* service.generate(makeInput());
58+
}).pipe(
59+
Effect.provide(CommitMessageServiceLive.pipe(Layer.provide(llmLayer))),
60+
Effect.forkChild,
61+
);
62+
63+
yield* TestClock.adjust("10 seconds");
64+
65+
const message = yield* Fiber.join(messageFiber);
66+
67+
expect(callCount).toBe(2);
68+
expect(message.title).toBe("feat(core): add flow");
69+
expect(message.bullets).toEqual(["Add flow"]);
70+
}),
71+
);
72+
73+
it.effect(
74+
"switches to hook-fix prompting when previous message and hook feedback exist",
75+
Effect.fn(function* () {
76+
const prompts: Array<string> = [];
77+
78+
const llmLayer = Layer.mock(LlmClient, {
79+
call: (input) =>
80+
Effect.sync(() => {
81+
prompts.push(input.userPrompt);
82+
return JSON.stringify({
83+
title: "fix(core): satisfy hook",
84+
bullets: ["Fix hook issue"],
85+
explanation: "Keeps the original meaning.",
86+
});
87+
}),
88+
});
89+
90+
yield* Effect.gen(function* () {
91+
const service = yield* CommitMessageService;
92+
return yield* service.generate(
93+
makeInput({
94+
hookFeedback: "scope is required",
95+
previousMessage: "fix: old title",
96+
}),
97+
);
98+
}).pipe(Effect.provide(CommitMessageServiceLive.pipe(Layer.provide(llmLayer))));
99+
100+
expect(prompts).toHaveLength(1);
101+
expect(prompts[0]).toContain("Fix the following commit message");
102+
expect(prompts[0]).toContain("scope is required");
103+
}),
104+
);
105+
});
106+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, layer } from "@effect/vitest";
2+
import { Effect, Layer } from "effect";
3+
import { emptyProjectConfig } from "../src/domain/project.ts";
4+
import {
5+
CommitPlannerService,
6+
CommitPlannerServiceLive,
7+
type PlanCommitsInput,
8+
} from "../src/services/commit-service.ts";
9+
import { LlmClient } from "../src/services/openai-client.ts";
10+
11+
const makeInput = (overrides: Partial<PlanCommitsInput> = {}): PlanCommitsInput => ({
12+
provider: {
13+
apiKey: "test-key",
14+
baseUrl: "https://example.test/v1",
15+
model: "test-model",
16+
noGitAgentCoAuthor: false,
17+
noModelCoAuthor: false,
18+
},
19+
stagedFiles: ["src/staged.ts"],
20+
unstagedFiles: ["src/unstaged.ts"],
21+
intent: undefined,
22+
config: emptyProjectConfig(),
23+
...overrides,
24+
});
25+
26+
describe("CommitPlannerService", () => {
27+
layer(Layer.empty)((it) => {
28+
it.effect(
29+
"includes staged and unstaged files in the prompt and decodes grouped output",
30+
Effect.fn(function* () {
31+
const prompts: Array<string> = [];
32+
33+
const llmLayer = Layer.succeed(LlmClient, {
34+
call: (input) =>
35+
Effect.sync(() => {
36+
prompts.push(input.userPrompt);
37+
return JSON.stringify({
38+
groups: [
39+
{
40+
files: ["src/staged.ts"],
41+
title: "feat(core): stage first",
42+
bullets: ["Keep staged work together"],
43+
explanation: "Uses the user-selected staged file.",
44+
},
45+
{
46+
files: ["src/unstaged.ts"],
47+
title: "feat(core): handle rest",
48+
bullets: ["Handle unstaged work"],
49+
explanation: "Adds the unstaged change separately.",
50+
},
51+
],
52+
});
53+
}),
54+
});
55+
56+
const plan = yield* Effect.gen(function* () {
57+
const service = yield* CommitPlannerService;
58+
return yield* service.plan(makeInput());
59+
}).pipe(Effect.provide(CommitPlannerServiceLive.pipe(Layer.provide(llmLayer))));
60+
61+
expect(prompts).toHaveLength(1);
62+
expect(prompts[0]).toContain("Staged files");
63+
expect(prompts[0]).toContain("src/staged.ts");
64+
expect(prompts[0]).toContain("src/unstaged.ts");
65+
expect(plan.groups).toHaveLength(2);
66+
expect(plan.groups[0]?.message?.title).toBe("feat(core): stage first");
67+
}),
68+
);
69+
});
70+
});

tests/commit-service.test.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Effect } from "effect";
2-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, layer } from "@effect/vitest";
2+
import { Effect, Layer } from "effect";
3+
import { it } from "vitest";
34
import type { CommitGroup } from "../src/domain/commit.ts";
45
import type { CommitRequest } from "../src/services/commit-service.ts";
56
import {
@@ -28,27 +29,30 @@ const group = (files: ReadonlyArray<string>) =>
2829
}) satisfies CommitGroup;
2930

3031
describe("ensureJjWorkingCopyMatchesPlan", () => {
31-
it("succeeds when the working copy still matches the remaining groups", async () => {
32-
await expect(
33-
Effect.runPromise(
34-
ensureJjWorkingCopyMatchesPlan(makeRequest(["src/feature.ts"]), [
32+
layer(Layer.empty)((it) => {
33+
it.effect(
34+
"succeeds when the working copy still matches the remaining groups",
35+
Effect.fn(function* () {
36+
yield* ensureJjWorkingCopyMatchesPlan(makeRequest(["src/feature.ts"]), [
3537
group(["src/feature.ts"]),
36-
]) as Effect.Effect<void, unknown, never>,
37-
),
38-
).resolves.toBeUndefined();
39-
});
38+
]);
39+
}),
40+
);
4041

41-
it("fails when unexpected files appear in the jj working copy", async () => {
42-
await expect(
43-
Effect.runPromise(
44-
ensureJjWorkingCopyMatchesPlan(makeRequest(["src/feature.ts", "src/unexpected.ts"]), [
45-
group(["src/feature.ts"]),
46-
]) as Effect.Effect<void, unknown, never>,
47-
),
48-
).rejects.toMatchObject({
49-
message:
50-
"jj working copy drifted after commit; expected remaining files: src/feature.ts; actual remaining files: src/feature.ts, src/unexpected.ts",
51-
});
42+
it.effect(
43+
"fails when unexpected files appear in the jj working copy",
44+
Effect.fn(function* () {
45+
const error = yield* Effect.flip(
46+
ensureJjWorkingCopyMatchesPlan(makeRequest(["src/feature.ts", "src/unexpected.ts"]), [
47+
group(["src/feature.ts"]),
48+
]),
49+
);
50+
51+
expect(error.message).toBe(
52+
"jj working copy drifted after commit; expected remaining files: src/feature.ts; actual remaining files: src/feature.ts, src/unexpected.ts",
53+
);
54+
}),
55+
);
5256
});
5357
});
5458

tests/hooks.test.ts

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { NodeServices } from "@effect/platform-node";
2-
import { Effect } from "effect";
2+
import { describe, expect, layer } from "@effect/vitest";
3+
import { Effect, Layer } from "effect";
34
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
45
import { tmpdir } from "node:os";
56
import { join } from "node:path";
6-
import { afterEach, describe, expect, it } from "vitest";
7-
import { executeHooks, type HookInput } from "../src/services/hooks.ts";
8-
9-
const runEffect = <A>(effect: Effect.Effect<A, unknown, any>) =>
10-
Effect.runPromise(Effect.provide(effect, NodeServices.layer) as Effect.Effect<A, unknown, never>);
7+
import { afterEach } from "vitest";
8+
import { HookService, HookServiceLive, type HookInput } from "../src/services/hooks.ts";
119

1210
const hookInput: HookInput = {
1311
diff: "",
@@ -35,26 +33,36 @@ afterEach(() => {
3533
}
3634
});
3735

38-
describe("executeHooks", () => {
39-
it("fails when a configured shell hook is missing", async () => {
40-
const dir = mkdtempSync(join(tmpdir(), "git-agent-hooks-"));
41-
tempDirs.push(dir);
42-
const hookPath = join(dir, "missing-hook.sh");
36+
describe("HookService", () => {
37+
layer(HookServiceLive.pipe(Layer.provide(NodeServices.layer)))((it) => {
38+
it.effect(
39+
"fails when a configured shell hook is missing",
40+
Effect.fn(function* () {
41+
const dir = mkdtempSync(join(tmpdir(), "git-agent-hooks-"));
42+
tempDirs.push(dir);
43+
const hookPath = join(dir, "missing-hook.sh");
44+
const hookService = yield* HookService;
4345

44-
await expect(runEffect(executeHooks([hookPath], hookInput))).rejects.toMatchObject({
45-
message: expect.stringContaining(`failed to read hook "${hookPath}"`),
46-
});
47-
});
46+
const error = yield* Effect.flip(hookService.execute([hookPath], hookInput));
47+
48+
expect(error.message).toContain(`failed to read hook "${hookPath}"`);
49+
}),
50+
);
51+
52+
it.effect(
53+
"fails for non-executable hook files",
54+
Effect.fn(function* () {
55+
const dir = mkdtempSync(join(tmpdir(), "git-agent-hooks-"));
56+
tempDirs.push(dir);
57+
const hookPath = join(dir, "hook.sh");
58+
writeFileSync(hookPath, "#!/bin/sh\nexit 0\n");
59+
chmodSync(hookPath, 0o644);
60+
const hookService = yield* HookService;
4861

49-
it("fails for non-executable hook files", async () => {
50-
const dir = mkdtempSync(join(tmpdir(), "git-agent-hooks-"));
51-
tempDirs.push(dir);
52-
const hookPath = join(dir, "hook.sh");
53-
writeFileSync(hookPath, "#!/bin/sh\nexit 0\n");
54-
chmodSync(hookPath, 0o644);
62+
const error = yield* Effect.flip(hookService.execute([hookPath], hookInput));
5563

56-
await expect(runEffect(executeHooks([hookPath], hookInput))).rejects.toMatchObject({
57-
message: `hook is not executable: ${hookPath}`,
58-
});
64+
expect(error.message).toBe(`hook is not executable: ${hookPath}`);
65+
}),
66+
);
5967
});
6068
});

tests/scope-service.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, layer } from "@effect/vitest";
2+
import { Effect, Layer } from "effect";
3+
import type { ProviderConfig } from "../src/config/provider.ts";
4+
import { LlmClient } from "../src/services/openai-client.ts";
5+
import { ScopeService, ScopeServiceLive } from "../src/services/scope-service.ts";
6+
import type { VcsClient } from "../src/services/vcs.ts";
7+
8+
const provider: ProviderConfig = {
9+
apiKey: "test-key",
10+
baseUrl: "https://example.test/v1",
11+
model: "test-model",
12+
noGitAgentCoAuthor: false,
13+
noModelCoAuthor: false,
14+
};
15+
16+
const vcs: VcsClient = {
17+
kind: "git",
18+
supportsStaging: true,
19+
isRepo: () => Effect.succeed(true),
20+
initRepo: () => Effect.succeed(""),
21+
repoRoot: () => Effect.succeed("/repo"),
22+
stagedDiff: () => Effect.die("not used"),
23+
unstagedDiff: () => Effect.die("not used"),
24+
diffForFiles: () => Effect.die("not used"),
25+
addAll: () => Effect.die("not used"),
26+
stageFiles: () => Effect.die("not used"),
27+
unstageAll: () => Effect.die("not used"),
28+
commit: () => Effect.die("not used"),
29+
amendCommit: () => Effect.die("not used"),
30+
lastCommitDiff: () => Effect.die("not used"),
31+
formatTrailers: () => Effect.die("not used"),
32+
commitLog: () => Effect.succeed(["feat(api): add route"]),
33+
topLevelDirs: () => Effect.succeed(["api", "web"]),
34+
projectFiles: () => Effect.succeed(["api/routes.ts", "web/page.tsx"]),
35+
};
36+
37+
describe("ScopeService", () => {
38+
layer(Layer.empty)((it) => {
39+
it.effect(
40+
"filters out conventional commit types from generated scopes",
41+
Effect.fn(function* () {
42+
const llmLayer = Layer.succeed(LlmClient, {
43+
call: () =>
44+
Effect.succeed(
45+
JSON.stringify({
46+
scopes: [
47+
{ name: "feat", description: "Should be filtered" },
48+
{ name: "api", description: "Backend API handlers" },
49+
],
50+
}),
51+
),
52+
});
53+
54+
const scopes = yield* Effect.gen(function* () {
55+
const service = yield* ScopeService;
56+
return yield* service.generateProjectScopes({
57+
provider,
58+
vcs,
59+
cwd: "/repo",
60+
maxCommits: 10,
61+
});
62+
}).pipe(Effect.provide(ScopeServiceLive.pipe(Layer.provide(llmLayer))));
63+
64+
expect(scopes).toEqual([{ name: "api", description: "Backend API handlers" }]);
65+
}),
66+
);
67+
});
68+
});

0 commit comments

Comments
 (0)