therepo/packages/conventional/tests/features/commit-msg.test.ts
M. George Hansen 5f519f54f2
feat(@websnacksjs/conventional): initial implementation
`@websnacksjs/conventional` is a cli program which can be used with
`husky` & git hooks to enforce that commit messages adhere to the
conventional commits standard.
2025-08-19 18:31:34 +12:00

469 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import { fork } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import inspector from "node:inspector";
import os from "node:os";
import path from "node:path";
import {
after,
afterEach,
before,
beforeEach,
describe,
it,
mock,
} from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { code } from "ts-poet";
const detectInspectorPort = (): string | undefined => {
let inspectorUrl: URL | string | undefined = inspector.url();
if (!inspectorUrl) {
return;
}
inspectorUrl = new URL(inspectorUrl);
return inspectorUrl.port;
};
const runCommand = async (
messageFile: URL,
{
signal,
}: {
signal: AbortSignal;
},
): Promise<{ stderr: string; stdout: string; code: number }> => {
const binFile = new URL("../../bin/conventional.js", import.meta.url);
const inspectorPort = detectInspectorPort();
const command = fork(binFile, ["commit-msg", fileURLToPath(messageFile)], {
signal,
stdio: ["ipc", "pipe", "pipe"],
execArgv:
inspectorPort != null
? [
"--inspect",
"--inspect-port=0",
// Ensure that inspector output isn't redirectd to
// stderr which can mess up our tests that assert on
// stderr.
"--inspect-publish-uid=http",
]
: [],
});
let stderr = "";
command.stderr?.setEncoding("utf-8");
command.stderr?.on("data", (chunk) => {
stderr += chunk;
});
let stdout = "";
command.stdout?.setEncoding("utf-8");
command.stdout?.on("data", (chunk) => {
stdout += chunk;
});
const code = await new Promise<number>((resolve) => {
command.once("close", (code) => resolve(code ?? 0));
});
stderr = stderr.trim();
stdout = stdout.trim();
return {
stderr,
stdout,
code,
};
};
const previousCwd = process.cwd();
let tempDir: URL;
before(async () => {
tempDir = await fs
.mkdtemp(path.join(os.tmpdir(), "websnacks-conventional-"))
.then(pathToFileURL);
// Add trailing slash so relative URLs get properly appended.
tempDir.pathname = `${tempDir.pathname}/`;
process.chdir(fileURLToPath(tempDir));
});
after(async () => {
process.chdir(previousCwd);
await fs.rm(tempDir, { recursive: true, force: true });
});
const mockCommitMessage = async (message: string): Promise<URL> => {
const messageFile = new URL(`./message-${randomUUID()}`, tempDir);
await fs.writeFile(messageFile, message, "utf-8");
return messageFile;
};
afterEach(() => {
mock.reset();
});
describe("conventional commit-msg ...", () => {
describe("when commit message is missing conventional commit summary line", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage("fixes an issue");
});
it("produces an appropriate error", async ({ signal }) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: [
"⛔ Error: commit message is not in a conventional commit v1.0.0 compliant format",
"(see https://www.conventionalcommits.org/en/v1.0.0/)",
].join(" "),
},
);
});
});
describe("when commit message contains only a properly formatted summary line & whitespace", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
["fix(something): fixes something, I think", "", ""].join("\n"),
);
});
it("outputs properly formatted commit message", async ({ signal }) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
},
);
assert.equal(
await fs.readFile(messageFile, "utf-8"),
"fix(something): fixes something, I think",
);
});
});
describe('when commit message description ends with a comma ","', () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
"fix: resolves issue where things wouldn't work,",
);
});
it("rejects the commit message w/ an appropriate error", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: [
"⛔ Error: commit message description malformed:",
'ends w/ trailing comma "," (did you include the entire string?)',
].join(" "),
},
);
});
});
describe('when commit message body ends with a comma ","', () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"fix: resolves issue where things wouldn't work",
"",
"This fixes that annoying bug reported last week,",
].join("\n"),
);
});
it("rejects the commit message w/ an appropriate error", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: '⛔ Error: commit message body malformed: ends w/ trailing comma "," (did you include the entire string?)',
},
);
});
});
describe("when commit message summary line is longer than 72 characters", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"feat(subpackage-a): this adds TONS of new features such as",
"walking your dog, buying groceries, paying your bills, and",
"more!",
].join(" "),
);
});
it("rejects commit message w/ appropriate error message", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: "⛔ Error: commit message summary line exceeds max length of 72 characters",
},
);
});
});
describe("when commit message contains comments", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"feat(subpackage-a): added lots of new features",
"",
"This adds a LOT of new features. Like, an incredible amount!",
"",
"# Please enter the commit message for your changes. Lines starting",
"# with '#' will be ignored, and an empty message aborts the commit.",
"#",
"# Date: Mon Aug 18 16:24:29 2025 +0000",
].join("\n"),
);
});
it("removes comments from the comment message", async ({ signal }) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
stderr: "",
},
);
assert.equal(
await fs.readFile(messageFile, "utf-8"),
[
"feat(subpackage-a): added lots of new features",
"",
"This adds a LOT of new features. Like, an incredible amount!",
].join("\n"),
);
});
});
describe("when commit message is valid conventional commit but is improperly formatted", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"feat( subpackage-a ): added lots of new features",
"adds a lot of neat stuff",
"",
"you should check it out brah!",
"",
"BREAKING-CHANGE: It's gonna break production, gurranteed.",
"",
"",
"authored-By: Someone",
" ",
].join("\n"),
);
});
it("formats commit message to normalize whitespace, capitalization & punctuation", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
stderr: "",
},
);
assert.equal(
await fs.readFile(messageFile, "utf-8"),
[
"feat(subpackage-a)!: added lots of new features",
"",
"Adds a lot of neat stuff.",
"",
"You should check it out brah!",
"",
"BREAKING CHANGE: it's gonna break production, gurranteed",
"",
"Authored-by: Someone",
].join("\n"),
);
});
});
describe("when commit message body exceeds max line length of 72 characters", () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"feat(subpackage-a): added lots of new features",
"",
[
"Lorem ipsum dolor sit amet, consectetur adipiscing",
"elit, sed do eiusmod tempor incididunt ut labore et",
"dolore magna aliqua. Ut enim ad minim veniam, quis",
"https://example.com/some/very/very/very/very/long-path/with-hyphens.html",
"nostrud exercitation ullamco laboris nisi ut aliquip",
"ex ea commodo consequat.",
].join(" "),
"",
"",
].join("\n"),
);
});
it("wraps message body at 72 character rule where feasible", async ({
signal,
}) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
},
);
assert.equal(
await fs.readFile(messageFile, "utf-8"),
[
"feat(subpackage-a): added lots of new features",
"",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod",
"tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim",
"veniam, quis",
"https://example.com/some/very/very/very/very/long-path/with-hyphens.html",
"nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo",
"consequat.",
].join("\n"),
);
});
});
describe('when commit message has "BREAKING CHANGE" footer', () => {
let messageFile: URL;
beforeEach(async () => {
messageFile = await mockCommitMessage(
[
"fix(@websnacksjs/conventional): changes a few things",
"",
"BREAKING CHANGE: this will break production, guaranteed",
].join("\n"),
);
});
it("marks commit message summary as breaking", async ({ signal }) => {
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
stderr: "",
},
);
const [firstLine] = await fs
.readFile(messageFile, "utf-8")
.then((message) => message.split("\n"));
assert.equal(
firstLine,
"fix(@websnacksjs/conventional)!: changes a few things",
);
});
});
describe("when user provides a config file that fails to load", () => {
let configFile: URL;
before(async () => {
configFile = new URL("./conventional.config.js", tempDir);
await fs.writeFile(
configFile,
code`
export default =
`.toString(),
"utf-8",
);
});
after(async () => {
await fs.rm(configFile);
});
it("provides an appropriate error message", async ({ signal }) => {
const messageFile = await mockCommitMessage(
["feat(good): this is a good thing, probably"].join("\n"),
);
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: [
`⛔ Error: failed to load config from ./conventional.config.js:`,
`Unexpected token '='`,
].join(" "),
},
);
});
});
describe("when user provides config file w/ validateCommitMessage(...)", () => {
let configFile: URL;
before(async () => {
configFile = new URL("./conventional.config.js", tempDir);
await fs.writeFile(
configFile,
code`
export default {
validateCommitMessage(message) {
if (message.scope !== "good") {
throw new Error(\`only "good" is accepted as a valid scope\`)
}
}
};
`.toString(),
"utf-8",
);
});
after(async () => {
await fs.rm(configFile);
});
it("rejects commit message that DON'T satisfy user validation function", async ({
signal,
}) => {
const messageFile = await mockCommitMessage(
"feat(bad): adds a terrible new feature, beware",
);
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 1,
stderr: [
`⛔ Error: user-defined validateCommitMessage(...) rejected commit message:`,
`only "good" is accepted as a valid scope`,
].join(" "),
},
);
});
it("approves commit message that DO satisfy user validation function", async ({
signal,
}) => {
const messageFile = await mockCommitMessage(
"feat(good): adds a terrible new feature, beware",
);
assert.partialDeepStrictEqual(
await runCommand(messageFile, { signal }),
{
code: 0,
stdout: "",
},
);
});
});
});