Skip to content

Commit a01f0ca

Browse files
committed
test(core,sisyphus): add 192 unit tests for pure functions
core (33 tests): - deepMerge: flat merge, nested objects, arrays, prototype pollution - mri-utils: buildMriOptions, convertNumbers, mapPositionals, validatePositionals sisyphus (159 tests): - BumpType: compareBumps, higherBump, isBumpType - Stone: create, JSON roundtrip, merge, snapshot-drop edge case - Commit: conventional commit parsing, breaking changes, scopes - Package: fromJson, withBump, newVersion, labels - helpers: nonEmpty, ChangesetParser.toStoneData - VersionCalculator: semver bumps, pre-release, snapshot - GitRemoteParser: URL parsing for GitHub/GitLab/Bitbucket - utils: buildPackagePathMap, findAffectedPackages, findDependencyPackages
1 parent 7a4bb7e commit a01f0ca

10 files changed

Lines changed: 1574 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
/**
49+
* SECURITY: prototype pollution via constructor key.
50+
*
51+
* The current implementation uses spread (`{ ...target }`) and `Object.keys()`,
52+
* which treats `constructor` as a regular own property on the result object
53+
* rather than walking up the prototype chain. This means `Object.prototype`
54+
* is NOT polluted by this particular vector.
55+
*
56+
* However, the implementation does NOT explicitly reject dangerous keys
57+
* (`__proto__`, `constructor`, `prototype`). If the merge strategy changes
58+
* (e.g., to mutate in place), pollution could be re-introduced. Consider
59+
* adding an explicit key blocklist for defense in depth.
60+
*/
61+
test("SECURITY: prototype pollution via constructor key does not pollute Object.prototype", () => {
62+
const malicious = JSON.parse('{"constructor":{"prototype":{"polluted":true}}}');
63+
64+
const result = deepMerge({}, malicious);
65+
66+
// Object.prototype must NOT be polluted
67+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
68+
69+
// The constructor key should exist as a plain property on the result
70+
expect(result).toHaveProperty("constructor");
71+
});
72+
73+
test("mixed types: source number overwrites target object", () => {
74+
expect(deepMerge({ a: { nested: true } }, { a: 42 })).toEqual({ a: 42 });
75+
});
76+
77+
test("target keys not in source are preserved", () => {
78+
expect(deepMerge({ a: 1, b: 2, c: 3 }, { b: 20 })).toEqual({ a: 1, b: 20, c: 3 });
79+
});
80+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
const opts = buildMriOptions(defs);
10+
expect(opts.boolean).toEqual(["debug", "verbose"]);
11+
expect(opts.string).toEqual([]);
12+
});
13+
14+
test("string and number args go to string array", () => {
15+
const defs: Record<string, ArgDefinition> = { count: { type: "number" }, name: { type: "string" } };
16+
const opts = buildMriOptions(defs);
17+
expect(opts.string).toEqual(["count", "name"]);
18+
expect(opts.boolean).toEqual([]);
19+
});
20+
21+
test("aliases registered correctly", () => {
22+
const defs: Record<string, ArgDefinition> = {
23+
output: { alias: "o", type: "string" },
24+
verbose: { alias: "v", type: "boolean" },
25+
};
26+
const opts = buildMriOptions(defs);
27+
expect(opts.alias.v).toBe("verbose");
28+
expect(opts.alias.o).toBe("output");
29+
});
30+
31+
test("camelCase auto-generates kebab-case alias", () => {
32+
const defs: Record<string, ArgDefinition> = { dryRun: { type: "boolean" }, outputDir: { type: "string" } };
33+
const opts = buildMriOptions(defs);
34+
expect(opts.alias["dry-run"]).toBe("dryRun");
35+
expect(opts.alias["output-dir"]).toBe("outputDir");
36+
});
37+
38+
test("defaults populated", () => {
39+
const defs: Record<string, ArgDefinition> = {
40+
count: { default: 10, type: "number" },
41+
name: { type: "string" },
42+
verbose: { default: false, type: "boolean" },
43+
};
44+
const opts = buildMriOptions(defs);
45+
expect(opts.default).toEqual({ count: 10, verbose: false });
46+
});
47+
});
48+
49+
describe("convertNumbers", () => {
50+
test("converts string '42' to number 42", () => {
51+
const defs: Record<string, ArgDefinition> = { count: { type: "number" } };
52+
const result = convertNumbers({ count: "42" }, defs);
53+
expect(result.count).toBe(42);
54+
});
55+
56+
test("throws on invalid number (NaN)", () => {
57+
const defs: Record<string, ArgDefinition> = { count: { type: "number" } };
58+
expect(() => convertNumbers({ count: "abc" }, defs)).toThrow('Invalid number for --count: "abc"');
59+
});
60+
61+
test("skips boolean and string types", () => {
62+
const defs: Record<string, ArgDefinition> = { name: { type: "string" }, verbose: { type: "boolean" } };
63+
const result = convertNumbers({ name: "hello", verbose: true }, defs);
64+
expect(result.verbose).toBe(true);
65+
expect(result.name).toBe("hello");
66+
});
67+
68+
test("skips undefined values", () => {
69+
const defs: Record<string, ArgDefinition> = { count: { type: "number" } };
70+
const result = convertNumbers({} as Record<string, string | boolean>, defs);
71+
expect(result.count).toBeUndefined();
72+
});
73+
});
74+
75+
describe("mapPositionals", () => {
76+
test("maps single positional correctly", () => {
77+
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
78+
const result = mapPositionals(["foo"], defs);
79+
expect(result.name).toBe("foo");
80+
});
81+
82+
test("maps multiple positionals by index", () => {
83+
const defs: Record<string, PositionalDefinition> = { dest: { required: true }, source: { required: true } };
84+
const result = mapPositionals(["a.txt", "b.txt"], defs);
85+
expect(result.dest).toBe("a.txt");
86+
expect(result.source).toBe("b.txt");
87+
});
88+
89+
test("variadic positional captures rest as array", () => {
90+
const defs: Record<string, PositionalDefinition> = { first: { required: true }, rest: { variadic: true } };
91+
const result = mapPositionals(["a", "b", "c", "d"], defs);
92+
expect(result.first).toBe("a");
93+
expect(result.rest).toEqual(["b", "c", "d"]);
94+
});
95+
96+
test("missing positional returns undefined", () => {
97+
const defs: Record<string, PositionalDefinition> = { extra: {}, name: { required: true } };
98+
const result = mapPositionals(["foo"], defs);
99+
expect(result.extra).toBe("foo");
100+
expect(result.name).toBeUndefined();
101+
});
102+
103+
test("extra positionals silently dropped", () => {
104+
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
105+
const result = mapPositionals(["foo", "bar", "baz"], defs);
106+
expect(result.name).toBe("foo");
107+
expect(Object.keys(result)).toEqual(["name"]);
108+
});
109+
});
110+
111+
describe("validatePositionals", () => {
112+
test("returns null when all required present", () => {
113+
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
114+
const result = validatePositionals({ name: "foo" }, defs);
115+
expect(result).toBeNull();
116+
});
117+
118+
test("returns error for missing required", () => {
119+
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
120+
const result = validatePositionals({ name: undefined }, defs);
121+
expect(result).toBe("Missing required argument: <name>");
122+
});
123+
124+
test("returns error for empty variadic required", () => {
125+
const defs: Record<string, PositionalDefinition> = { files: { required: true, variadic: true } };
126+
const result = validatePositionals({ files: [] }, defs);
127+
expect(result).toBe("Missing required argument: <files...>");
128+
});
129+
130+
test("skips non-required", () => {
131+
const defs: Record<string, PositionalDefinition> = { optional: { required: false } };
132+
const result = validatePositionals({ optional: undefined }, defs);
133+
expect(result).toBeNull();
134+
});
135+
});
136+
137+
describe("toKebabCase (via buildMriOptions)", () => {
138+
test("camelCase generates kebab-case alias", () => {
139+
const defs: Record<string, ArgDefinition> = { myLongOption: { type: "string" } };
140+
const opts = buildMriOptions(defs);
141+
expect(opts.alias["my-long-option"]).toBe("myLongOption");
142+
});
143+
144+
test("already kebab-case does not produce duplicate alias", () => {
145+
const defs: Record<string, ArgDefinition> = { "already-kebab": { type: "string" } };
146+
const opts = buildMriOptions(defs);
147+
expect(opts.alias["already-kebab"]).toBeUndefined();
148+
});
149+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import {
4+
BUMP_EMOJI,
5+
BUMP_ORDER,
6+
BUMP_PRIORITY,
7+
BumpType,
8+
compareBumps,
9+
higherBump,
10+
isBumpType,
11+
} from "../../src/domain/BumpType";
12+
13+
describe("compareBumps", () => {
14+
test("Major > Minor returns positive", () => {
15+
expect(compareBumps(BumpType.Major, BumpType.Minor)).toBeGreaterThan(0);
16+
});
17+
18+
test("Minor > Patch returns positive", () => {
19+
expect(compareBumps(BumpType.Minor, BumpType.Patch)).toBeGreaterThan(0);
20+
});
21+
22+
test("Patch > Dependency returns positive", () => {
23+
expect(compareBumps(BumpType.Patch, BumpType.Dependency)).toBeGreaterThan(0);
24+
});
25+
26+
test("Dependency > Snapshot returns positive", () => {
27+
expect(compareBumps(BumpType.Dependency, BumpType.Snapshot)).toBeGreaterThan(0);
28+
});
29+
30+
test("same type returns 0", () => {
31+
expect(compareBumps(BumpType.Major, BumpType.Major)).toBe(0);
32+
expect(compareBumps(BumpType.Minor, BumpType.Minor)).toBe(0);
33+
expect(compareBumps(BumpType.Patch, BumpType.Patch)).toBe(0);
34+
expect(compareBumps(BumpType.Dependency, BumpType.Dependency)).toBe(0);
35+
expect(compareBumps(BumpType.Snapshot, BumpType.Snapshot)).toBe(0);
36+
});
37+
38+
test("Minor < Major returns negative", () => {
39+
expect(compareBumps(BumpType.Minor, BumpType.Major)).toBeLessThan(0);
40+
});
41+
});
42+
43+
describe("higherBump", () => {
44+
test("returns Major when Major vs Minor", () => {
45+
expect(higherBump(BumpType.Major, BumpType.Minor)).toBe(BumpType.Major);
46+
});
47+
48+
test("returns Major when Minor vs Major (order independent)", () => {
49+
expect(higherBump(BumpType.Minor, BumpType.Major)).toBe(BumpType.Major);
50+
});
51+
52+
test("equal bumps returns first argument", () => {
53+
expect(higherBump(BumpType.Patch, BumpType.Patch)).toBe(BumpType.Patch);
54+
});
55+
});
56+
57+
describe("isBumpType", () => {
58+
test.each(["major", "minor", "patch", "dependency", "snapshot"])('returns true for valid value "%s"', (value) => {
59+
expect(isBumpType(value)).toBe(true);
60+
});
61+
62+
test.each(["invalid", "", "Major", "MAJOR"])('returns false for invalid value "%s"', (value) => {
63+
expect(isBumpType(value)).toBe(false);
64+
});
65+
});
66+
67+
describe("BUMP_ORDER", () => {
68+
test("contains all 5 types in priority order", () => {
69+
expect(BUMP_ORDER).toEqual([
70+
BumpType.Major,
71+
BumpType.Minor,
72+
BumpType.Patch,
73+
BumpType.Dependency,
74+
BumpType.Snapshot,
75+
]);
76+
expect(BUMP_ORDER).toHaveLength(5);
77+
});
78+
});
79+
80+
describe("BUMP_PRIORITY", () => {
81+
test("Major has highest priority (5)", () => {
82+
expect(BUMP_PRIORITY[BumpType.Major]).toBe(5);
83+
84+
for (const type of [BumpType.Minor, BumpType.Patch, BumpType.Dependency, BumpType.Snapshot]) {
85+
expect(BUMP_PRIORITY[type]).toBeLessThan(BUMP_PRIORITY[BumpType.Major]);
86+
}
87+
});
88+
});
89+
90+
describe("BUMP_EMOJI", () => {
91+
test("all types have an emoji", () => {
92+
for (const type of Object.values(BumpType)) {
93+
expect(BUMP_EMOJI[type]).toBeDefined();
94+
expect(typeof BUMP_EMOJI[type]).toBe("string");
95+
expect(BUMP_EMOJI[type].length).toBeGreaterThan(0);
96+
}
97+
});
98+
});

0 commit comments

Comments
 (0)