Skip to content

Commit 4454834

Browse files
authored
test(core,sisyphus): add unit tests for pure functions (#11)
1 parent 32916fb commit 4454834

18 files changed

Lines changed: 1447 additions & 57 deletions

package.json

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"license": "Apache-2.0",
1010
"name": "r5n-clis",
1111
"packageManager": "bun@1.3.3",
12+
"private": true,
1213
"scripts": {
1314
"build": "bun --filter '*' build",
1415
"clean": "bun --filter '*' clean; rm -rf node_modules; bun install",
@@ -29,11 +30,5 @@
2930
"type-check:go": "bun --elide-lines=0 --filter '*' type-check:go"
3031
},
3132
"version": "0.0.0",
32-
"workspaces": {
33-
"packages": [
34-
"packages/*",
35-
"tools"
36-
]
37-
},
38-
"private": true
33+
"workspaces": { "packages": ["packages/*", "tools"] }
3934
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { deepMerge } from "../../src/util/misc";
3+
4+
describe("deepMerge", () => {
5+
test("merges flat objects", () => {
6+
expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
7+
});
8+
9+
test("source overrides target for same key", () => {
10+
expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
11+
});
12+
13+
test("deep nested merge", () => {
14+
expect(deepMerge({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } });
15+
});
16+
17+
test("arrays are replaced entirely, not concatenated", () => {
18+
expect(deepMerge({ a: [1, 2] }, { a: [3] })).toEqual({ a: [3] });
19+
});
20+
21+
test("non-object source returns source", () => {
22+
expect(deepMerge({ a: 1 }, "string")).toBe("string");
23+
});
24+
25+
test("non-object target with object source returns source", () => {
26+
expect(deepMerge("string", { a: 1 })).toEqual({ a: 1 });
27+
});
28+
29+
test("null source returns null (source wins)", () => {
30+
expect(deepMerge({ a: 1 }, null)).toBeNull();
31+
});
32+
33+
test("undefined values in source overwrite target", () => {
34+
expect(deepMerge({ a: 1, b: 2 }, { a: undefined })).toEqual({ a: undefined, b: 2 });
35+
});
36+
37+
test("empty objects merge to empty object", () => {
38+
expect(deepMerge({}, {})).toEqual({});
39+
});
40+
41+
test("deep nested 3+ levels", () => {
42+
const target = { a: { b: { c: 1, d: 2 }, e: 3 } };
43+
const source = { a: { b: { c: 10, f: 4 }, g: 5 } };
44+
45+
expect(deepMerge(target, source)).toEqual({ a: { b: { c: 10, d: 2, f: 4 }, e: 3, g: 5 } });
46+
});
47+
48+
test("prototype pollution via constructor key does not pollute Object.prototype", () => {
49+
const malicious = JSON.parse('{"constructor":{"prototype":{"polluted":true}}}');
50+
const result = deepMerge({}, malicious);
51+
52+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
53+
expect(result).toHaveProperty("constructor");
54+
});
55+
56+
test("mixed types: source number overwrites target object", () => {
57+
expect(deepMerge({ a: { nested: true } }, { a: 42 })).toEqual({ a: 42 });
58+
});
59+
60+
test("target keys not in source are preserved", () => {
61+
expect(deepMerge({ a: 1, b: 2, c: 3 }, { b: 20 })).toEqual({ a: 1, b: 20, c: 3 });
62+
});
63+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { ArgDefinition } from "../../src/command/args";
3+
import type { PositionalDefinition } from "../../src/command/positionals";
4+
import { buildMriOptions, convertNumbers, mapPositionals, validatePositionals } from "../../src/util/mri-utils";
5+
6+
describe("buildMriOptions", () => {
7+
test("boolean args go to boolean array", () => {
8+
const defs: Record<string, ArgDefinition> = { debug: { type: "boolean" }, verbose: { type: "boolean" } };
9+
10+
expect(buildMriOptions(defs)).toMatchObject({ boolean: ["debug", "verbose"], string: [] });
11+
});
12+
13+
test("string and number args go to string array", () => {
14+
const defs: Record<string, ArgDefinition> = { count: { type: "number" }, name: { type: "string" } };
15+
16+
expect(buildMriOptions(defs)).toMatchObject({ boolean: [], string: ["count", "name"] });
17+
});
18+
19+
test("aliases registered correctly", () => {
20+
const defs: Record<string, ArgDefinition> = {
21+
output: { alias: "o", type: "string" },
22+
verbose: { alias: "v", type: "boolean" },
23+
};
24+
25+
expect(buildMriOptions(defs).alias).toMatchObject({ o: "output", v: "verbose" });
26+
});
27+
28+
test("camelCase auto-generates kebab-case alias", () => {
29+
const defs: Record<string, ArgDefinition> = { dryRun: { type: "boolean" }, outputDir: { type: "string" } };
30+
31+
expect(buildMriOptions(defs).alias).toMatchObject({ "dry-run": "dryRun", "output-dir": "outputDir" });
32+
});
33+
34+
test("defaults populated", () => {
35+
const defs: Record<string, ArgDefinition> = {
36+
count: { default: 10, type: "number" },
37+
name: { type: "string" },
38+
verbose: { default: false, type: "boolean" },
39+
};
40+
const opts = buildMriOptions(defs);
41+
expect(opts.default).toEqual({ count: 10, verbose: false });
42+
});
43+
});
44+
45+
describe("convertNumbers", () => {
46+
test("converts string '42' to number 42", () => {
47+
const defs: Record<string, ArgDefinition> = { count: { type: "number" } };
48+
const result = convertNumbers({ count: "42" }, defs);
49+
expect(result.count).toBe(42);
50+
});
51+
52+
test("throws on invalid number (NaN)", () => {
53+
const defs: Record<string, ArgDefinition> = { count: { type: "number" } };
54+
expect(() => convertNumbers({ count: "abc" }, defs)).toThrow('Invalid number for --count: "abc"');
55+
});
56+
57+
test("skips boolean and string types", () => {
58+
const defs: Record<string, ArgDefinition> = { name: { type: "string" }, verbose: { type: "boolean" } };
59+
60+
expect(convertNumbers({ name: "hello", verbose: true }, defs)).toMatchObject({ name: "hello", verbose: true });
61+
});
62+
63+
test("skips undefined values", () => {
64+
const defs: Record<string, ArgDefinition> = { count: { type: "number" } };
65+
const result = convertNumbers({} as Record<string, string | boolean>, defs);
66+
expect(result.count).toBeUndefined();
67+
});
68+
});
69+
70+
describe("mapPositionals", () => {
71+
test("maps single positional correctly", () => {
72+
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
73+
const result = mapPositionals(["foo"], defs);
74+
expect(result.name).toBe("foo");
75+
});
76+
77+
test("maps multiple positionals by index", () => {
78+
const defs: Record<string, PositionalDefinition> = { dest: { required: true }, source: { required: true } };
79+
80+
expect(mapPositionals(["a.txt", "b.txt"], defs)).toMatchObject({ dest: "a.txt", source: "b.txt" });
81+
});
82+
83+
test("variadic positional captures rest as array", () => {
84+
const defs: Record<string, PositionalDefinition> = { first: { required: true }, rest: { variadic: true } };
85+
86+
expect(mapPositionals(["a", "b", "c", "d"], defs)).toMatchObject({ first: "a", rest: ["b", "c", "d"] });
87+
});
88+
89+
test("missing positional returns undefined", () => {
90+
const defs: Record<string, PositionalDefinition> = { extra: {}, name: { required: true } };
91+
92+
expect(mapPositionals(["foo"], defs)).toMatchObject({ extra: "foo", name: undefined });
93+
});
94+
95+
test("extra positionals silently dropped", () => {
96+
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
97+
const result = mapPositionals(["foo", "bar", "baz"], defs);
98+
99+
expect(result).toEqual({ name: "foo" });
100+
});
101+
});
102+
103+
describe("validatePositionals", () => {
104+
test("returns null when all required present", () => {
105+
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
106+
const result = validatePositionals({ name: "foo" }, defs);
107+
expect(result).toBeNull();
108+
});
109+
110+
test("returns error for missing required", () => {
111+
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
112+
const result = validatePositionals({ name: undefined }, defs);
113+
expect(result).toBe("Missing required argument: <name>");
114+
});
115+
116+
test("returns error for empty variadic required", () => {
117+
const defs: Record<string, PositionalDefinition> = { files: { required: true, variadic: true } };
118+
const result = validatePositionals({ files: [] }, defs);
119+
expect(result).toBe("Missing required argument: <files...>");
120+
});
121+
122+
test("skips non-required", () => {
123+
const defs: Record<string, PositionalDefinition> = { optional: { required: false } };
124+
const result = validatePositionals({ optional: undefined }, defs);
125+
expect(result).toBeNull();
126+
});
127+
});
128+
129+
describe("toKebabCase (via buildMriOptions)", () => {
130+
test("camelCase generates kebab-case alias", () => {
131+
const defs: Record<string, ArgDefinition> = { myLongOption: { type: "string" } };
132+
const opts = buildMriOptions(defs);
133+
expect(opts.alias["my-long-option"]).toBe("myLongOption");
134+
});
135+
136+
test("already kebab-case does not produce duplicate alias", () => {
137+
const defs: Record<string, ArgDefinition> = { "already-kebab": { type: "string" } };
138+
const opts = buildMriOptions(defs);
139+
expect(opts.alias["already-kebab"]).toBeUndefined();
140+
});
141+
});

packages/sisyphus/src/commands/actions/init.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { dirname } from "node:path";
44
import { args, color, confirm, Exit, log, multiselect, note } from "@r5n/cli-core";
55
import { BaseCommand, type Ctx } from "../../base-command";
66
import { detectProvider } from "../../providers";
7-
import { createCiGenerator, GitHubCiGenerator, GitLabCiGenerator, WORKFLOW_CONFIGS, type WorkflowType } from "./CiGenerator";
7+
import {
8+
createCiGenerator,
9+
GitHubCiGenerator,
10+
GitLabCiGenerator,
11+
WORKFLOW_CONFIGS,
12+
type WorkflowType,
13+
} from "./CiGenerator";
814

915
const PROVIDER_LABELS = { bitbucket: "Bitbucket Pipelines", github: "GitHub Actions", gitlab: "GitLab CI" };
1016

@@ -80,7 +86,7 @@ export class ActionsInitCommand extends BaseCommand {
8086

8187
if (generator instanceof GitHubCiGenerator) {
8288
log.info(
83-
`\n${color.yellow("Note:")} Enable ${color.bold("\"Allow GitHub Actions to create and approve pull requests\"")}`,
89+
`\n${color.yellow("Note:")} Enable ${color.bold('"Allow GitHub Actions to create and approve pull requests"')}`,
8490
);
8591
log.info(color.dim("Settings → Actions → General → Workflow permissions"));
8692
log.info("");

packages/sisyphus/src/domain/Commit.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ export class Commit {
130130
const commits: Commit[] = [];
131131

132132
for (let i = 0; i < segments.length; i += 2) {
133-
const fields = segments[i]!.split(FIELD_SEPARATOR);
133+
const segment = segments[i];
134+
if (!segment) continue;
135+
const fields = segment.split(FIELD_SEPARATOR);
134136
if (fields.length < 3) continue;
135137

136138
const [hash, subject, author] = fields;

packages/sisyphus/src/providers/GitHubProvider.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,7 @@ export class GitHubProvider extends GitProvider {
133133

134134
async getPrCommits(number: number): Promise<string[]> {
135135
try {
136-
const result =
137-
await Bun.$`gh api ${this.apiPath}/pulls/${number}/commits --jq '.[].sha'`.quiet();
136+
const result = await Bun.$`gh api ${this.apiPath}/pulls/${number}/commits --jq '.[].sha'`.quiet();
138137
return result.stdout.toString().trim().split("\n").filter(Boolean);
139138
} catch {
140139
return [];
@@ -143,8 +142,7 @@ export class GitHubProvider extends GitProvider {
143142

144143
async getPrFiles(number: number): Promise<string[]> {
145144
try {
146-
const result =
147-
await Bun.$`gh api ${this.apiPath}/pulls/${number}/files --jq '.[].filename'`.quiet();
145+
const result = await Bun.$`gh api ${this.apiPath}/pulls/${number}/files --jq '.[].filename'`.quiet();
148146
return result.stdout.toString().trim().split("\n").filter(Boolean);
149147
} catch {
150148
return [];

packages/sisyphus/src/providers/GitLabProvider.ts

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,7 @@ export class GitLabProvider extends GitProvider {
6060
if (options.head) params.set("source_branch", options.head);
6161
if (options.label) params.set("labels", options.label);
6262

63-
const mrs = await this.api<GitLabMrResponse[]>(
64-
`/projects/${this.projectPath}/merge_requests?${params}`,
65-
);
63+
const mrs = await this.api<GitLabMrResponse[]>(`/projects/${this.projectPath}/merge_requests?${params}`);
6664

6765
if (!mrs[0]) return null;
6866
return this.mapMrResponse(mrs[0]);
@@ -83,10 +81,10 @@ export class GitLabProvider extends GitProvider {
8381
body.labels = options.labels.join(",");
8482
}
8583

86-
const data = await this.api<GitLabMrResponse>(
87-
`/projects/${this.projectPath}/merge_requests`,
88-
{ body: JSON.stringify(body), method: "POST" },
89-
);
84+
const data = await this.api<GitLabMrResponse>(`/projects/${this.projectPath}/merge_requests`, {
85+
body: JSON.stringify(body),
86+
method: "POST",
87+
});
9088

9189
return this.mapMrResponse(data);
9290
}
@@ -96,17 +94,15 @@ export class GitLabProvider extends GitProvider {
9694
if (options.title) body.title = options.title;
9795
if (options.body) body.description = options.body;
9896

99-
await this.api(
100-
`/projects/${this.projectPath}/merge_requests/${number}`,
101-
{ body: JSON.stringify(body), method: "PUT" },
102-
);
97+
await this.api(`/projects/${this.projectPath}/merge_requests/${number}`, {
98+
body: JSON.stringify(body),
99+
method: "PUT",
100+
});
103101
}
104102

105103
async getPr(number: number): Promise<PullRequest> {
106104
try {
107-
const data = await this.api<GitLabMrResponse>(
108-
`/projects/${this.projectPath}/merge_requests/${number}`,
109-
);
105+
const data = await this.api<GitLabMrResponse>(`/projects/${this.projectPath}/merge_requests/${number}`);
110106
return this.mapMrResponse(data);
111107
} catch {
112108
throw new Exit(`Failed to fetch MR !${number}`, "Make sure the MR exists and you have access");
@@ -119,9 +115,7 @@ export class GitLabProvider extends GitProvider {
119115
const branch = result.stdout.toString().trim();
120116

121117
const params = new URLSearchParams({ per_page: "1", source_branch: branch, state: "opened" });
122-
const mrs = await this.api<GitLabMrResponse[]>(
123-
`/projects/${this.projectPath}/merge_requests?${params}`,
124-
);
118+
const mrs = await this.api<GitLabMrResponse[]>(`/projects/${this.projectPath}/merge_requests?${params}`);
125119

126120
if (!mrs[0]) throw new Error("No MR found");
127121
return this.mapMrResponse(mrs[0]);
@@ -158,29 +152,20 @@ export class GitLabProvider extends GitProvider {
158152
if (options?.description) body.description = options.description;
159153
if (options?.color) body.color = `#${options.color}`;
160154

161-
await this.api(`/projects/${this.projectPath}/labels`, {
162-
body: JSON.stringify(body),
163-
method: "POST",
164-
});
155+
await this.api(`/projects/${this.projectPath}/labels`, { body: JSON.stringify(body), method: "POST" });
165156
} catch {}
166157
}
167158

168159
async createRelease(options: CreateReleaseOptions): Promise<void> {
169160
await this.api(`/projects/${this.projectPath}/releases`, {
170-
body: JSON.stringify({
171-
description: options.notes,
172-
name: options.title,
173-
tag_name: options.tag,
174-
}),
161+
body: JSON.stringify({ description: options.notes, name: options.title, tag_name: options.tag }),
175162
method: "POST",
176163
});
177164
}
178165

179166
async deleteRelease(tag: string): Promise<void> {
180167
try {
181-
await this.api(`/projects/${this.projectPath}/releases/${encodeURIComponent(tag)}`, {
182-
method: "DELETE",
183-
});
168+
await this.api(`/projects/${this.projectPath}/releases/${encodeURIComponent(tag)}`, { method: "DELETE" });
184169
} catch {}
185170
}
186171

@@ -204,11 +189,7 @@ export class GitLabProvider extends GitProvider {
204189
private async api<T>(path: string, options?: RequestInit): Promise<T> {
205190
const response = await fetch(`${this.apiUrl}${path}`, {
206191
...options,
207-
headers: {
208-
"Content-Type": "application/json",
209-
...this.authHeader,
210-
...options?.headers,
211-
},
192+
headers: { "Content-Type": "application/json", ...this.authHeader, ...options?.headers },
212193
});
213194

214195
if (!response.ok) {

packages/sisyphus/src/services/ChangelogGenerator.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,7 @@ export class ChangelogGenerator {
177177
const date = this.getDate();
178178
const packageList = packages.map((p) => `${p.name}@${p.newVersion ?? p.version}`).join(", ");
179179

180-
return this.config.rootHeader
181-
.replace("{date}", () => date)
182-
.replace("{packages}", () => packageList);
180+
return this.config.rootHeader.replace("{date}", () => date).replace("{packages}", () => packageList);
183181
}
184182

185183
private formatRootStoneContent(stone: Stone): string {

0 commit comments

Comments
 (0)