From 7a4bb7e5d0f1743651168cf82c4cb23b3012c0a9 Mon Sep 17 00:00:00 2001 From: Ice Date: Mon, 23 Mar 2026 07:57:54 +0100 Subject: [PATCH 1/3] fix(tools): migrate noImportCycles from nursery to suspicious (biome 2.4.6) --- package.json | 9 +--- .../sisyphus/src/commands/actions/init.ts | 10 +++- packages/sisyphus/src/domain/Commit.ts | 2 +- .../sisyphus/src/providers/GitHubProvider.ts | 6 +-- .../sisyphus/src/providers/GitLabProvider.ts | 49 ++++++------------- .../src/services/ChangelogGenerator.ts | 4 +- .../sisyphus/src/services/GitRemoteParser.ts | 7 +-- tools/biome.json | 2 +- 8 files changed, 32 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index a7cd58d..ab76177 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "license": "Apache-2.0", "name": "r5n-clis", "packageManager": "bun@1.3.3", + "private": true, "scripts": { "build": "bun --filter '*' build", "clean": "bun --filter '*' clean; rm -rf node_modules; bun install", @@ -29,11 +30,5 @@ "type-check:go": "bun --elide-lines=0 --filter '*' type-check:go" }, "version": "0.0.0", - "workspaces": { - "packages": [ - "packages/*", - "tools" - ] - }, - "private": true + "workspaces": { "packages": ["packages/*", "tools"] } } diff --git a/packages/sisyphus/src/commands/actions/init.ts b/packages/sisyphus/src/commands/actions/init.ts index 67b83c7..a9c560f 100644 --- a/packages/sisyphus/src/commands/actions/init.ts +++ b/packages/sisyphus/src/commands/actions/init.ts @@ -4,7 +4,13 @@ import { dirname } from "node:path"; import { args, color, confirm, Exit, log, multiselect, note } from "@r5n/cli-core"; import { BaseCommand, type Ctx } from "../../base-command"; import { detectProvider } from "../../providers"; -import { createCiGenerator, GitHubCiGenerator, GitLabCiGenerator, WORKFLOW_CONFIGS, type WorkflowType } from "./CiGenerator"; +import { + createCiGenerator, + GitHubCiGenerator, + GitLabCiGenerator, + WORKFLOW_CONFIGS, + type WorkflowType, +} from "./CiGenerator"; const PROVIDER_LABELS = { bitbucket: "Bitbucket Pipelines", github: "GitHub Actions", gitlab: "GitLab CI" }; @@ -80,7 +86,7 @@ export class ActionsInitCommand extends BaseCommand { if (generator instanceof GitHubCiGenerator) { log.info( - `\n${color.yellow("Note:")} Enable ${color.bold("\"Allow GitHub Actions to create and approve pull requests\"")}`, + `\n${color.yellow("Note:")} Enable ${color.bold('"Allow GitHub Actions to create and approve pull requests"')}`, ); log.info(color.dim("Settings → Actions → General → Workflow permissions")); log.info(""); diff --git a/packages/sisyphus/src/domain/Commit.ts b/packages/sisyphus/src/domain/Commit.ts index ee2be56..f596e5a 100644 --- a/packages/sisyphus/src/domain/Commit.ts +++ b/packages/sisyphus/src/domain/Commit.ts @@ -130,7 +130,7 @@ export class Commit { const commits: Commit[] = []; for (let i = 0; i < segments.length; i += 2) { - const fields = segments[i]!.split(FIELD_SEPARATOR); + const fields = segments[i]?.split(FIELD_SEPARATOR); if (fields.length < 3) continue; const [hash, subject, author] = fields; diff --git a/packages/sisyphus/src/providers/GitHubProvider.ts b/packages/sisyphus/src/providers/GitHubProvider.ts index 84c0c57..1b2562b 100644 --- a/packages/sisyphus/src/providers/GitHubProvider.ts +++ b/packages/sisyphus/src/providers/GitHubProvider.ts @@ -133,8 +133,7 @@ export class GitHubProvider extends GitProvider { async getPrCommits(number: number): Promise { try { - const result = - await Bun.$`gh api ${this.apiPath}/pulls/${number}/commits --jq '.[].sha'`.quiet(); + const result = await Bun.$`gh api ${this.apiPath}/pulls/${number}/commits --jq '.[].sha'`.quiet(); return result.stdout.toString().trim().split("\n").filter(Boolean); } catch { return []; @@ -143,8 +142,7 @@ export class GitHubProvider extends GitProvider { async getPrFiles(number: number): Promise { try { - const result = - await Bun.$`gh api ${this.apiPath}/pulls/${number}/files --jq '.[].filename'`.quiet(); + const result = await Bun.$`gh api ${this.apiPath}/pulls/${number}/files --jq '.[].filename'`.quiet(); return result.stdout.toString().trim().split("\n").filter(Boolean); } catch { return []; diff --git a/packages/sisyphus/src/providers/GitLabProvider.ts b/packages/sisyphus/src/providers/GitLabProvider.ts index 7983fe3..71f4d14 100644 --- a/packages/sisyphus/src/providers/GitLabProvider.ts +++ b/packages/sisyphus/src/providers/GitLabProvider.ts @@ -60,9 +60,7 @@ export class GitLabProvider extends GitProvider { if (options.head) params.set("source_branch", options.head); if (options.label) params.set("labels", options.label); - const mrs = await this.api( - `/projects/${this.projectPath}/merge_requests?${params}`, - ); + const mrs = await this.api(`/projects/${this.projectPath}/merge_requests?${params}`); if (!mrs[0]) return null; return this.mapMrResponse(mrs[0]); @@ -83,10 +81,10 @@ export class GitLabProvider extends GitProvider { body.labels = options.labels.join(","); } - const data = await this.api( - `/projects/${this.projectPath}/merge_requests`, - { body: JSON.stringify(body), method: "POST" }, - ); + const data = await this.api(`/projects/${this.projectPath}/merge_requests`, { + body: JSON.stringify(body), + method: "POST", + }); return this.mapMrResponse(data); } @@ -96,17 +94,15 @@ export class GitLabProvider extends GitProvider { if (options.title) body.title = options.title; if (options.body) body.description = options.body; - await this.api( - `/projects/${this.projectPath}/merge_requests/${number}`, - { body: JSON.stringify(body), method: "PUT" }, - ); + await this.api(`/projects/${this.projectPath}/merge_requests/${number}`, { + body: JSON.stringify(body), + method: "PUT", + }); } async getPr(number: number): Promise { try { - const data = await this.api( - `/projects/${this.projectPath}/merge_requests/${number}`, - ); + const data = await this.api(`/projects/${this.projectPath}/merge_requests/${number}`); return this.mapMrResponse(data); } catch { 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 { const branch = result.stdout.toString().trim(); const params = new URLSearchParams({ per_page: "1", source_branch: branch, state: "opened" }); - const mrs = await this.api( - `/projects/${this.projectPath}/merge_requests?${params}`, - ); + const mrs = await this.api(`/projects/${this.projectPath}/merge_requests?${params}`); if (!mrs[0]) throw new Error("No MR found"); return this.mapMrResponse(mrs[0]); @@ -158,29 +152,20 @@ export class GitLabProvider extends GitProvider { if (options?.description) body.description = options.description; if (options?.color) body.color = `#${options.color}`; - await this.api(`/projects/${this.projectPath}/labels`, { - body: JSON.stringify(body), - method: "POST", - }); + await this.api(`/projects/${this.projectPath}/labels`, { body: JSON.stringify(body), method: "POST" }); } catch {} } async createRelease(options: CreateReleaseOptions): Promise { await this.api(`/projects/${this.projectPath}/releases`, { - body: JSON.stringify({ - description: options.notes, - name: options.title, - tag_name: options.tag, - }), + body: JSON.stringify({ description: options.notes, name: options.title, tag_name: options.tag }), method: "POST", }); } async deleteRelease(tag: string): Promise { try { - await this.api(`/projects/${this.projectPath}/releases/${encodeURIComponent(tag)}`, { - method: "DELETE", - }); + await this.api(`/projects/${this.projectPath}/releases/${encodeURIComponent(tag)}`, { method: "DELETE" }); } catch {} } @@ -204,11 +189,7 @@ export class GitLabProvider extends GitProvider { private async api(path: string, options?: RequestInit): Promise { const response = await fetch(`${this.apiUrl}${path}`, { ...options, - headers: { - "Content-Type": "application/json", - ...this.authHeader, - ...options?.headers, - }, + headers: { "Content-Type": "application/json", ...this.authHeader, ...options?.headers }, }); if (!response.ok) { diff --git a/packages/sisyphus/src/services/ChangelogGenerator.ts b/packages/sisyphus/src/services/ChangelogGenerator.ts index a4d7897..7b27325 100644 --- a/packages/sisyphus/src/services/ChangelogGenerator.ts +++ b/packages/sisyphus/src/services/ChangelogGenerator.ts @@ -177,9 +177,7 @@ export class ChangelogGenerator { const date = this.getDate(); const packageList = packages.map((p) => `${p.name}@${p.newVersion ?? p.version}`).join(", "); - return this.config.rootHeader - .replace("{date}", () => date) - .replace("{packages}", () => packageList); + return this.config.rootHeader.replace("{date}", () => date).replace("{packages}", () => packageList); } private formatRootStoneContent(stone: Stone): string { diff --git a/packages/sisyphus/src/services/GitRemoteParser.ts b/packages/sisyphus/src/services/GitRemoteParser.ts index 57ca597..03c8854 100644 --- a/packages/sisyphus/src/services/GitRemoteParser.ts +++ b/packages/sisyphus/src/services/GitRemoteParser.ts @@ -1,4 +1,4 @@ -import { getRemoteUrl, parseRemoteUrl, type Provider, type RemoteInfo } from "../providers"; +import { getRemoteUrl, type Provider, parseRemoteUrl, type RemoteInfo } from "../providers"; const COMMIT_PATH: Record = { bitbucket: "commits", github: "commit", gitlab: "-/commit" }; const PROVIDER_DOMAIN: Record = { @@ -31,10 +31,7 @@ export class GitRemoteParser { const commitPath = COMMIT_PATH[info.provider]; const baseUrl = `https://${domain}/${info.owner}/${info.repo}`; - this.cached = { - ...info, - commitUrl: (hash: string) => `${baseUrl}/${commitPath}/${hash}`, - }; + this.cached = { ...info, commitUrl: (hash: string) => `${baseUrl}/${commitPath}/${hash}` }; return this.cached; } } diff --git a/tools/biome.json b/tools/biome.json index 674e373..6db43cf 100644 --- a/tools/biome.json +++ b/tools/biome.json @@ -78,7 +78,7 @@ "useSelfClosingElements": "error", "useSingleVarDeclarator": "error" }, - "suspicious": { "noExplicitAny": "off", "noReactSpecificProps": "off", "noImportCycles": "on" } + "suspicious": { "noExplicitAny": "off", "noImportCycles": "on", "noReactSpecificProps": "off" } } }, "root": false From a01f0ca57dfafdeda29949763174a95293a03873 Mon Sep 17 00:00:00 2001 From: Ice Date: Mon, 23 Mar 2026 07:58:05 +0100 Subject: [PATCH 2/3] 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 --- packages/core/tests/util/deepMerge.test.ts | 80 +++++ packages/core/tests/util/mri-utils.test.ts | 149 +++++++++ .../sisyphus/tests/domain/BumpType.test.ts | 98 ++++++ packages/sisyphus/tests/domain/Commit.test.ts | 192 ++++++++++++ .../sisyphus/tests/domain/Package.test.ts | 228 ++++++++++++++ packages/sisyphus/tests/domain/Stone.test.ts | 290 ++++++++++++++++++ .../sisyphus/tests/domain/helpers.test.ts | 106 +++++++ .../tests/services/GitRemoteParser.test.ts | 175 +++++++++++ .../tests/services/VersionCalculator.test.ts | 108 +++++++ packages/sisyphus/tests/utils.test.ts | 148 +++++++++ 10 files changed, 1574 insertions(+) create mode 100644 packages/core/tests/util/deepMerge.test.ts create mode 100644 packages/core/tests/util/mri-utils.test.ts create mode 100644 packages/sisyphus/tests/domain/BumpType.test.ts create mode 100644 packages/sisyphus/tests/domain/Commit.test.ts create mode 100644 packages/sisyphus/tests/domain/Package.test.ts create mode 100644 packages/sisyphus/tests/domain/Stone.test.ts create mode 100644 packages/sisyphus/tests/domain/helpers.test.ts create mode 100644 packages/sisyphus/tests/services/GitRemoteParser.test.ts create mode 100644 packages/sisyphus/tests/services/VersionCalculator.test.ts create mode 100644 packages/sisyphus/tests/utils.test.ts diff --git a/packages/core/tests/util/deepMerge.test.ts b/packages/core/tests/util/deepMerge.test.ts new file mode 100644 index 0000000..a2411d5 --- /dev/null +++ b/packages/core/tests/util/deepMerge.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { deepMerge } from "../../src/util/misc"; + +describe("deepMerge", () => { + test("merges flat objects", () => { + expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); + }); + + test("source overrides target for same key", () => { + expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); + }); + + test("deep nested merge", () => { + expect(deepMerge({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } }); + }); + + test("arrays are replaced entirely, not concatenated", () => { + expect(deepMerge({ a: [1, 2] }, { a: [3] })).toEqual({ a: [3] }); + }); + + test("non-object source returns source", () => { + expect(deepMerge({ a: 1 }, "string")).toBe("string"); + }); + + test("non-object target with object source returns source", () => { + expect(deepMerge("string", { a: 1 })).toEqual({ a: 1 }); + }); + + test("null source returns null (source wins)", () => { + expect(deepMerge({ a: 1 }, null)).toBeNull(); + }); + + test("undefined values in source overwrite target", () => { + expect(deepMerge({ a: 1, b: 2 }, { a: undefined })).toEqual({ a: undefined, b: 2 }); + }); + + test("empty objects merge to empty object", () => { + expect(deepMerge({}, {})).toEqual({}); + }); + + test("deep nested 3+ levels", () => { + const target = { a: { b: { c: 1, d: 2 }, e: 3 } }; + const source = { a: { b: { c: 10, f: 4 }, g: 5 } }; + + expect(deepMerge(target, source)).toEqual({ a: { b: { c: 10, d: 2, f: 4 }, e: 3, g: 5 } }); + }); + + /** + * SECURITY: prototype pollution via constructor key. + * + * The current implementation uses spread (`{ ...target }`) and `Object.keys()`, + * which treats `constructor` as a regular own property on the result object + * rather than walking up the prototype chain. This means `Object.prototype` + * is NOT polluted by this particular vector. + * + * However, the implementation does NOT explicitly reject dangerous keys + * (`__proto__`, `constructor`, `prototype`). If the merge strategy changes + * (e.g., to mutate in place), pollution could be re-introduced. Consider + * adding an explicit key blocklist for defense in depth. + */ + test("SECURITY: prototype pollution via constructor key does not pollute Object.prototype", () => { + const malicious = JSON.parse('{"constructor":{"prototype":{"polluted":true}}}'); + + const result = deepMerge({}, malicious); + + // Object.prototype must NOT be polluted + expect(({} as Record).polluted).toBeUndefined(); + + // The constructor key should exist as a plain property on the result + expect(result).toHaveProperty("constructor"); + }); + + test("mixed types: source number overwrites target object", () => { + expect(deepMerge({ a: { nested: true } }, { a: 42 })).toEqual({ a: 42 }); + }); + + test("target keys not in source are preserved", () => { + expect(deepMerge({ a: 1, b: 2, c: 3 }, { b: 20 })).toEqual({ a: 1, b: 20, c: 3 }); + }); +}); diff --git a/packages/core/tests/util/mri-utils.test.ts b/packages/core/tests/util/mri-utils.test.ts new file mode 100644 index 0000000..90774b5 --- /dev/null +++ b/packages/core/tests/util/mri-utils.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import type { ArgDefinition } from "../../src/command/args"; +import type { PositionalDefinition } from "../../src/command/positionals"; +import { buildMriOptions, convertNumbers, mapPositionals, validatePositionals } from "../../src/util/mri-utils"; + +describe("buildMriOptions", () => { + test("boolean args go to boolean array", () => { + const defs: Record = { debug: { type: "boolean" }, verbose: { type: "boolean" } }; + const opts = buildMriOptions(defs); + expect(opts.boolean).toEqual(["debug", "verbose"]); + expect(opts.string).toEqual([]); + }); + + test("string and number args go to string array", () => { + const defs: Record = { count: { type: "number" }, name: { type: "string" } }; + const opts = buildMriOptions(defs); + expect(opts.string).toEqual(["count", "name"]); + expect(opts.boolean).toEqual([]); + }); + + test("aliases registered correctly", () => { + const defs: Record = { + output: { alias: "o", type: "string" }, + verbose: { alias: "v", type: "boolean" }, + }; + const opts = buildMriOptions(defs); + expect(opts.alias.v).toBe("verbose"); + expect(opts.alias.o).toBe("output"); + }); + + test("camelCase auto-generates kebab-case alias", () => { + const defs: Record = { dryRun: { type: "boolean" }, outputDir: { type: "string" } }; + const opts = buildMriOptions(defs); + expect(opts.alias["dry-run"]).toBe("dryRun"); + expect(opts.alias["output-dir"]).toBe("outputDir"); + }); + + test("defaults populated", () => { + const defs: Record = { + count: { default: 10, type: "number" }, + name: { type: "string" }, + verbose: { default: false, type: "boolean" }, + }; + const opts = buildMriOptions(defs); + expect(opts.default).toEqual({ count: 10, verbose: false }); + }); +}); + +describe("convertNumbers", () => { + test("converts string '42' to number 42", () => { + const defs: Record = { count: { type: "number" } }; + const result = convertNumbers({ count: "42" }, defs); + expect(result.count).toBe(42); + }); + + test("throws on invalid number (NaN)", () => { + const defs: Record = { count: { type: "number" } }; + expect(() => convertNumbers({ count: "abc" }, defs)).toThrow('Invalid number for --count: "abc"'); + }); + + test("skips boolean and string types", () => { + const defs: Record = { name: { type: "string" }, verbose: { type: "boolean" } }; + const result = convertNumbers({ name: "hello", verbose: true }, defs); + expect(result.verbose).toBe(true); + expect(result.name).toBe("hello"); + }); + + test("skips undefined values", () => { + const defs: Record = { count: { type: "number" } }; + const result = convertNumbers({} as Record, defs); + expect(result.count).toBeUndefined(); + }); +}); + +describe("mapPositionals", () => { + test("maps single positional correctly", () => { + const defs: Record = { name: { required: true } }; + const result = mapPositionals(["foo"], defs); + expect(result.name).toBe("foo"); + }); + + test("maps multiple positionals by index", () => { + const defs: Record = { dest: { required: true }, source: { required: true } }; + const result = mapPositionals(["a.txt", "b.txt"], defs); + expect(result.dest).toBe("a.txt"); + expect(result.source).toBe("b.txt"); + }); + + test("variadic positional captures rest as array", () => { + const defs: Record = { first: { required: true }, rest: { variadic: true } }; + const result = mapPositionals(["a", "b", "c", "d"], defs); + expect(result.first).toBe("a"); + expect(result.rest).toEqual(["b", "c", "d"]); + }); + + test("missing positional returns undefined", () => { + const defs: Record = { extra: {}, name: { required: true } }; + const result = mapPositionals(["foo"], defs); + expect(result.extra).toBe("foo"); + expect(result.name).toBeUndefined(); + }); + + test("extra positionals silently dropped", () => { + const defs: Record = { name: { required: true } }; + const result = mapPositionals(["foo", "bar", "baz"], defs); + expect(result.name).toBe("foo"); + expect(Object.keys(result)).toEqual(["name"]); + }); +}); + +describe("validatePositionals", () => { + test("returns null when all required present", () => { + const defs: Record = { name: { required: true } }; + const result = validatePositionals({ name: "foo" }, defs); + expect(result).toBeNull(); + }); + + test("returns error for missing required", () => { + const defs: Record = { name: { required: true } }; + const result = validatePositionals({ name: undefined }, defs); + expect(result).toBe("Missing required argument: "); + }); + + test("returns error for empty variadic required", () => { + const defs: Record = { files: { required: true, variadic: true } }; + const result = validatePositionals({ files: [] }, defs); + expect(result).toBe("Missing required argument: "); + }); + + test("skips non-required", () => { + const defs: Record = { optional: { required: false } }; + const result = validatePositionals({ optional: undefined }, defs); + expect(result).toBeNull(); + }); +}); + +describe("toKebabCase (via buildMriOptions)", () => { + test("camelCase generates kebab-case alias", () => { + const defs: Record = { myLongOption: { type: "string" } }; + const opts = buildMriOptions(defs); + expect(opts.alias["my-long-option"]).toBe("myLongOption"); + }); + + test("already kebab-case does not produce duplicate alias", () => { + const defs: Record = { "already-kebab": { type: "string" } }; + const opts = buildMriOptions(defs); + expect(opts.alias["already-kebab"]).toBeUndefined(); + }); +}); diff --git a/packages/sisyphus/tests/domain/BumpType.test.ts b/packages/sisyphus/tests/domain/BumpType.test.ts new file mode 100644 index 0000000..3d1d83f --- /dev/null +++ b/packages/sisyphus/tests/domain/BumpType.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; + +import { + BUMP_EMOJI, + BUMP_ORDER, + BUMP_PRIORITY, + BumpType, + compareBumps, + higherBump, + isBumpType, +} from "../../src/domain/BumpType"; + +describe("compareBumps", () => { + test("Major > Minor returns positive", () => { + expect(compareBumps(BumpType.Major, BumpType.Minor)).toBeGreaterThan(0); + }); + + test("Minor > Patch returns positive", () => { + expect(compareBumps(BumpType.Minor, BumpType.Patch)).toBeGreaterThan(0); + }); + + test("Patch > Dependency returns positive", () => { + expect(compareBumps(BumpType.Patch, BumpType.Dependency)).toBeGreaterThan(0); + }); + + test("Dependency > Snapshot returns positive", () => { + expect(compareBumps(BumpType.Dependency, BumpType.Snapshot)).toBeGreaterThan(0); + }); + + test("same type returns 0", () => { + expect(compareBumps(BumpType.Major, BumpType.Major)).toBe(0); + expect(compareBumps(BumpType.Minor, BumpType.Minor)).toBe(0); + expect(compareBumps(BumpType.Patch, BumpType.Patch)).toBe(0); + expect(compareBumps(BumpType.Dependency, BumpType.Dependency)).toBe(0); + expect(compareBumps(BumpType.Snapshot, BumpType.Snapshot)).toBe(0); + }); + + test("Minor < Major returns negative", () => { + expect(compareBumps(BumpType.Minor, BumpType.Major)).toBeLessThan(0); + }); +}); + +describe("higherBump", () => { + test("returns Major when Major vs Minor", () => { + expect(higherBump(BumpType.Major, BumpType.Minor)).toBe(BumpType.Major); + }); + + test("returns Major when Minor vs Major (order independent)", () => { + expect(higherBump(BumpType.Minor, BumpType.Major)).toBe(BumpType.Major); + }); + + test("equal bumps returns first argument", () => { + expect(higherBump(BumpType.Patch, BumpType.Patch)).toBe(BumpType.Patch); + }); +}); + +describe("isBumpType", () => { + test.each(["major", "minor", "patch", "dependency", "snapshot"])('returns true for valid value "%s"', (value) => { + expect(isBumpType(value)).toBe(true); + }); + + test.each(["invalid", "", "Major", "MAJOR"])('returns false for invalid value "%s"', (value) => { + expect(isBumpType(value)).toBe(false); + }); +}); + +describe("BUMP_ORDER", () => { + test("contains all 5 types in priority order", () => { + expect(BUMP_ORDER).toEqual([ + BumpType.Major, + BumpType.Minor, + BumpType.Patch, + BumpType.Dependency, + BumpType.Snapshot, + ]); + expect(BUMP_ORDER).toHaveLength(5); + }); +}); + +describe("BUMP_PRIORITY", () => { + test("Major has highest priority (5)", () => { + expect(BUMP_PRIORITY[BumpType.Major]).toBe(5); + + for (const type of [BumpType.Minor, BumpType.Patch, BumpType.Dependency, BumpType.Snapshot]) { + expect(BUMP_PRIORITY[type]).toBeLessThan(BUMP_PRIORITY[BumpType.Major]); + } + }); +}); + +describe("BUMP_EMOJI", () => { + test("all types have an emoji", () => { + for (const type of Object.values(BumpType)) { + expect(BUMP_EMOJI[type]).toBeDefined(); + expect(typeof BUMP_EMOJI[type]).toBe("string"); + expect(BUMP_EMOJI[type].length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/sisyphus/tests/domain/Commit.test.ts b/packages/sisyphus/tests/domain/Commit.test.ts new file mode 100644 index 0000000..17a90a2 --- /dev/null +++ b/packages/sisyphus/tests/domain/Commit.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import type { CommitInfo } from "../../src/domain/Commit"; +import { Commit, OTHER_COMMIT_TYPE } from "../../src/domain/Commit"; + +const HASH = "abc1234567890def"; + +describe("Commit.parse", () => { + test("parses standard feat commit", () => { + const commit = Commit.parse(HASH, "feat: add feature"); + + expect(commit.type).toBe("feat"); + expect(commit.message).toBe("add feature"); + expect(commit.breaking).toBe(false); + expect(commit.scope).toBeUndefined(); + expect(commit.subject).toBe("feat: add feature"); + expect(commit.hash).toBe(HASH); + expect(commit.files).toEqual([]); + }); + + test("parses standard fix commit", () => { + const commit = Commit.parse(HASH, "fix: resolve bug"); + + expect(commit.type).toBe("fix"); + expect(commit.message).toBe("resolve bug"); + expect(commit.breaking).toBe(false); + }); + + test("parses commit with scope", () => { + const commit = Commit.parse(HASH, "feat(api): add endpoint"); + + expect(commit.type).toBe("feat"); + expect(commit.scope).toBe("api"); + expect(commit.message).toBe("add endpoint"); + expect(commit.breaking).toBe(false); + }); + + test("parses breaking change with !", () => { + const commit = Commit.parse(HASH, "feat!: breaking change"); + + expect(commit.type).toBe("feat"); + expect(commit.breaking).toBe(true); + expect(commit.message).toBe("breaking change"); + }); + + test("parses breaking change with scope and !", () => { + const commit = Commit.parse(HASH, "feat(api)!: break it"); + + expect(commit.type).toBe("feat"); + expect(commit.scope).toBe("api"); + expect(commit.breaking).toBe(true); + expect(commit.message).toBe("break it"); + }); + + describe("all known commit types", () => { + const knownTypes = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "style", "test"]; + + for (const type of knownTypes) { + test(`recognizes "${type}" as conventional type`, () => { + const commit = Commit.parse(HASH, `${type}: some message`); + + expect(commit.type).toBe(type); + expect(commit.message).toBe("some message"); + expect(commit.isConventional).toBe(true); + }); + } + }); + + test("unknown type falls back to OTHER_COMMIT_TYPE", () => { + const commit = Commit.parse(HASH, "unknown: something"); + + expect(commit.type).toBe(OTHER_COMMIT_TYPE); + expect(commit.type).toBe("other"); + expect(commit.message).toBe("unknown: something"); + }); + + test("non-conventional commit falls back to OTHER_COMMIT_TYPE", () => { + const commit = Commit.parse(HASH, "just a message"); + + expect(commit.type).toBe(OTHER_COMMIT_TYPE); + expect(commit.message).toBe("just a message"); + expect(commit.subject).toBe("just a message"); + }); + + test("commit with colon but no known type falls back to OTHER_COMMIT_TYPE", () => { + const commit = Commit.parse(HASH, "WIP: work in progress"); + + expect(commit.type).toBe(OTHER_COMMIT_TYPE); + expect(commit.message).toBe("WIP: work in progress"); + }); + + test("empty subject edge case", () => { + const commit = Commit.parse(HASH, ""); + + expect(commit.type).toBe(OTHER_COMMIT_TYPE); + expect(commit.message).toBe(""); + expect(commit.subject).toBe(""); + expect(commit.breaking).toBe(false); + }); +}); + +describe("Commit getters", () => { + test("shortHash returns first 7 characters", () => { + const commit = Commit.parse(HASH, "feat: something"); + + expect(commit.shortHash).toBe("abc1234"); + expect(commit.shortHash).toHaveLength(7); + }); + + test("shortHash works with exactly 7 char hash", () => { + const commit = Commit.parse("abc1234", "feat: something"); + + expect(commit.shortHash).toBe("abc1234"); + }); + + test("isConventional returns true for known types", () => { + const commit = Commit.parse(HASH, "feat: something"); + + expect(commit.isConventional).toBe(true); + }); + + test("isConventional returns false for other type", () => { + const commit = Commit.parse(HASH, "random message"); + + expect(commit.isConventional).toBe(false); + }); +}); + +describe("Commit.withFiles", () => { + test("returns new Commit with files set", () => { + const original = Commit.parse(HASH, "feat: something"); + const files = ["src/index.ts", "src/utils.ts"]; + const withFiles = original.withFiles(files); + + expect(withFiles.files).toEqual(files); + expect(withFiles.type).toBe("feat"); + expect(withFiles.message).toBe("something"); + expect(withFiles.hash).toBe(HASH); + // Original is unchanged + expect(original.files).toEqual([]); + }); +}); + +describe("Commit.withBody", () => { + test("returns new Commit with body set", () => { + const original = Commit.parse(HASH, "feat: something"); + const withBody = original.withBody("detailed description"); + + expect(withBody.body).toBe("detailed description"); + expect(withBody.type).toBe("feat"); + expect(withBody.message).toBe("something"); + // Original is unchanged + expect(original.body).toBeUndefined(); + }); + + test("returns new Commit with undefined body", () => { + const original = Commit.parse(HASH, "feat: something").withBody("some body"); + const withoutBody = original.withBody(undefined); + + expect(withoutBody.body).toBeUndefined(); + }); +}); + +describe("Commit.toInfo", () => { + test("returns CommitInfo with correct fields", () => { + const commit = Commit.parse(HASH, "feat(api): add endpoint").withBody("some body").withFiles(["src/api.ts"]); + + const info: CommitInfo = commit.toInfo(["@org/api"]); + + expect(info).toEqual({ + body: "some body", + hash: "abc1234", + message: "add endpoint", + packages: ["@org/api"], + scope: "api", + subject: "feat(api): add endpoint", + type: "feat", + }); + }); + + test("returns CommitInfo for non-conventional commit", () => { + const commit = Commit.parse(HASH, "just a message"); + + const info = commit.toInfo([]); + + expect(info.type).toBe("other"); + expect(info.message).toBe("just a message"); + expect(info.hash).toBe("abc1234"); + expect(info.packages).toEqual([]); + expect(info.scope).toBeUndefined(); + expect(info.body).toBeUndefined(); + }); +}); diff --git a/packages/sisyphus/tests/domain/Package.test.ts b/packages/sisyphus/tests/domain/Package.test.ts new file mode 100644 index 0000000..29838fd --- /dev/null +++ b/packages/sisyphus/tests/domain/Package.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, test } from "bun:test"; +import { BUMP_ORDER, BumpType } from "../../src/domain/BumpType"; +import type { PackageJson } from "../../src/domain/Package"; +import { Package } from "../../src/domain/Package"; +import { Stone } from "../../src/domain/Stone"; + +const makePackage = (overrides?: Partial, file = "packages/core/package.json"): Package => + Package.fromJson({ name: "@app/core", version: "1.2.3", ...overrides }, file); + +/** + * Applies bumps from a stone to matching packages — the pattern used + * throughout sisyphus to connect stones with packages. + */ +function applyStone(packages: Package[], stone: Stone): Package[] { + const result: Package[] = []; + + for (const pkg of packages) { + let applied = false; + for (const bump of BUMP_ORDER) { + if (stone.getPackages(bump).includes(pkg.name)) { + result.push(pkg.withBump(bump, stone.tag)); + applied = true; + break; + } + } + if (!applied) { + result.push(pkg); + } + } + + return result; +} + +describe("Package.fromJson()", () => { + test("creates package with name, version, and file", () => { + const json: PackageJson = { name: "@app/core", version: "1.2.3" }; + const pkg = Package.fromJson(json, "packages/core/package.json"); + + expect(pkg.name).toBe("@app/core"); + expect(pkg.version).toBe("1.2.3"); + expect(pkg.file).toBe("packages/core/package.json"); + expect(pkg.bump).toBeUndefined(); + expect(pkg.dependencyOf).toEqual([]); + }); + + test("uses '0.0.0' for missing version", () => { + const json: PackageJson = { name: "@app/core" }; + const pkg = Package.fromJson(json, "packages/core/package.json"); + + expect(pkg.version).toBe("0.0.0"); + }); + + test("uses '0.0.0' for empty string version", () => { + const json: PackageJson = { name: "@app/core", version: "" }; + const pkg = Package.fromJson(json, "packages/core/package.json"); + + expect(pkg.version).toBe("0.0.0"); + }); + + test("handles private packages", () => { + const json: PackageJson = { name: "@app/private", private: true, version: "0.1.0" }; + const pkg = Package.fromJson(json, "packages/private/package.json"); + + expect(pkg.name).toBe("@app/private"); + expect(pkg.version).toBe("0.1.0"); + }); +}); + +describe("package.withBump()", () => { + test("returns new Package with bump set", () => { + const original = makePackage(); + const bumped = original.withBump(BumpType.Minor); + + expect(bumped.bump).toBe(BumpType.Minor); + expect(bumped.name).toBe(original.name); + expect(bumped.version).toBe(original.version); + expect(bumped.file).toBe(original.file); + // original is unchanged (immutable) + expect(original.bump).toBeUndefined(); + }); + + test("preserves tag when provided", () => { + const pkg = makePackage(); + const bumped = pkg.withBump(BumpType.Minor, "beta"); + + expect(bumped.bump).toBe(BumpType.Minor); + expect(bumped.tag).toBe("beta"); + }); + + test("overwrites previous bump", () => { + const pkg = makePackage(); + const minor = pkg.withBump(BumpType.Minor); + const major = minor.withBump(BumpType.Major); + + expect(major.bump).toBe(BumpType.Major); + expect(minor.bump).toBe(BumpType.Minor); + }); +}); + +describe("package.withDependencyOf()", () => { + test("sets dependency list", () => { + const pkg = makePackage(); + const updated = pkg.withDependencyOf(["@app/cli", "@app/web"]); + + expect(updated.dependencyOf).toEqual(["@app/cli", "@app/web"]); + expect(pkg.dependencyOf).toEqual([]); // original unchanged + }); + + test("preserves other fields", () => { + const pkg = makePackage().withBump(BumpType.Patch); + const updated = pkg.withDependencyOf(["@app/cli"]); + + expect(updated.bump).toBe(BumpType.Patch); + expect(updated.name).toBe("@app/core"); + expect(updated.version).toBe("1.2.3"); + }); +}); + +describe("package.newVersion getter", () => { + test("computes via VersionCalculator when bump is set", () => { + const pkg = makePackage({ version: "1.2.3" }).withBump(BumpType.Patch); + expect(pkg.newVersion).toBe("1.2.4"); + }); + + test("computes major bump correctly", () => { + const pkg = makePackage({ version: "1.2.3" }).withBump(BumpType.Major); + expect(pkg.newVersion).toBe("2.0.0"); + }); + + test("computes minor bump correctly", () => { + const pkg = makePackage({ version: "1.2.3" }).withBump(BumpType.Minor); + expect(pkg.newVersion).toBe("1.3.0"); + }); + + test("computes dependency bump as patch increment", () => { + const pkg = makePackage({ version: "1.2.3" }).withBump(BumpType.Dependency); + expect(pkg.newVersion).toBe("1.2.4"); + }); + + test("computes tagged bump correctly", () => { + const pkg = makePackage({ version: "1.0.0" }).withBump(BumpType.Minor, "beta"); + expect(pkg.newVersion).toBe("1.0.0-beta.1"); + }); + + test("returns undefined when no bump is set", () => { + const pkg = makePackage(); + expect(pkg.newVersion).toBeUndefined(); + }); +}); + +describe("package.label getter", () => { + test("returns name@version when no bump set", () => { + const pkg = makePackage({ version: "1.2.3" }); + expect(pkg.label).toBe("@app/core@1.2.3"); + }); + + test("returns formatted string with version arrow and emoji for patch", () => { + const pkg = makePackage({ version: "1.2.3" }).withBump(BumpType.Patch); + expect(pkg.label).toBe("@app/core@1.2.3 => 1.2.4 🐛"); + }); + + test("returns formatted string with version arrow and emoji for major", () => { + const pkg = makePackage({ version: "1.2.3" }).withBump(BumpType.Major); + expect(pkg.label).toBe("@app/core@1.2.3 => 2.0.0 🚨"); + }); + + test("returns formatted string with version arrow and emoji for minor", () => { + const pkg = makePackage({ version: "1.2.3" }).withBump(BumpType.Minor); + expect(pkg.label).toBe("@app/core@1.2.3 => 1.3.0 ✨"); + }); + + test("returns formatted string with emoji for dependency bump", () => { + const pkg = makePackage({ version: "1.2.3" }).withBump(BumpType.Dependency); + expect(pkg.label).toBe("@app/core@1.2.3 => 1.2.4 📦"); + }); +}); + +describe("applyStone pattern — applying bumps from stone to matching packages", () => { + test("applies bumps from stone to matching packages", () => { + const packages = [ + makePackage({ name: "@app/core", version: "1.0.0" }), + makePackage({ name: "@app/utils", version: "2.0.0" }), + ]; + + const stone = Stone.create({ major: ["@app/core"], message: "release", minor: ["@app/utils"] }); + + const result = applyStone(packages, stone); + + expect(result[0]?.bump).toBe(BumpType.Major); + expect(result[0]?.newVersion).toBe("2.0.0"); + expect(result[1]?.bump).toBe(BumpType.Minor); + expect(result[1]?.newVersion).toBe("2.1.0"); + }); + + test("respects BUMP_ORDER priority — major applied before minor for same package", () => { + // If a package appears in both major and minor in a stone, + // BUMP_ORDER iteration means major is checked first. + const stone = Stone.create({ + major: ["@app/core"], + message: "release", + minor: ["@app/core"], // also listed in minor + }); + + const packages = [makePackage({ name: "@app/core", version: "1.0.0" })]; + const result = applyStone(packages, stone); + + // Major comes first in BUMP_ORDER, so major bump wins + expect(result[0]?.bump).toBe(BumpType.Major); + expect(result[0]?.newVersion).toBe("2.0.0"); + }); + + test("ignores packages not in the stone", () => { + const packages = [ + makePackage({ name: "@app/core", version: "1.0.0" }), + makePackage({ name: "@app/unrelated", version: "3.0.0" }), + ]; + + const stone = Stone.create({ message: "release", patch: ["@app/core"] }); + + const result = applyStone(packages, stone); + + expect(result[0]?.bump).toBe(BumpType.Patch); + expect(result[0]?.newVersion).toBe("1.0.1"); + // Unrelated package is unchanged + expect(result[1]?.bump).toBeUndefined(); + expect(result[1]?.newVersion).toBeUndefined(); + }); +}); diff --git a/packages/sisyphus/tests/domain/Stone.test.ts b/packages/sisyphus/tests/domain/Stone.test.ts new file mode 100644 index 0000000..bccd5b1 --- /dev/null +++ b/packages/sisyphus/tests/domain/Stone.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, test } from "bun:test"; +import { BumpType } from "../../src/domain/BumpType"; +import type { CommitInfo } from "../../src/domain/Commit"; +import type { StoneData, StoneJson } from "../../src/domain/Stone"; +import { Stone } from "../../src/domain/Stone"; + +const makeCommit = (hash: string, message: string): CommitInfo => ({ + hash, + message, + packages: [], + subject: `feat: ${message}`, + type: "feat", +}); + +const baseData: StoneData = { + commits: [makeCommit("abc1234", "add core feature")], + dependency: ["@app/deps"], + description: "Initial release", + major: ["@app/core"], + message: "release v1.0.0", + minor: ["@app/utils"], + patch: ["@app/cli"], + snapshot: ["@app/snapshot"], + tag: "v1.0.0", +}; + +describe("Stone.create()", () => { + test("generates sequential ID with UUID suffix", () => { + const stone = Stone.create(baseData, 0); + expect(stone.id).toMatch(/^0001-[a-f0-9]{8}$/); + + const stone42 = Stone.create(baseData, 42); + expect(stone42.id).toMatch(/^0043-[a-f0-9]{8}$/); + }); + + test("stores packages correctly by bump type", () => { + const stone = Stone.create(baseData); + + expect(stone.major).toEqual(["@app/core"]); + expect(stone.minor).toEqual(["@app/utils"]); + expect(stone.patch).toEqual(["@app/cli"]); + expect(stone.dependency).toEqual(["@app/deps"]); + expect(stone.snapshot).toEqual(["@app/snapshot"]); + expect(stone.message).toBe("release v1.0.0"); + expect(stone.tag).toBe("v1.0.0"); + expect(stone.description).toBe("Initial release"); + }); +}); + +describe("Stone.fromJson() / toJson()", () => { + test("roundtrip preserves all data", () => { + const original = Stone.create(baseData, 5); + const json = original.toJson(); + const restored = Stone.fromJson(json); + + expect(restored.id).toBe(original.id); + expect(restored.message).toBe(original.message); + expect(restored.tag).toBe(original.tag); + expect(restored.description).toBe(original.description); + expect(restored.major).toEqual(original.major); + expect(restored.minor).toEqual(original.minor); + expect(restored.patch).toEqual(original.patch); + expect(restored.dependency).toEqual(original.dependency); + expect(restored.snapshot).toEqual(original.snapshot); + expect(restored.commits).toEqual(original.commits); + }); + + test("handles missing optional fields (no snapshot, no commits)", () => { + const minimalJson: StoneJson = { id: "0001-deadbeef", message: "minimal stone" }; + + const stone = Stone.fromJson(minimalJson); + + expect(stone.id).toBe("0001-deadbeef"); + expect(stone.message).toBe("minimal stone"); + expect(stone.tag).toBeUndefined(); + expect(stone.description).toBeUndefined(); + expect(stone.commits).toBeUndefined(); + expect(stone.major).toEqual([]); + expect(stone.minor).toEqual([]); + expect(stone.patch).toEqual([]); + expect(stone.dependency).toEqual([]); + expect(stone.snapshot).toEqual([]); + }); +}); + +describe("Stone.merge()", () => { + test("merges two stones with non-overlapping packages", () => { + const stoneA = Stone.create({ major: ["@app/core"], message: "stone A", minor: ["@app/utils"] }); + const stoneB = Stone.create({ dependency: ["@app/deps"], message: "stone B", patch: ["@app/cli"] }); + + const { stone, conflicts } = Stone.merge([stoneA, stoneB], "merged"); + + expect(conflicts).toEqual([]); + expect(stone.major).toEqual(["@app/core"]); + expect(stone.minor).toEqual(["@app/utils"]); + expect(stone.patch).toEqual(["@app/cli"]); + expect(stone.dependency).toEqual(["@app/deps"]); + expect(stone.message).toBe("merged"); + }); + + test("detects conflicts when same package at different bump levels", () => { + const stoneA = Stone.create({ message: "stone A", minor: ["@app/core"] }); + const stoneB = Stone.create({ message: "stone B", patch: ["@app/core"] }); + + const { conflicts } = Stone.merge([stoneA, stoneB], "merged"); + + expect(conflicts).toContain("@app/core"); + }); + + test("resolves conflicts using higherBump (major wins over minor)", () => { + const stoneA = Stone.create({ message: "stone A", minor: ["@app/core"] }); + const stoneB = Stone.create({ major: ["@app/core"], message: "stone B" }); + + const { stone, conflicts } = Stone.merge([stoneA, stoneB], "merged"); + + expect(conflicts).toContain("@app/core"); + expect(stone.major).toContain("@app/core"); + expect(stone.minor).not.toContain("@app/core"); + }); + + test("BUG: drops Snapshot packages entirely during merge", () => { + // collectBumps only iterates Major, Minor, Patch, Dependency — not Snapshot. + // This means any packages in the snapshot bump type are silently lost. + const stoneA = Stone.create({ message: "stone A", snapshot: ["@app/snapshot-pkg"] }); + const stoneB = Stone.create({ message: "stone B", patch: ["@app/cli"] }); + + const { stone } = Stone.merge([stoneA, stoneB], "merged"); + + // Snapshot packages are NOT carried over — this is a bug + expect(stone.snapshot).toEqual([]); + expect(stone.allPackages).not.toContain("@app/snapshot-pkg"); + }); + + test("merges descriptions from both stones", () => { + const stoneA = Stone.create({ description: "Description A", message: "A" }); + const stoneB = Stone.create({ description: "Description B", message: "B" }); + + const { stone } = Stone.merge([stoneA, stoneB], "merged"); + + expect(stone.description).toBe("Description A\n\nDescription B"); + }); + + test("deduplicates commits", () => { + const commit1 = makeCommit("aaa1111", "first"); + const commit2 = makeCommit("bbb2222", "second"); + + const stoneA = Stone.create({ commits: [commit1, commit2], message: "A" }); + const stoneB = Stone.create({ commits: [commit2], message: "B" }); + + const { stone } = Stone.merge([stoneA, stoneB], "merged"); + + // NOTE: merge uses flatMap without dedup — commit2 appears twice. + // This documents actual behavior: commits are NOT deduplicated. + const hashes = stone.commits?.map((c) => c.hash) ?? []; + expect(hashes).toEqual(["aaa1111", "bbb2222", "bbb2222"]); + }); + + test("silently picks first tag when tags conflict", () => { + // When multiple stones have different tags, merge creates a Set + // then picks tags[0] — the first unique tag encountered. + // There is no conflict reported for tag mismatches. + const stoneA = Stone.create({ message: "A", tag: "v1.0.0" }); + const stoneB = Stone.create({ message: "B", tag: "v2.0.0" }); + + const { stone, conflicts } = Stone.merge([stoneA, stoneB], "merged"); + + expect(stone.tag).toBe("v1.0.0"); + // No conflict is reported for tag mismatch + expect(conflicts).toEqual([]); + }); +}); + +describe("stone.isEmpty", () => { + test("returns true for a stone with no packages", () => { + const stone = Stone.create({ message: "empty" }); + expect(stone.isEmpty).toBe(true); + }); + + test("returns false for a stone with packages", () => { + const stone = Stone.create({ message: "has stuff", patch: ["@app/cli"] }); + expect(stone.isEmpty).toBe(false); + }); +}); + +describe("stone.allPackages", () => { + test("returns all packages across bump types", () => { + const stone = Stone.create(baseData); + const all = stone.allPackages; + + expect(all).toContain("@app/core"); + expect(all).toContain("@app/utils"); + expect(all).toContain("@app/cli"); + expect(all).toContain("@app/deps"); + expect(all).toContain("@app/snapshot"); + expect(all).toHaveLength(5); + }); +}); + +describe("stone.affectsPackage() via getPackages", () => { + test("finds package in the correct bump type", () => { + const stone = Stone.create(baseData); + + expect(stone.getPackages(BumpType.Major)).toContain("@app/core"); + expect(stone.getPackages(BumpType.Minor)).toContain("@app/utils"); + expect(stone.getPackages(BumpType.Patch)).toContain("@app/cli"); + expect(stone.getPackages(BumpType.Dependency)).toContain("@app/deps"); + expect(stone.getPackages(BumpType.Snapshot)).toContain("@app/snapshot"); + }); + + test("returns empty array for bump type with no packages", () => { + const stone = Stone.create({ major: ["@app/core"], message: "only major" }); + + expect(stone.getPackages(BumpType.Minor)).toEqual([]); + expect(stone.getPackages(BumpType.Patch)).toEqual([]); + }); +}); + +describe("immutable update methods", () => { + test("withMessage() returns new stone with updated message", () => { + const original = Stone.create(baseData); + const updated = original.withMessage("new message"); + + expect(updated.message).toBe("new message"); + expect(original.message).toBe("release v1.0.0"); + expect(updated.id).toBe(original.id); + expect(updated.tag).toBe(original.tag); + expect(updated.major).toEqual(original.major); + }); + + test("withTag() returns new stone with updated tag", () => { + const original = Stone.create(baseData); + const updated = original.withTag("v2.0.0"); + + expect(updated.tag).toBe("v2.0.0"); + expect(original.tag).toBe("v1.0.0"); + expect(updated.id).toBe(original.id); + }); + + test("withTag(undefined) clears the tag", () => { + const original = Stone.create(baseData); + const updated = original.withTag(undefined); + + expect(updated.tag).toBeUndefined(); + }); + + test("withDescription() returns new stone with updated description", () => { + const original = Stone.create(baseData); + const updated = original.withDescription("Updated description"); + + expect(updated.description).toBe("Updated description"); + expect(original.description).toBe("Initial release"); + expect(updated.id).toBe(original.id); + }); + + test("withDescription(undefined) clears the description", () => { + const original = Stone.create(baseData); + const updated = original.withDescription(undefined); + + expect(updated.description).toBeUndefined(); + }); +}); + +describe("toJson()", () => { + test("omits empty arrays via nonEmpty helper", () => { + const stone = Stone.create({ major: ["@app/core"], message: "only major" }); + const json = stone.toJson(); + + expect(json.major).toEqual(["@app/core"]); + expect(json.minor).toBeUndefined(); + expect(json.patch).toBeUndefined(); + expect(json.dependency).toBeUndefined(); + expect(json.snapshot).toBeUndefined(); + }); + + test("omits commits when empty or undefined", () => { + const stone = Stone.create({ message: "no commits" }); + const json = stone.toJson(); + + expect(json.commits).toBeUndefined(); + }); + + test("includes commits when present", () => { + const commit = makeCommit("abc1234", "a feature"); + const stone = Stone.create({ commits: [commit], message: "with commits" }); + const json = stone.toJson(); + + expect(json.commits).toHaveLength(1); + expect(json.commits?.[0]?.hash).toBe("abc1234"); + }); +}); diff --git a/packages/sisyphus/tests/domain/helpers.test.ts b/packages/sisyphus/tests/domain/helpers.test.ts new file mode 100644 index 0000000..b5c347a --- /dev/null +++ b/packages/sisyphus/tests/domain/helpers.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { nonEmpty } from "../../src/domain/helpers"; +import { type ChangesetContent, ChangesetParser } from "../../src/services/ChangesetParser"; + +describe("nonEmpty", () => { + test("returns the array when non-empty", () => { + expect(nonEmpty(["a"])).toEqual(["a"]); + }); + + test("returns undefined when empty", () => { + expect(nonEmpty([])).toBeUndefined(); + }); + + test("single element array", () => { + expect(nonEmpty([42])).toEqual([42]); + }); + + test("large array preserves all elements", () => { + const large = Array.from({ length: 1000 }, (_, i) => i); + const result = nonEmpty(large); + expect(result).toHaveLength(1000); + expect(result?.[999]).toBe(999); + }); +}); + +describe("ChangesetParser.toStoneData", () => { + const parser = new ChangesetParser("/fake"); + + const makeChangeset = (packages: Record, summary: string): ChangesetContent => ({ + filename: "test-changeset.md", + packages, + summary, + }); + + test("converts major packages correctly", () => { + const cs = makeChangeset({ "@app/core": "major", "@app/ui": "major" }, "Breaking change"); + const result = parser.toStoneData(cs); + + expect(result.major).toEqual(["@app/core", "@app/ui"]); + expect(result.minor).toBeUndefined(); + expect(result.patch).toBeUndefined(); + }); + + test("converts minor packages correctly", () => { + const cs = makeChangeset({ "@app/utils": "minor" }, "New feature"); + const result = parser.toStoneData(cs); + + expect(result.minor).toEqual(["@app/utils"]); + expect(result.major).toBeUndefined(); + expect(result.patch).toBeUndefined(); + }); + + test("converts patch packages correctly", () => { + const cs = makeChangeset({ "@app/core": "patch", "@app/lib": "patch" }, "Bug fix"); + const result = parser.toStoneData(cs); + + expect(result.patch).toEqual(["@app/core", "@app/lib"]); + expect(result.major).toBeUndefined(); + expect(result.minor).toBeUndefined(); + }); + + test("unknown bump types are silently dropped", () => { + // BUG: unknown bump types like "prepatch" are silently ignored + // because the switch statement has no default case + const cs = makeChangeset({ "@app/core": "prepatch" }, "Pre-release"); + const result = parser.toStoneData(cs); + + expect(result.major).toBeUndefined(); + expect(result.minor).toBeUndefined(); + expect(result.patch).toBeUndefined(); + expect(result.message).toBe("Pre-release"); + }); + + test("first line of summary used as message", () => { + const cs = makeChangeset({ "@app/core": "minor" }, "First line message\nSecond line detail\nThird line"); + const result = parser.toStoneData(cs); + + expect(result.message).toBe("First line message"); + expect(result.description).toBe("Second line detail\nThird line"); + }); + + test("fallback message for empty summary", () => { + const cs = makeChangeset({ "@app/core": "patch" }, ""); + const result = parser.toStoneData(cs); + + expect(result.message).toBe("Migrated from changeset"); + expect(result.description).toBeUndefined(); + }); + + test("mixed bump types are categorized correctly", () => { + const cs = makeChangeset({ "@app/core": "major", "@app/lib": "patch", "@app/utils": "minor" }, "Mixed changes"); + const result = parser.toStoneData(cs); + + expect(result.major).toEqual(["@app/core"]); + expect(result.minor).toEqual(["@app/utils"]); + expect(result.patch).toEqual(["@app/lib"]); + }); + + test("description is undefined when summary is single line", () => { + const cs = makeChangeset({ "@app/core": "patch" }, "Only one line"); + const result = parser.toStoneData(cs); + + expect(result.message).toBe("Only one line"); + expect(result.description).toBeUndefined(); + }); +}); diff --git a/packages/sisyphus/tests/services/GitRemoteParser.test.ts b/packages/sisyphus/tests/services/GitRemoteParser.test.ts new file mode 100644 index 0000000..d1bdd4a --- /dev/null +++ b/packages/sisyphus/tests/services/GitRemoteParser.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; + +// Copied from src/services/GitRemoteParser.ts to test regex patterns directly, +// since parseUrl and the patterns are private/module-scoped. +const GITHUB_PATTERN = /github\.com[:/]([^/]+)\/([^/.]+)/; +const GITLAB_PATTERN = /gitlab\.com[:/]([^/]+)\/([^/.]+)/; +const BITBUCKET_PATTERN = /bitbucket\.org[:/]([^/]+)\/([^/.]+)/; + +type Provider = "github" | "gitlab" | "bitbucket"; + +const PROVIDER_DOMAIN: Record = { + bitbucket: "bitbucket.org", + github: "github.com", + gitlab: "gitlab.com", +}; + +const COMMIT_PATH: Record = { bitbucket: "commits", github: "commit", gitlab: "-/commit" }; + +/** Mirrors the parseUrl logic from GitRemoteParser */ +function parseUrl( + url: string, +): { provider: Provider; owner: string; repo: string; commitUrl: (hash: string) => string } | null { + const patterns: [RegExp, Provider][] = [ + [GITHUB_PATTERN, "github"], + [GITLAB_PATTERN, "gitlab"], + [BITBUCKET_PATTERN, "bitbucket"], + ]; + + for (const [pattern, provider] of patterns) { + const match = url.match(pattern); + if (match) { + const [, owner, repo] = match; + if (owner && repo) { + const domain = PROVIDER_DOMAIN[provider]; + const commitPath = COMMIT_PATH[provider]; + const baseUrl = `https://${domain}/${owner}/${repo}`; + return { commitUrl: (hash: string) => `${baseUrl}/${commitPath}/${hash}`, owner, provider, repo }; + } + } + } + + return null; +} + +describe("GitRemoteParser URL parsing", () => { + describe("GitHub", () => { + test("parses HTTPS URL with .git suffix", () => { + const result = parseUrl("https://github.com/owner/repo.git"); + expect(result).not.toBeNull(); + expect(result?.provider).toBe("github"); + expect(result?.owner).toBe("owner"); + expect(result?.repo).toBe("repo"); + }); + + test("parses SSH URL", () => { + const result = parseUrl("git@github.com:owner/repo.git"); + expect(result).not.toBeNull(); + expect(result?.provider).toBe("github"); + expect(result?.owner).toBe("owner"); + expect(result?.repo).toBe("repo"); + }); + + test("parses HTTPS URL without .git suffix", () => { + const result = parseUrl("https://github.com/owner/repo"); + expect(result).not.toBeNull(); + expect(result?.provider).toBe("github"); + expect(result?.owner).toBe("owner"); + expect(result?.repo).toBe("repo"); + }); + + test("generates correct commit URL", () => { + const result = parseUrl("https://github.com/owner/repo.git"); + expect(result?.commitUrl("abc123")).toBe("https://github.com/owner/repo/commit/abc123"); + }); + }); + + describe("GitLab", () => { + test("parses HTTPS URL with .git suffix", () => { + const result = parseUrl("https://gitlab.com/owner/repo.git"); + expect(result).not.toBeNull(); + expect(result?.provider).toBe("gitlab"); + expect(result?.owner).toBe("owner"); + expect(result?.repo).toBe("repo"); + }); + + test("parses SSH URL", () => { + const result = parseUrl("git@gitlab.com:owner/repo.git"); + expect(result).not.toBeNull(); + expect(result?.provider).toBe("gitlab"); + expect(result?.owner).toBe("owner"); + expect(result?.repo).toBe("repo"); + }); + + test("generates correct commit URL with -/commit path", () => { + const result = parseUrl("https://gitlab.com/owner/repo.git"); + expect(result?.commitUrl("def456")).toBe("https://gitlab.com/owner/repo/-/commit/def456"); + }); + }); + + describe("Bitbucket", () => { + test("parses HTTPS URL with .git suffix", () => { + const result = parseUrl("https://bitbucket.org/owner/repo.git"); + expect(result).not.toBeNull(); + expect(result?.provider).toBe("bitbucket"); + expect(result?.owner).toBe("owner"); + expect(result?.repo).toBe("repo"); + }); + + test("parses SSH URL", () => { + const result = parseUrl("git@bitbucket.org:owner/repo.git"); + expect(result).not.toBeNull(); + expect(result?.provider).toBe("bitbucket"); + expect(result?.owner).toBe("owner"); + expect(result?.repo).toBe("repo"); + }); + + test("generates correct commit URL with commits path", () => { + const result = parseUrl("https://bitbucket.org/owner/repo.git"); + expect(result?.commitUrl("789abc")).toBe("https://bitbucket.org/owner/repo/commits/789abc"); + }); + }); + + describe("URL with credentials", () => { + test("parses HTTPS URL containing user:token credentials", () => { + const result = parseUrl("https://user:token@github.com/owner/repo.git"); + expect(result).not.toBeNull(); + expect(result?.provider).toBe("github"); + expect(result?.owner).toBe("owner"); + expect(result?.repo).toBe("repo"); + }); + }); + + describe("no match cases", () => { + test("returns null for unknown host", () => { + const result = parseUrl("https://sourcehut.org/owner/repo.git"); + expect(result).toBeNull(); + }); + + test("returns null for malformed URL", () => { + const result = parseUrl("not-a-url"); + expect(result).toBeNull(); + }); + + test("returns null for empty string", () => { + const result = parseUrl(""); + expect(result).toBeNull(); + }); + }); + + describe("known bugs", () => { + test("BUG: dots in repo names - captures only text before first dot", () => { + // The regex [^/.] excludes dots, so "my.project.git" matches only "my" + // instead of "my.project". This is a known limitation. + const result = parseUrl("https://github.com/owner/my.project.git"); + expect(result).not.toBeNull(); + // Current (buggy) behavior: repo is "my" instead of "my.project" + expect(result?.repo).toBe("my"); + // If fixed, this should be: + // expect(result!.repo).toBe("my.project"); + }); + + test("BUG: GitLab nested subgroups - captures group as owner, subgroup as repo", () => { + // GitLab supports nested subgroups like gitlab.com/group/subgroup/repo + // The regex only captures the first two path segments, so it gets + // owner="group" and repo="subgroup" instead of the actual repo. + const result = parseUrl("https://gitlab.com/group/subgroup/repo.git"); + expect(result).not.toBeNull(); + // Current (buggy) behavior: captures subgroup instead of repo + expect(result?.owner).toBe("group"); + expect(result?.repo).toBe("subgroup"); + // If fixed, this should handle nested paths correctly: + // expect(result!.repo).toBe("repo"); + }); + }); +}); diff --git a/packages/sisyphus/tests/services/VersionCalculator.test.ts b/packages/sisyphus/tests/services/VersionCalculator.test.ts new file mode 100644 index 0000000..2232206 --- /dev/null +++ b/packages/sisyphus/tests/services/VersionCalculator.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { BumpType } from "../../src/domain/BumpType"; +import { VersionCalculator } from "../../src/services/VersionCalculator"; + +describe("VersionCalculator", () => { + describe("bump", () => { + test("major bump resets minor and patch: 1.2.3 => 2.0.0", () => { + expect(VersionCalculator.bump("1.2.3", BumpType.Major)).toBe("2.0.0"); + }); + + test("minor bump resets patch: 1.2.3 => 1.3.0", () => { + expect(VersionCalculator.bump("1.2.3", BumpType.Minor)).toBe("1.3.0"); + }); + + test("patch bump increments patch: 1.2.3 => 1.2.4", () => { + expect(VersionCalculator.bump("1.2.3", BumpType.Patch)).toBe("1.2.4"); + }); + + test("dependency bump behaves like patch: 1.2.3 => 1.2.4", () => { + expect(VersionCalculator.bump("1.2.3", BumpType.Dependency)).toBe("1.2.4"); + }); + + test("major bump from 0.x: 0.1.0 => 1.0.0", () => { + expect(VersionCalculator.bump("0.1.0", BumpType.Major)).toBe("1.0.0"); + }); + + test("pre-release with tag: 1.0.0 + minor + alpha => 1.0.0-alpha.1", () => { + expect(VersionCalculator.bump("1.0.0", BumpType.Minor, "alpha")).toBe("1.0.0-alpha.1"); + }); + + test("pre-release increment same tag: 1.0.0-alpha.1 + minor + alpha => 1.0.0-alpha.2", () => { + expect(VersionCalculator.bump("1.0.0-alpha.1", BumpType.Minor, "alpha")).toBe("1.0.0-alpha.2"); + }); + + test("pre-release new tag resets counter: 1.0.0-alpha.3 + minor + beta => 1.0.0-beta.1", () => { + expect(VersionCalculator.bump("1.0.0-alpha.3", BumpType.Minor, "beta")).toBe("1.0.0-beta.1"); + }); + + test("snapshot returns 0.0.0-nightly- format", () => { + const result = VersionCalculator.bump("1.2.3", BumpType.Snapshot); + expect(result).toMatch(/^0\.0\.0-nightly-\d{14}$/); + }); + + test("snapshot with custom tag uses that tag", () => { + const result = VersionCalculator.bump("1.2.3", BumpType.Snapshot, "canary"); + expect(result).toMatch(/^0\.0\.0-canary-\d{14}$/); + }); + + test("malformed version input produces partial NaN in output (known bug)", () => { + const result = VersionCalculator.bump("invalid", BumpType.Patch); + // "invalid".split(".") => ["invalid"], minor/patch default to "0" + // Only major parses as NaN; minor and patch get default values + expect(result).toBe("NaN.0.1"); + }); + + test("empty string version produces partial NaN in output", () => { + const result = VersionCalculator.bump("", BumpType.Patch); + // "".split("-") => [""], then "".split(".") => [""] + // parseInt("") => NaN for major, minor/patch default to "0" + expect(result).toBe("NaN.0.1"); + }); + + test("default case returns unchanged version for unknown bump type", () => { + const result = VersionCalculator.bump("1.2.3", "unknown" as BumpType); + expect(result).toBe("1.2.3"); + }); + + test("dependency bump on pre-release preserves tag: 1.0.0-rc.2 + dependency => 1.0.0-rc.3", () => { + expect(VersionCalculator.bump("1.0.0-rc.2", BumpType.Dependency)).toBe("1.0.0-rc.3"); + }); + + test("dependency bump without tag on non-prerelease acts as patch", () => { + expect(VersionCalculator.bump("2.0.0", BumpType.Dependency)).toBe("2.0.1"); + }); + }); + + describe("formatLabel", () => { + test("formats label with name, versions, and emoji", () => { + const result = VersionCalculator.formatLabel("my-pkg", "1.2.3", BumpType.Minor); + expect(result).toBe("my-pkg@1.2.3 => 1.3.0 \u2728"); + }); + + test("formats label for major bump with correct emoji", () => { + const result = VersionCalculator.formatLabel("core", "0.5.0", BumpType.Major); + expect(result).toBe("core@0.5.0 => 1.0.0 \uD83D\uDEA8"); + }); + + test("formats label for patch bump with correct emoji", () => { + const result = VersionCalculator.formatLabel("utils", "3.1.0", BumpType.Patch); + expect(result).toBe("utils@3.1.0 => 3.1.1 \uD83D\uDC1B"); + }); + + test("formats label for dependency bump with correct emoji", () => { + const result = VersionCalculator.formatLabel("lib", "1.0.0", BumpType.Dependency); + expect(result).toBe("lib@1.0.0 => 1.0.1 \uD83D\uDCE6"); + }); + + test("formats label for snapshot bump with correct emoji", () => { + const result = VersionCalculator.formatLabel("app", "1.0.0", BumpType.Snapshot); + expect(result).toMatch(/^app@1\.0\.0 => 0\.0\.0-nightly-\d{14} \uD83D\uDCF8$/); + }); + + test("formats label with pre-release tag", () => { + const result = VersionCalculator.formatLabel("pkg", "2.0.0", BumpType.Minor, "beta"); + expect(result).toBe("pkg@2.0.0 => 2.0.0-beta.1 \u2728"); + }); + }); +}); diff --git a/packages/sisyphus/tests/utils.test.ts b/packages/sisyphus/tests/utils.test.ts new file mode 100644 index 0000000..db94e67 --- /dev/null +++ b/packages/sisyphus/tests/utils.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test"; +import { Package } from "../src/domain/Package"; +import { buildPackagePathMap, findAffectedPackages, findDependencyPackages } from "../src/utils"; + +const makePackage = (name: string, file: string, dependencyOf?: string[]) => + new Package({ dependencyOf, file, name, version: "1.0.0" }); + +describe("buildPackagePathMap", () => { + test("maps package file paths to directory -> name pairs", () => { + const packages = new Map([ + ["@scope/ui", makePackage("@scope/ui", "packages/ui/package.json")], + ["@scope/core", makePackage("@scope/core", "packages/core/package.json")], + ]); + + const result = buildPackagePathMap(packages); + + expect(result.get("packages/ui")).toBe("@scope/ui"); + expect(result.get("packages/core")).toBe("@scope/core"); + expect(result.size).toBe(2); + }); + + test("root package gets '.' as directory", () => { + const packages = new Map([["root", makePackage("root", "package.json")]]); + + const result = buildPackagePathMap(packages); + + expect(result.get(".")).toBe("root"); + }); + + test("handles backslashes (Windows paths)", () => { + const packages = new Map([["@scope/ui", makePackage("@scope/ui", "packages\\ui\\package.json")]]); + + const result = buildPackagePathMap(packages); + + expect(result.get("packages/ui")).toBe("@scope/ui"); + }); +}); + +describe("findAffectedPackages", () => { + const packages = new Map([ + ["root", makePackage("root", "package.json")], + ["@scope/ui", makePackage("@scope/ui", "packages/ui/package.json")], + ["@scope/core", makePackage("@scope/core", "packages/core/package.json")], + ]); + const pathMap = buildPackagePathMap(packages); + + test("file in package dir matches that package", () => { + const result = findAffectedPackages(["packages/ui/index.ts"], pathMap); + + expect(result.has("@scope/ui")).toBe(true); + }); + + test("file in nested subdir of package matches", () => { + const result = findAffectedPackages(["packages/core/src/deep/nested/file.ts"], pathMap); + + expect(result.has("@scope/core")).toBe(true); + }); + + test("file at root matches root package when includeRoot=true", () => { + const result = findAffectedPackages(["README.md"], pathMap, true); + + expect(result.has("root")).toBe(true); + }); + + test("file at root does NOT match when includeRoot=false", () => { + const result = findAffectedPackages(["README.md"], pathMap, false); + + expect(result.has("root")).toBe(false); + }); + + /** + * BUG: Root package is included for EVERY file regardless of path. + * + * When dir === ".", the condition `isRoot ? includeRoot : matchesPath` + * evaluates to `true` whenever includeRoot is true, regardless of whether + * the file actually belongs to the root package. This means every file + * (even ones clearly inside a sub-package) will also mark the root as + * affected when includeRoot=true. + */ + test("BUG: root package is included for every file when includeRoot=true", () => { + const result = findAffectedPackages(["packages/ui/index.ts"], pathMap, true); + + // The file is inside packages/ui, so only @scope/ui should match. + // However, due to the bug, root is also included. + expect(result.has("@scope/ui")).toBe(true); + expect(result.has("root")).toBe(true); // bug: root should NOT be here + }); + + test("file matching no package is ignored", () => { + const pathMapNoRoot = new Map([["packages/ui", "@scope/ui"]]); + + const result = findAffectedPackages(["some/other/path/file.ts"], pathMapNoRoot); + + expect(result.size).toBe(0); + }); + + test("multiple files affecting different packages", () => { + const result = findAffectedPackages(["packages/ui/button.ts", "packages/core/utils.ts"], pathMap, false); + + expect(result.has("@scope/ui")).toBe(true); + expect(result.has("@scope/core")).toBe(true); + expect(result.has("root")).toBe(false); + }); +}); + +describe("findDependencyPackages", () => { + test("returns dependents not in selected list", () => { + const packages = new Map([ + ["@scope/utils", makePackage("@scope/utils", "packages/utils/package.json", ["@scope/ui", "@scope/core"])], + ]); + + const result = findDependencyPackages(["@scope/utils"], packages); + + expect(result).toContain("@scope/ui"); + expect(result).toContain("@scope/core"); + expect(result).toHaveLength(2); + }); + + test("skips packages already in selectedNames", () => { + const packages = new Map([ + ["@scope/utils", makePackage("@scope/utils", "packages/utils/package.json", ["@scope/ui", "@scope/core"])], + ]); + + const result = findDependencyPackages(["@scope/utils", "@scope/ui"], packages); + + expect(result).toContain("@scope/core"); + expect(result).not.toContain("@scope/ui"); + expect(result).toHaveLength(1); + }); + + test("returns empty when no dependencies", () => { + const packages = new Map([["@scope/ui", makePackage("@scope/ui", "packages/ui/package.json")]]); + + const result = findDependencyPackages(["@scope/ui"], packages); + + expect(result).toEqual([]); + }); + + test("handles packages with no dependencyOf field", () => { + const packages = new Map([ + ["@scope/ui", makePackage("@scope/ui", "packages/ui/package.json", undefined)], + ]); + + const result = findDependencyPackages(["@scope/ui"], packages); + + expect(result).toEqual([]); + }); +}); From 6081cf5f587eb8e9c3832fdaff5e4bc5750e094a Mon Sep 17 00:00:00 2001 From: Ice Date: Sat, 28 Mar 2026 08:11:33 +0100 Subject: [PATCH 3/3] chore: round of test approval --- packages/core/tests/util/deepMerge.test.ts | 19 +-- packages/core/tests/util/mri-utils.test.ts | 44 +++--- packages/sisyphus/src/domain/Commit.ts | 4 +- .../sisyphus/tests/domain/BumpType.test.ts | 7 +- packages/sisyphus/tests/domain/Commit.test.ts | 147 +++++++----------- .../sisyphus/tests/domain/Package.test.ts | 60 +++---- packages/sisyphus/tests/domain/Stone.test.ts | 142 ++++++++--------- .../sisyphus/tests/domain/helpers.test.ts | 62 +++----- .../tests/services/GitRemoteParser.test.ts | 136 +++++++--------- .../tests/services/VersionCalculator.test.ts | 10 +- packages/sisyphus/tests/utils.test.ts | 44 ++---- 11 files changed, 258 insertions(+), 417 deletions(-) diff --git a/packages/core/tests/util/deepMerge.test.ts b/packages/core/tests/util/deepMerge.test.ts index a2411d5..f5449cd 100644 --- a/packages/core/tests/util/deepMerge.test.ts +++ b/packages/core/tests/util/deepMerge.test.ts @@ -45,28 +45,11 @@ describe("deepMerge", () => { expect(deepMerge(target, source)).toEqual({ a: { b: { c: 10, d: 2, f: 4 }, e: 3, g: 5 } }); }); - /** - * SECURITY: prototype pollution via constructor key. - * - * The current implementation uses spread (`{ ...target }`) and `Object.keys()`, - * which treats `constructor` as a regular own property on the result object - * rather than walking up the prototype chain. This means `Object.prototype` - * is NOT polluted by this particular vector. - * - * However, the implementation does NOT explicitly reject dangerous keys - * (`__proto__`, `constructor`, `prototype`). If the merge strategy changes - * (e.g., to mutate in place), pollution could be re-introduced. Consider - * adding an explicit key blocklist for defense in depth. - */ - test("SECURITY: prototype pollution via constructor key does not pollute Object.prototype", () => { + test("prototype pollution via constructor key does not pollute Object.prototype", () => { const malicious = JSON.parse('{"constructor":{"prototype":{"polluted":true}}}'); - const result = deepMerge({}, malicious); - // Object.prototype must NOT be polluted expect(({} as Record).polluted).toBeUndefined(); - - // The constructor key should exist as a plain property on the result expect(result).toHaveProperty("constructor"); }); diff --git a/packages/core/tests/util/mri-utils.test.ts b/packages/core/tests/util/mri-utils.test.ts index 90774b5..302a6c7 100644 --- a/packages/core/tests/util/mri-utils.test.ts +++ b/packages/core/tests/util/mri-utils.test.ts @@ -6,16 +6,14 @@ import { buildMriOptions, convertNumbers, mapPositionals, validatePositionals } describe("buildMriOptions", () => { test("boolean args go to boolean array", () => { const defs: Record = { debug: { type: "boolean" }, verbose: { type: "boolean" } }; - const opts = buildMriOptions(defs); - expect(opts.boolean).toEqual(["debug", "verbose"]); - expect(opts.string).toEqual([]); + + expect(buildMriOptions(defs)).toMatchObject({ boolean: ["debug", "verbose"], string: [] }); }); test("string and number args go to string array", () => { const defs: Record = { count: { type: "number" }, name: { type: "string" } }; - const opts = buildMriOptions(defs); - expect(opts.string).toEqual(["count", "name"]); - expect(opts.boolean).toEqual([]); + + expect(buildMriOptions(defs)).toMatchObject({ boolean: [], string: ["count", "name"] }); }); test("aliases registered correctly", () => { @@ -23,16 +21,14 @@ describe("buildMriOptions", () => { output: { alias: "o", type: "string" }, verbose: { alias: "v", type: "boolean" }, }; - const opts = buildMriOptions(defs); - expect(opts.alias.v).toBe("verbose"); - expect(opts.alias.o).toBe("output"); + + expect(buildMriOptions(defs).alias).toMatchObject({ o: "output", v: "verbose" }); }); test("camelCase auto-generates kebab-case alias", () => { const defs: Record = { dryRun: { type: "boolean" }, outputDir: { type: "string" } }; - const opts = buildMriOptions(defs); - expect(opts.alias["dry-run"]).toBe("dryRun"); - expect(opts.alias["output-dir"]).toBe("outputDir"); + + expect(buildMriOptions(defs).alias).toMatchObject({ "dry-run": "dryRun", "output-dir": "outputDir" }); }); test("defaults populated", () => { @@ -60,9 +56,8 @@ describe("convertNumbers", () => { test("skips boolean and string types", () => { const defs: Record = { name: { type: "string" }, verbose: { type: "boolean" } }; - const result = convertNumbers({ name: "hello", verbose: true }, defs); - expect(result.verbose).toBe(true); - expect(result.name).toBe("hello"); + + expect(convertNumbers({ name: "hello", verbose: true }, defs)).toMatchObject({ name: "hello", verbose: true }); }); test("skips undefined values", () => { @@ -81,30 +76,27 @@ describe("mapPositionals", () => { test("maps multiple positionals by index", () => { const defs: Record = { dest: { required: true }, source: { required: true } }; - const result = mapPositionals(["a.txt", "b.txt"], defs); - expect(result.dest).toBe("a.txt"); - expect(result.source).toBe("b.txt"); + + expect(mapPositionals(["a.txt", "b.txt"], defs)).toMatchObject({ dest: "a.txt", source: "b.txt" }); }); test("variadic positional captures rest as array", () => { const defs: Record = { first: { required: true }, rest: { variadic: true } }; - const result = mapPositionals(["a", "b", "c", "d"], defs); - expect(result.first).toBe("a"); - expect(result.rest).toEqual(["b", "c", "d"]); + + expect(mapPositionals(["a", "b", "c", "d"], defs)).toMatchObject({ first: "a", rest: ["b", "c", "d"] }); }); test("missing positional returns undefined", () => { const defs: Record = { extra: {}, name: { required: true } }; - const result = mapPositionals(["foo"], defs); - expect(result.extra).toBe("foo"); - expect(result.name).toBeUndefined(); + + expect(mapPositionals(["foo"], defs)).toMatchObject({ extra: "foo", name: undefined }); }); test("extra positionals silently dropped", () => { const defs: Record = { name: { required: true } }; const result = mapPositionals(["foo", "bar", "baz"], defs); - expect(result.name).toBe("foo"); - expect(Object.keys(result)).toEqual(["name"]); + + expect(result).toEqual({ name: "foo" }); }); }); diff --git a/packages/sisyphus/src/domain/Commit.ts b/packages/sisyphus/src/domain/Commit.ts index f596e5a..9b82dd6 100644 --- a/packages/sisyphus/src/domain/Commit.ts +++ b/packages/sisyphus/src/domain/Commit.ts @@ -130,7 +130,9 @@ export class Commit { const commits: Commit[] = []; for (let i = 0; i < segments.length; i += 2) { - const fields = segments[i]?.split(FIELD_SEPARATOR); + const segment = segments[i]; + if (!segment) continue; + const fields = segment.split(FIELD_SEPARATOR); if (fields.length < 3) continue; const [hash, subject, author] = fields; diff --git a/packages/sisyphus/tests/domain/BumpType.test.ts b/packages/sisyphus/tests/domain/BumpType.test.ts index 3d1d83f..5df4862 100644 --- a/packages/sisyphus/tests/domain/BumpType.test.ts +++ b/packages/sisyphus/tests/domain/BumpType.test.ts @@ -73,7 +73,6 @@ describe("BUMP_ORDER", () => { BumpType.Dependency, BumpType.Snapshot, ]); - expect(BUMP_ORDER).toHaveLength(5); }); }); @@ -88,11 +87,9 @@ describe("BUMP_PRIORITY", () => { }); describe("BUMP_EMOJI", () => { - test("all types have an emoji", () => { + test("all types have a non-empty emoji string", () => { for (const type of Object.values(BumpType)) { - expect(BUMP_EMOJI[type]).toBeDefined(); - expect(typeof BUMP_EMOJI[type]).toBe("string"); - expect(BUMP_EMOJI[type].length).toBeGreaterThan(0); + expect(BUMP_EMOJI[type]).toMatch(/.+/); } }); }); diff --git a/packages/sisyphus/tests/domain/Commit.test.ts b/packages/sisyphus/tests/domain/Commit.test.ts index 17a90a2..17e842f 100644 --- a/packages/sisyphus/tests/domain/Commit.test.ts +++ b/packages/sisyphus/tests/domain/Commit.test.ts @@ -1,54 +1,48 @@ import { describe, expect, test } from "bun:test"; +import { OTHER_COMMIT_TYPE } from "../../src/constants"; import type { CommitInfo } from "../../src/domain/Commit"; -import { Commit, OTHER_COMMIT_TYPE } from "../../src/domain/Commit"; +import { Commit } from "../../src/domain/Commit"; const HASH = "abc1234567890def"; +const AUTHOR = "test-author"; describe("Commit.parse", () => { test("parses standard feat commit", () => { - const commit = Commit.parse(HASH, "feat: add feature"); - - expect(commit.type).toBe("feat"); - expect(commit.message).toBe("add feature"); - expect(commit.breaking).toBe(false); - expect(commit.scope).toBeUndefined(); - expect(commit.subject).toBe("feat: add feature"); - expect(commit.hash).toBe(HASH); - expect(commit.files).toEqual([]); + const commit = Commit.parse(HASH, "feat: add feature", AUTHOR); + + expect(commit).toMatchObject({ + breaking: false, + files: [], + hash: HASH, + message: "add feature", + scope: undefined, + subject: "feat: add feature", + type: "feat", + }); }); test("parses standard fix commit", () => { - const commit = Commit.parse(HASH, "fix: resolve bug"); + const commit = Commit.parse(HASH, "fix: resolve bug", AUTHOR); - expect(commit.type).toBe("fix"); - expect(commit.message).toBe("resolve bug"); - expect(commit.breaking).toBe(false); + expect(commit).toMatchObject({ breaking: false, message: "resolve bug", type: "fix" }); }); test("parses commit with scope", () => { - const commit = Commit.parse(HASH, "feat(api): add endpoint"); + const commit = Commit.parse(HASH, "feat(api): add endpoint", AUTHOR); - expect(commit.type).toBe("feat"); - expect(commit.scope).toBe("api"); - expect(commit.message).toBe("add endpoint"); - expect(commit.breaking).toBe(false); + expect(commit).toMatchObject({ breaking: false, message: "add endpoint", scope: "api", type: "feat" }); }); test("parses breaking change with !", () => { - const commit = Commit.parse(HASH, "feat!: breaking change"); + const commit = Commit.parse(HASH, "feat!: breaking change", AUTHOR); - expect(commit.type).toBe("feat"); - expect(commit.breaking).toBe(true); - expect(commit.message).toBe("breaking change"); + expect(commit).toMatchObject({ breaking: true, message: "breaking change", type: "feat" }); }); test("parses breaking change with scope and !", () => { - const commit = Commit.parse(HASH, "feat(api)!: break it"); + const commit = Commit.parse(HASH, "feat(api)!: break it", AUTHOR); - expect(commit.type).toBe("feat"); - expect(commit.scope).toBe("api"); - expect(commit.breaking).toBe(true); - expect(commit.message).toBe("break it"); + expect(commit).toMatchObject({ breaking: true, message: "break it", scope: "api", type: "feat" }); }); describe("all known commit types", () => { @@ -56,114 +50,88 @@ describe("Commit.parse", () => { for (const type of knownTypes) { test(`recognizes "${type}" as conventional type`, () => { - const commit = Commit.parse(HASH, `${type}: some message`); + const commit = Commit.parse(HASH, `${type}: some message`, AUTHOR); - expect(commit.type).toBe(type); - expect(commit.message).toBe("some message"); - expect(commit.isConventional).toBe(true); + expect(commit).toMatchObject({ isConventional: true, message: "some message", type }); }); } }); test("unknown type falls back to OTHER_COMMIT_TYPE", () => { - const commit = Commit.parse(HASH, "unknown: something"); + const commit = Commit.parse(HASH, "unknown: something", AUTHOR); - expect(commit.type).toBe(OTHER_COMMIT_TYPE); - expect(commit.type).toBe("other"); - expect(commit.message).toBe("unknown: something"); + expect(commit).toMatchObject({ message: "unknown: something", type: OTHER_COMMIT_TYPE }); }); test("non-conventional commit falls back to OTHER_COMMIT_TYPE", () => { - const commit = Commit.parse(HASH, "just a message"); + const commit = Commit.parse(HASH, "just a message", AUTHOR); - expect(commit.type).toBe(OTHER_COMMIT_TYPE); - expect(commit.message).toBe("just a message"); - expect(commit.subject).toBe("just a message"); + expect(commit).toMatchObject({ message: "just a message", subject: "just a message", type: OTHER_COMMIT_TYPE }); }); test("commit with colon but no known type falls back to OTHER_COMMIT_TYPE", () => { - const commit = Commit.parse(HASH, "WIP: work in progress"); + const commit = Commit.parse(HASH, "WIP: work in progress", AUTHOR); - expect(commit.type).toBe(OTHER_COMMIT_TYPE); - expect(commit.message).toBe("WIP: work in progress"); + expect(commit).toMatchObject({ message: "WIP: work in progress", type: OTHER_COMMIT_TYPE }); }); test("empty subject edge case", () => { - const commit = Commit.parse(HASH, ""); + const commit = Commit.parse(HASH, "", AUTHOR); - expect(commit.type).toBe(OTHER_COMMIT_TYPE); - expect(commit.message).toBe(""); - expect(commit.subject).toBe(""); - expect(commit.breaking).toBe(false); + expect(commit).toMatchObject({ breaking: false, message: "", subject: "", type: OTHER_COMMIT_TYPE }); }); }); describe("Commit getters", () => { test("shortHash returns first 7 characters", () => { - const commit = Commit.parse(HASH, "feat: something"); - - expect(commit.shortHash).toBe("abc1234"); - expect(commit.shortHash).toHaveLength(7); + expect(Commit.parse(HASH, "feat: something", AUTHOR).shortHash).toBe("abc1234"); }); test("shortHash works with exactly 7 char hash", () => { - const commit = Commit.parse("abc1234", "feat: something"); - - expect(commit.shortHash).toBe("abc1234"); + expect(Commit.parse("abc1234", "feat: something", AUTHOR).shortHash).toBe("abc1234"); }); test("isConventional returns true for known types", () => { - const commit = Commit.parse(HASH, "feat: something"); - - expect(commit.isConventional).toBe(true); + expect(Commit.parse(HASH, "feat: something", AUTHOR).isConventional).toBe(true); }); test("isConventional returns false for other type", () => { - const commit = Commit.parse(HASH, "random message"); - - expect(commit.isConventional).toBe(false); + expect(Commit.parse(HASH, "random message", AUTHOR).isConventional).toBe(false); }); }); describe("Commit.withFiles", () => { - test("returns new Commit with files set", () => { - const original = Commit.parse(HASH, "feat: something"); + test("returns new Commit with files set, original unchanged", () => { + const original = Commit.parse(HASH, "feat: something", AUTHOR); const files = ["src/index.ts", "src/utils.ts"]; const withFiles = original.withFiles(files); - expect(withFiles.files).toEqual(files); - expect(withFiles.type).toBe("feat"); - expect(withFiles.message).toBe("something"); - expect(withFiles.hash).toBe(HASH); - // Original is unchanged + expect(withFiles).toMatchObject({ files, hash: HASH, message: "something", type: "feat" }); expect(original.files).toEqual([]); }); }); describe("Commit.withBody", () => { - test("returns new Commit with body set", () => { - const original = Commit.parse(HASH, "feat: something"); + test("returns new Commit with body set, original unchanged", () => { + const original = Commit.parse(HASH, "feat: something", AUTHOR); const withBody = original.withBody("detailed description"); - expect(withBody.body).toBe("detailed description"); - expect(withBody.type).toBe("feat"); - expect(withBody.message).toBe("something"); - // Original is unchanged + expect(withBody).toMatchObject({ body: "detailed description", message: "something", type: "feat" }); expect(original.body).toBeUndefined(); }); - test("returns new Commit with undefined body", () => { - const original = Commit.parse(HASH, "feat: something").withBody("some body"); - const withoutBody = original.withBody(undefined); + test("withBody(undefined) clears the body", () => { + const original = Commit.parse(HASH, "feat: something", AUTHOR).withBody("some body"); - expect(withoutBody.body).toBeUndefined(); + expect(original.withBody(undefined).body).toBeUndefined(); }); }); describe("Commit.toInfo", () => { test("returns CommitInfo with correct fields", () => { - const commit = Commit.parse(HASH, "feat(api): add endpoint").withBody("some body").withFiles(["src/api.ts"]); - + const commit = Commit.parse(HASH, "feat(api): add endpoint", AUTHOR) + .withBody("some body") + .withFiles(["src/api.ts"]); const info: CommitInfo = commit.toInfo(["@org/api"]); expect(info).toEqual({ @@ -178,15 +146,16 @@ describe("Commit.toInfo", () => { }); test("returns CommitInfo for non-conventional commit", () => { - const commit = Commit.parse(HASH, "just a message"); - - const info = commit.toInfo([]); + const info = Commit.parse(HASH, "just a message", AUTHOR).toInfo([]); - expect(info.type).toBe("other"); - expect(info.message).toBe("just a message"); - expect(info.hash).toBe("abc1234"); - expect(info.packages).toEqual([]); - expect(info.scope).toBeUndefined(); - expect(info.body).toBeUndefined(); + expect(info).toEqual({ + body: undefined, + hash: "abc1234", + message: "just a message", + packages: [], + scope: undefined, + subject: "just a message", + type: "other", + }); }); }); diff --git a/packages/sisyphus/tests/domain/Package.test.ts b/packages/sisyphus/tests/domain/Package.test.ts index 29838fd..4e8f289 100644 --- a/packages/sisyphus/tests/domain/Package.test.ts +++ b/packages/sisyphus/tests/domain/Package.test.ts @@ -7,10 +7,6 @@ import { Stone } from "../../src/domain/Stone"; const makePackage = (overrides?: Partial, file = "packages/core/package.json"): Package => Package.fromJson({ name: "@app/core", version: "1.2.3", ...overrides }, file); -/** - * Applies bumps from a stone to matching packages — the pattern used - * throughout sisyphus to connect stones with packages. - */ function applyStone(packages: Package[], stone: Stone): Package[] { const result: Package[] = []; @@ -33,14 +29,15 @@ function applyStone(packages: Package[], stone: Stone): Package[] { describe("Package.fromJson()", () => { test("creates package with name, version, and file", () => { - const json: PackageJson = { name: "@app/core", version: "1.2.3" }; - const pkg = Package.fromJson(json, "packages/core/package.json"); - - expect(pkg.name).toBe("@app/core"); - expect(pkg.version).toBe("1.2.3"); - expect(pkg.file).toBe("packages/core/package.json"); - expect(pkg.bump).toBeUndefined(); - expect(pkg.dependencyOf).toEqual([]); + const pkg = Package.fromJson({ name: "@app/core", version: "1.2.3" }, "packages/core/package.json"); + + expect(pkg).toMatchObject({ + bump: undefined, + dependencyOf: [], + file: "packages/core/package.json", + name: "@app/core", + version: "1.2.3", + }); }); test("uses '0.0.0' for missing version", () => { @@ -67,15 +64,16 @@ describe("Package.fromJson()", () => { }); describe("package.withBump()", () => { - test("returns new Package with bump set", () => { + test("returns new Package with bump set, original unchanged", () => { const original = makePackage(); const bumped = original.withBump(BumpType.Minor); - expect(bumped.bump).toBe(BumpType.Minor); - expect(bumped.name).toBe(original.name); - expect(bumped.version).toBe(original.version); - expect(bumped.file).toBe(original.file); - // original is unchanged (immutable) + expect(bumped).toMatchObject({ + bump: BumpType.Minor, + file: original.file, + name: original.name, + version: original.version, + }); expect(original.bump).toBeUndefined(); }); @@ -107,12 +105,9 @@ describe("package.withDependencyOf()", () => { }); test("preserves other fields", () => { - const pkg = makePackage().withBump(BumpType.Patch); - const updated = pkg.withDependencyOf(["@app/cli"]); + const updated = makePackage().withBump(BumpType.Patch).withDependencyOf(["@app/cli"]); - expect(updated.bump).toBe(BumpType.Patch); - expect(updated.name).toBe("@app/core"); - expect(updated.version).toBe("1.2.3"); + expect(updated).toMatchObject({ bump: BumpType.Patch, name: "@app/core", version: "1.2.3" }); }); }); @@ -186,15 +181,11 @@ describe("applyStone pattern — applying bumps from stone to matching packages" const result = applyStone(packages, stone); - expect(result[0]?.bump).toBe(BumpType.Major); - expect(result[0]?.newVersion).toBe("2.0.0"); - expect(result[1]?.bump).toBe(BumpType.Minor); - expect(result[1]?.newVersion).toBe("2.1.0"); + expect(result[0]).toMatchObject({ bump: BumpType.Major, newVersion: "2.0.0" }); + expect(result[1]).toMatchObject({ bump: BumpType.Minor, newVersion: "2.1.0" }); }); test("respects BUMP_ORDER priority — major applied before minor for same package", () => { - // If a package appears in both major and minor in a stone, - // BUMP_ORDER iteration means major is checked first. const stone = Stone.create({ major: ["@app/core"], message: "release", @@ -204,9 +195,7 @@ describe("applyStone pattern — applying bumps from stone to matching packages" const packages = [makePackage({ name: "@app/core", version: "1.0.0" })]; const result = applyStone(packages, stone); - // Major comes first in BUMP_ORDER, so major bump wins - expect(result[0]?.bump).toBe(BumpType.Major); - expect(result[0]?.newVersion).toBe("2.0.0"); + expect(result[0]).toMatchObject({ bump: BumpType.Major, newVersion: "2.0.0" }); }); test("ignores packages not in the stone", () => { @@ -219,10 +208,7 @@ describe("applyStone pattern — applying bumps from stone to matching packages" const result = applyStone(packages, stone); - expect(result[0]?.bump).toBe(BumpType.Patch); - expect(result[0]?.newVersion).toBe("1.0.1"); - // Unrelated package is unchanged - expect(result[1]?.bump).toBeUndefined(); - expect(result[1]?.newVersion).toBeUndefined(); + expect(result[0]).toMatchObject({ bump: BumpType.Patch, newVersion: "1.0.1" }); + expect(result[1]).toMatchObject({ bump: undefined, newVersion: undefined }); }); }); diff --git a/packages/sisyphus/tests/domain/Stone.test.ts b/packages/sisyphus/tests/domain/Stone.test.ts index bccd5b1..4e389a5 100644 --- a/packages/sisyphus/tests/domain/Stone.test.ts +++ b/packages/sisyphus/tests/domain/Stone.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { BumpType } from "../../src/domain/BumpType"; import type { CommitInfo } from "../../src/domain/Commit"; -import type { StoneData, StoneJson } from "../../src/domain/Stone"; +import type { StoneData } from "../../src/domain/Stone"; import { Stone } from "../../src/domain/Stone"; const makeCommit = (hash: string, message: string): CommitInfo => ({ @@ -36,50 +36,53 @@ describe("Stone.create()", () => { test("stores packages correctly by bump type", () => { const stone = Stone.create(baseData); - expect(stone.major).toEqual(["@app/core"]); - expect(stone.minor).toEqual(["@app/utils"]); - expect(stone.patch).toEqual(["@app/cli"]); - expect(stone.dependency).toEqual(["@app/deps"]); - expect(stone.snapshot).toEqual(["@app/snapshot"]); - expect(stone.message).toBe("release v1.0.0"); - expect(stone.tag).toBe("v1.0.0"); - expect(stone.description).toBe("Initial release"); + expect(stone).toMatchObject({ + dependency: ["@app/deps"], + description: "Initial release", + major: ["@app/core"], + message: "release v1.0.0", + minor: ["@app/utils"], + patch: ["@app/cli"], + snapshot: ["@app/snapshot"], + tag: "v1.0.0", + }); }); }); describe("Stone.fromJson() / toJson()", () => { test("roundtrip preserves all data", () => { const original = Stone.create(baseData, 5); - const json = original.toJson(); - const restored = Stone.fromJson(json); - - expect(restored.id).toBe(original.id); - expect(restored.message).toBe(original.message); - expect(restored.tag).toBe(original.tag); - expect(restored.description).toBe(original.description); - expect(restored.major).toEqual(original.major); - expect(restored.minor).toEqual(original.minor); - expect(restored.patch).toEqual(original.patch); - expect(restored.dependency).toEqual(original.dependency); - expect(restored.snapshot).toEqual(original.snapshot); - expect(restored.commits).toEqual(original.commits); + const restored = Stone.fromJson(original.toJson()); + + expect(restored).toMatchObject({ + commits: original.commits, + dependency: original.dependency, + description: original.description, + id: original.id, + major: original.major, + message: original.message, + minor: original.minor, + patch: original.patch, + snapshot: original.snapshot, + tag: original.tag, + }); }); test("handles missing optional fields (no snapshot, no commits)", () => { - const minimalJson: StoneJson = { id: "0001-deadbeef", message: "minimal stone" }; - - const stone = Stone.fromJson(minimalJson); - - expect(stone.id).toBe("0001-deadbeef"); - expect(stone.message).toBe("minimal stone"); - expect(stone.tag).toBeUndefined(); - expect(stone.description).toBeUndefined(); - expect(stone.commits).toBeUndefined(); - expect(stone.major).toEqual([]); - expect(stone.minor).toEqual([]); - expect(stone.patch).toEqual([]); - expect(stone.dependency).toEqual([]); - expect(stone.snapshot).toEqual([]); + const stone = Stone.fromJson({ id: "0001-deadbeef", message: "minimal stone" }); + + expect(stone).toMatchObject({ + commits: undefined, + dependency: [], + description: undefined, + id: "0001-deadbeef", + major: [], + message: "minimal stone", + minor: [], + patch: [], + snapshot: [], + tag: undefined, + }); }); }); @@ -91,11 +94,13 @@ describe("Stone.merge()", () => { const { stone, conflicts } = Stone.merge([stoneA, stoneB], "merged"); expect(conflicts).toEqual([]); - expect(stone.major).toEqual(["@app/core"]); - expect(stone.minor).toEqual(["@app/utils"]); - expect(stone.patch).toEqual(["@app/cli"]); - expect(stone.dependency).toEqual(["@app/deps"]); - expect(stone.message).toBe("merged"); + expect(stone).toMatchObject({ + dependency: ["@app/deps"], + major: ["@app/core"], + message: "merged", + minor: ["@app/utils"], + patch: ["@app/cli"], + }); }); test("detects conflicts when same package at different bump levels", () => { @@ -119,14 +124,11 @@ describe("Stone.merge()", () => { }); test("BUG: drops Snapshot packages entirely during merge", () => { - // collectBumps only iterates Major, Minor, Patch, Dependency — not Snapshot. - // This means any packages in the snapshot bump type are silently lost. const stoneA = Stone.create({ message: "stone A", snapshot: ["@app/snapshot-pkg"] }); const stoneB = Stone.create({ message: "stone B", patch: ["@app/cli"] }); const { stone } = Stone.merge([stoneA, stoneB], "merged"); - // Snapshot packages are NOT carried over — this is a bug expect(stone.snapshot).toEqual([]); expect(stone.allPackages).not.toContain("@app/snapshot-pkg"); }); @@ -149,23 +151,17 @@ describe("Stone.merge()", () => { const { stone } = Stone.merge([stoneA, stoneB], "merged"); - // NOTE: merge uses flatMap without dedup — commit2 appears twice. - // This documents actual behavior: commits are NOT deduplicated. const hashes = stone.commits?.map((c) => c.hash) ?? []; expect(hashes).toEqual(["aaa1111", "bbb2222", "bbb2222"]); }); test("silently picks first tag when tags conflict", () => { - // When multiple stones have different tags, merge creates a Set - // then picks tags[0] — the first unique tag encountered. - // There is no conflict reported for tag mismatches. const stoneA = Stone.create({ message: "A", tag: "v1.0.0" }); const stoneB = Stone.create({ message: "B", tag: "v2.0.0" }); const { stone, conflicts } = Stone.merge([stoneA, stoneB], "merged"); expect(stone.tag).toBe("v1.0.0"); - // No conflict is reported for tag mismatch expect(conflicts).toEqual([]); }); }); @@ -184,14 +180,9 @@ describe("stone.isEmpty", () => { describe("stone.allPackages", () => { test("returns all packages across bump types", () => { - const stone = Stone.create(baseData); - const all = stone.allPackages; + const all = Stone.create(baseData).allPackages; - expect(all).toContain("@app/core"); - expect(all).toContain("@app/utils"); - expect(all).toContain("@app/cli"); - expect(all).toContain("@app/deps"); - expect(all).toContain("@app/snapshot"); + expect(all).toEqual(expect.arrayContaining(["@app/core", "@app/utils", "@app/cli", "@app/deps", "@app/snapshot"])); expect(all).toHaveLength(5); }); }); @@ -216,56 +207,49 @@ describe("stone.affectsPackage() via getPackages", () => { }); describe("immutable update methods", () => { - test("withMessage() returns new stone with updated message", () => { + test("withMessage() returns new stone with updated message, original unchanged", () => { const original = Stone.create(baseData); const updated = original.withMessage("new message"); - expect(updated.message).toBe("new message"); + expect(updated).toMatchObject({ + id: original.id, + major: original.major, + message: "new message", + tag: original.tag, + }); expect(original.message).toBe("release v1.0.0"); - expect(updated.id).toBe(original.id); - expect(updated.tag).toBe(original.tag); - expect(updated.major).toEqual(original.major); }); - test("withTag() returns new stone with updated tag", () => { + test("withTag() returns new stone with updated tag, original unchanged", () => { const original = Stone.create(baseData); const updated = original.withTag("v2.0.0"); - expect(updated.tag).toBe("v2.0.0"); + expect(updated).toMatchObject({ id: original.id, tag: "v2.0.0" }); expect(original.tag).toBe("v1.0.0"); - expect(updated.id).toBe(original.id); }); test("withTag(undefined) clears the tag", () => { - const original = Stone.create(baseData); - const updated = original.withTag(undefined); - - expect(updated.tag).toBeUndefined(); + expect(Stone.create(baseData).withTag(undefined).tag).toBeUndefined(); }); - test("withDescription() returns new stone with updated description", () => { + test("withDescription() returns new stone with updated description, original unchanged", () => { const original = Stone.create(baseData); const updated = original.withDescription("Updated description"); - expect(updated.description).toBe("Updated description"); + expect(updated).toMatchObject({ description: "Updated description", id: original.id }); expect(original.description).toBe("Initial release"); - expect(updated.id).toBe(original.id); }); test("withDescription(undefined) clears the description", () => { - const original = Stone.create(baseData); - const updated = original.withDescription(undefined); - - expect(updated.description).toBeUndefined(); + expect(Stone.create(baseData).withDescription(undefined).description).toBeUndefined(); }); }); describe("toJson()", () => { test("omits empty arrays via nonEmpty helper", () => { - const stone = Stone.create({ major: ["@app/core"], message: "only major" }); - const json = stone.toJson(); + const json = Stone.create({ major: ["@app/core"], message: "only major" }).toJson(); - expect(json.major).toEqual(["@app/core"]); + expect(json).toMatchObject({ major: ["@app/core"] }); expect(json.minor).toBeUndefined(); expect(json.patch).toBeUndefined(); expect(json.dependency).toBeUndefined(); diff --git a/packages/sisyphus/tests/domain/helpers.test.ts b/packages/sisyphus/tests/domain/helpers.test.ts index b5c347a..fd4bd4e 100644 --- a/packages/sisyphus/tests/domain/helpers.test.ts +++ b/packages/sisyphus/tests/domain/helpers.test.ts @@ -33,74 +33,54 @@ describe("ChangesetParser.toStoneData", () => { }); test("converts major packages correctly", () => { - const cs = makeChangeset({ "@app/core": "major", "@app/ui": "major" }, "Breaking change"); - const result = parser.toStoneData(cs); + const result = parser.toStoneData(makeChangeset({ "@app/core": "major", "@app/ui": "major" }, "Breaking change")); - expect(result.major).toEqual(["@app/core", "@app/ui"]); - expect(result.minor).toBeUndefined(); - expect(result.patch).toBeUndefined(); + expect(result).toMatchObject({ major: ["@app/core", "@app/ui"], minor: undefined, patch: undefined }); }); test("converts minor packages correctly", () => { - const cs = makeChangeset({ "@app/utils": "minor" }, "New feature"); - const result = parser.toStoneData(cs); + const result = parser.toStoneData(makeChangeset({ "@app/utils": "minor" }, "New feature")); - expect(result.minor).toEqual(["@app/utils"]); - expect(result.major).toBeUndefined(); - expect(result.patch).toBeUndefined(); + expect(result).toMatchObject({ major: undefined, minor: ["@app/utils"], patch: undefined }); }); test("converts patch packages correctly", () => { - const cs = makeChangeset({ "@app/core": "patch", "@app/lib": "patch" }, "Bug fix"); - const result = parser.toStoneData(cs); + const result = parser.toStoneData(makeChangeset({ "@app/core": "patch", "@app/lib": "patch" }, "Bug fix")); - expect(result.patch).toEqual(["@app/core", "@app/lib"]); - expect(result.major).toBeUndefined(); - expect(result.minor).toBeUndefined(); + expect(result).toMatchObject({ major: undefined, minor: undefined, patch: ["@app/core", "@app/lib"] }); }); test("unknown bump types are silently dropped", () => { - // BUG: unknown bump types like "prepatch" are silently ignored - // because the switch statement has no default case - const cs = makeChangeset({ "@app/core": "prepatch" }, "Pre-release"); - const result = parser.toStoneData(cs); - - expect(result.major).toBeUndefined(); - expect(result.minor).toBeUndefined(); - expect(result.patch).toBeUndefined(); - expect(result.message).toBe("Pre-release"); + const result = parser.toStoneData(makeChangeset({ "@app/core": "prepatch" }, "Pre-release")); + + expect(result).toMatchObject({ major: undefined, message: "Pre-release", minor: undefined, patch: undefined }); }); test("first line of summary used as message", () => { - const cs = makeChangeset({ "@app/core": "minor" }, "First line message\nSecond line detail\nThird line"); - const result = parser.toStoneData(cs); + const result = parser.toStoneData( + makeChangeset({ "@app/core": "minor" }, "First line message\nSecond line detail\nThird line"), + ); - expect(result.message).toBe("First line message"); - expect(result.description).toBe("Second line detail\nThird line"); + expect(result).toMatchObject({ description: "Second line detail\nThird line", message: "First line message" }); }); test("fallback message for empty summary", () => { - const cs = makeChangeset({ "@app/core": "patch" }, ""); - const result = parser.toStoneData(cs); + const result = parser.toStoneData(makeChangeset({ "@app/core": "patch" }, "")); - expect(result.message).toBe("Migrated from changeset"); - expect(result.description).toBeUndefined(); + expect(result).toMatchObject({ description: undefined, message: "Migrated from changeset" }); }); test("mixed bump types are categorized correctly", () => { - const cs = makeChangeset({ "@app/core": "major", "@app/lib": "patch", "@app/utils": "minor" }, "Mixed changes"); - const result = parser.toStoneData(cs); + const result = parser.toStoneData( + makeChangeset({ "@app/core": "major", "@app/lib": "patch", "@app/utils": "minor" }, "Mixed changes"), + ); - expect(result.major).toEqual(["@app/core"]); - expect(result.minor).toEqual(["@app/utils"]); - expect(result.patch).toEqual(["@app/lib"]); + expect(result).toMatchObject({ major: ["@app/core"], minor: ["@app/utils"], patch: ["@app/lib"] }); }); test("description is undefined when summary is single line", () => { - const cs = makeChangeset({ "@app/core": "patch" }, "Only one line"); - const result = parser.toStoneData(cs); + const result = parser.toStoneData(makeChangeset({ "@app/core": "patch" }, "Only one line")); - expect(result.message).toBe("Only one line"); - expect(result.description).toBeUndefined(); + expect(result).toMatchObject({ description: undefined, message: "Only one line" }); }); }); diff --git a/packages/sisyphus/tests/services/GitRemoteParser.test.ts b/packages/sisyphus/tests/services/GitRemoteParser.test.ts index d1bdd4a..b13f444 100644 --- a/packages/sisyphus/tests/services/GitRemoteParser.test.ts +++ b/packages/sisyphus/tests/services/GitRemoteParser.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from "bun:test"; -// Copied from src/services/GitRemoteParser.ts to test regex patterns directly, -// since parseUrl and the patterns are private/module-scoped. const GITHUB_PATTERN = /github\.com[:/]([^/]+)\/([^/.]+)/; const GITLAB_PATTERN = /gitlab\.com[:/]([^/]+)\/([^/.]+)/; const BITBUCKET_PATTERN = /bitbucket\.org[:/]([^/]+)\/([^/.]+)/; @@ -16,7 +14,6 @@ const PROVIDER_DOMAIN: Record = { const COMMIT_PATH: Record = { bitbucket: "commits", github: "commit", gitlab: "-/commit" }; -/** Mirrors the parseUrl logic from GitRemoteParser */ function parseUrl( url: string, ): { provider: Provider; owner: string; repo: string; commitUrl: (hash: string) => string } | null { @@ -45,131 +42,110 @@ function parseUrl( describe("GitRemoteParser URL parsing", () => { describe("GitHub", () => { test("parses HTTPS URL with .git suffix", () => { - const result = parseUrl("https://github.com/owner/repo.git"); - expect(result).not.toBeNull(); - expect(result?.provider).toBe("github"); - expect(result?.owner).toBe("owner"); - expect(result?.repo).toBe("repo"); + expect(parseUrl("https://github.com/owner/repo.git")).toMatchObject({ + owner: "owner", + provider: "github", + repo: "repo", + }); }); test("parses SSH URL", () => { - const result = parseUrl("git@github.com:owner/repo.git"); - expect(result).not.toBeNull(); - expect(result?.provider).toBe("github"); - expect(result?.owner).toBe("owner"); - expect(result?.repo).toBe("repo"); + expect(parseUrl("git@github.com:owner/repo.git")).toMatchObject({ + owner: "owner", + provider: "github", + repo: "repo", + }); }); test("parses HTTPS URL without .git suffix", () => { - const result = parseUrl("https://github.com/owner/repo"); - expect(result).not.toBeNull(); - expect(result?.provider).toBe("github"); - expect(result?.owner).toBe("owner"); - expect(result?.repo).toBe("repo"); + expect(parseUrl("https://github.com/owner/repo")).toMatchObject({ + owner: "owner", + provider: "github", + repo: "repo", + }); }); test("generates correct commit URL", () => { - const result = parseUrl("https://github.com/owner/repo.git"); - expect(result?.commitUrl("abc123")).toBe("https://github.com/owner/repo/commit/abc123"); + expect(parseUrl("https://github.com/owner/repo.git")?.commitUrl("abc123")).toBe( + "https://github.com/owner/repo/commit/abc123", + ); }); }); describe("GitLab", () => { test("parses HTTPS URL with .git suffix", () => { - const result = parseUrl("https://gitlab.com/owner/repo.git"); - expect(result).not.toBeNull(); - expect(result?.provider).toBe("gitlab"); - expect(result?.owner).toBe("owner"); - expect(result?.repo).toBe("repo"); + expect(parseUrl("https://gitlab.com/owner/repo.git")).toMatchObject({ + owner: "owner", + provider: "gitlab", + repo: "repo", + }); }); test("parses SSH URL", () => { - const result = parseUrl("git@gitlab.com:owner/repo.git"); - expect(result).not.toBeNull(); - expect(result?.provider).toBe("gitlab"); - expect(result?.owner).toBe("owner"); - expect(result?.repo).toBe("repo"); + expect(parseUrl("git@gitlab.com:owner/repo.git")).toMatchObject({ + owner: "owner", + provider: "gitlab", + repo: "repo", + }); }); test("generates correct commit URL with -/commit path", () => { - const result = parseUrl("https://gitlab.com/owner/repo.git"); - expect(result?.commitUrl("def456")).toBe("https://gitlab.com/owner/repo/-/commit/def456"); + expect(parseUrl("https://gitlab.com/owner/repo.git")?.commitUrl("def456")).toBe( + "https://gitlab.com/owner/repo/-/commit/def456", + ); }); }); describe("Bitbucket", () => { test("parses HTTPS URL with .git suffix", () => { - const result = parseUrl("https://bitbucket.org/owner/repo.git"); - expect(result).not.toBeNull(); - expect(result?.provider).toBe("bitbucket"); - expect(result?.owner).toBe("owner"); - expect(result?.repo).toBe("repo"); + expect(parseUrl("https://bitbucket.org/owner/repo.git")).toMatchObject({ + owner: "owner", + provider: "bitbucket", + repo: "repo", + }); }); test("parses SSH URL", () => { - const result = parseUrl("git@bitbucket.org:owner/repo.git"); - expect(result).not.toBeNull(); - expect(result?.provider).toBe("bitbucket"); - expect(result?.owner).toBe("owner"); - expect(result?.repo).toBe("repo"); + expect(parseUrl("git@bitbucket.org:owner/repo.git")).toMatchObject({ + owner: "owner", + provider: "bitbucket", + repo: "repo", + }); }); test("generates correct commit URL with commits path", () => { - const result = parseUrl("https://bitbucket.org/owner/repo.git"); - expect(result?.commitUrl("789abc")).toBe("https://bitbucket.org/owner/repo/commits/789abc"); + expect(parseUrl("https://bitbucket.org/owner/repo.git")?.commitUrl("789abc")).toBe( + "https://bitbucket.org/owner/repo/commits/789abc", + ); }); }); describe("URL with credentials", () => { test("parses HTTPS URL containing user:token credentials", () => { - const result = parseUrl("https://user:token@github.com/owner/repo.git"); - expect(result).not.toBeNull(); - expect(result?.provider).toBe("github"); - expect(result?.owner).toBe("owner"); - expect(result?.repo).toBe("repo"); + expect(parseUrl("https://user:token@github.com/owner/repo.git")).toMatchObject({ + owner: "owner", + provider: "github", + repo: "repo", + }); }); }); describe("no match cases", () => { - test("returns null for unknown host", () => { - const result = parseUrl("https://sourcehut.org/owner/repo.git"); - expect(result).toBeNull(); - }); - - test("returns null for malformed URL", () => { - const result = parseUrl("not-a-url"); - expect(result).toBeNull(); - }); - - test("returns null for empty string", () => { - const result = parseUrl(""); - expect(result).toBeNull(); + test.each(["https://sourcehut.org/owner/repo.git", "not-a-url", ""])("returns null for %j", (url) => { + expect(parseUrl(url)).toBeNull(); }); }); describe("known bugs", () => { test("BUG: dots in repo names - captures only text before first dot", () => { - // The regex [^/.] excludes dots, so "my.project.git" matches only "my" - // instead of "my.project". This is a known limitation. - const result = parseUrl("https://github.com/owner/my.project.git"); - expect(result).not.toBeNull(); - // Current (buggy) behavior: repo is "my" instead of "my.project" - expect(result?.repo).toBe("my"); - // If fixed, this should be: - // expect(result!.repo).toBe("my.project"); + expect(parseUrl("https://github.com/owner/my.project.git")).toMatchObject({ owner: "owner", repo: "my" }); }); test("BUG: GitLab nested subgroups - captures group as owner, subgroup as repo", () => { - // GitLab supports nested subgroups like gitlab.com/group/subgroup/repo - // The regex only captures the first two path segments, so it gets - // owner="group" and repo="subgroup" instead of the actual repo. - const result = parseUrl("https://gitlab.com/group/subgroup/repo.git"); - expect(result).not.toBeNull(); - // Current (buggy) behavior: captures subgroup instead of repo - expect(result?.owner).toBe("group"); - expect(result?.repo).toBe("subgroup"); - // If fixed, this should handle nested paths correctly: - // expect(result!.repo).toBe("repo"); + expect(parseUrl("https://gitlab.com/group/subgroup/repo.git")).toMatchObject({ + owner: "group", + repo: "subgroup", + }); }); }); }); diff --git a/packages/sisyphus/tests/services/VersionCalculator.test.ts b/packages/sisyphus/tests/services/VersionCalculator.test.ts index 2232206..99e5507 100644 --- a/packages/sisyphus/tests/services/VersionCalculator.test.ts +++ b/packages/sisyphus/tests/services/VersionCalculator.test.ts @@ -47,17 +47,11 @@ describe("VersionCalculator", () => { }); test("malformed version input produces partial NaN in output (known bug)", () => { - const result = VersionCalculator.bump("invalid", BumpType.Patch); - // "invalid".split(".") => ["invalid"], minor/patch default to "0" - // Only major parses as NaN; minor and patch get default values - expect(result).toBe("NaN.0.1"); + expect(VersionCalculator.bump("invalid", BumpType.Patch)).toBe("NaN.0.1"); }); test("empty string version produces partial NaN in output", () => { - const result = VersionCalculator.bump("", BumpType.Patch); - // "".split("-") => [""], then "".split(".") => [""] - // parseInt("") => NaN for major, minor/patch default to "0" - expect(result).toBe("NaN.0.1"); + expect(VersionCalculator.bump("", BumpType.Patch)).toBe("NaN.0.1"); }); test("default case returns unchanged version for unknown bump type", () => { diff --git a/packages/sisyphus/tests/utils.test.ts b/packages/sisyphus/tests/utils.test.ts index db94e67..9e35e3d 100644 --- a/packages/sisyphus/tests/utils.test.ts +++ b/packages/sisyphus/tests/utils.test.ts @@ -45,61 +45,40 @@ describe("findAffectedPackages", () => { const pathMap = buildPackagePathMap(packages); test("file in package dir matches that package", () => { - const result = findAffectedPackages(["packages/ui/index.ts"], pathMap); - - expect(result.has("@scope/ui")).toBe(true); + expect(findAffectedPackages(["packages/ui/index.ts"], pathMap)).toContain("@scope/ui"); }); test("file in nested subdir of package matches", () => { - const result = findAffectedPackages(["packages/core/src/deep/nested/file.ts"], pathMap); - - expect(result.has("@scope/core")).toBe(true); + expect(findAffectedPackages(["packages/core/src/deep/nested/file.ts"], pathMap)).toContain("@scope/core"); }); test("file at root matches root package when includeRoot=true", () => { - const result = findAffectedPackages(["README.md"], pathMap, true); - - expect(result.has("root")).toBe(true); + expect(findAffectedPackages(["README.md"], pathMap, true)).toContain("root"); }); test("file at root does NOT match when includeRoot=false", () => { - const result = findAffectedPackages(["README.md"], pathMap, false); - - expect(result.has("root")).toBe(false); + expect(findAffectedPackages(["README.md"], pathMap, false)).not.toContain("root"); }); - /** - * BUG: Root package is included for EVERY file regardless of path. - * - * When dir === ".", the condition `isRoot ? includeRoot : matchesPath` - * evaluates to `true` whenever includeRoot is true, regardless of whether - * the file actually belongs to the root package. This means every file - * (even ones clearly inside a sub-package) will also mark the root as - * affected when includeRoot=true. - */ test("BUG: root package is included for every file when includeRoot=true", () => { const result = findAffectedPackages(["packages/ui/index.ts"], pathMap, true); - // The file is inside packages/ui, so only @scope/ui should match. - // However, due to the bug, root is also included. - expect(result.has("@scope/ui")).toBe(true); - expect(result.has("root")).toBe(true); // bug: root should NOT be here + expect(result).toContain("@scope/ui"); + expect(result).toContain("root"); }); test("file matching no package is ignored", () => { const pathMapNoRoot = new Map([["packages/ui", "@scope/ui"]]); - const result = findAffectedPackages(["some/other/path/file.ts"], pathMapNoRoot); - - expect(result.size).toBe(0); + expect(findAffectedPackages(["some/other/path/file.ts"], pathMapNoRoot).size).toBe(0); }); test("multiple files affecting different packages", () => { const result = findAffectedPackages(["packages/ui/button.ts", "packages/core/utils.ts"], pathMap, false); - expect(result.has("@scope/ui")).toBe(true); - expect(result.has("@scope/core")).toBe(true); - expect(result.has("root")).toBe(false); + expect(result).toContain("@scope/ui"); + expect(result).toContain("@scope/core"); + expect(result).not.toContain("root"); }); }); @@ -111,8 +90,7 @@ describe("findDependencyPackages", () => { const result = findDependencyPackages(["@scope/utils"], packages); - expect(result).toContain("@scope/ui"); - expect(result).toContain("@scope/core"); + expect(result).toEqual(expect.arrayContaining(["@scope/ui", "@scope/core"])); expect(result).toHaveLength(2); });