Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,24 @@ export function isAllDigits(str: string): boolean {
* Aligned with Sentry's canonical implementation:
* https://github.com/getsentry/sentry/blob/master/static/app/utils/slugify.tsx
*
* @example slugify("My Cool App") // "my-cool-app"
* @example slugify("my-app") // "my-app"
* @example slugify("Café Project") // "cafe-project"
* @example slugify("my_app") // "my_app"
* Diverges from Sentry's frontend canonical version in one place: `/` and `\`
* are normalized to a space before invalid-character stripping, so structural
* separators (npm scopes, monorepo path segments) become hyphens instead of
* being silently dropped. Without this, `@scope/pkg` would slugify to
* `scopepkg` instead of `scope-pkg`.
*
* @example slugify("My Cool App") // "my-cool-app"
* @example slugify("my-app") // "my-app"
* @example slugify("Café Project") // "cafe-project"
* @example slugify("my_app") // "my_app"
* @example slugify("@t3tools/web") // "t3tools-web"
* @example slugify("packages/api") // "packages-api"
*/
export function slugify(name: string): string {
return name
.normalize("NFKD")
.toLowerCase()
.replace(/[\\/]+/g, " ")
.replace(/[^a-z0-9_\s-]/g, "")
.replace(/[-\s]+/g, "-")
.replace(/^-|-$/g, "");
Expand Down
102 changes: 102 additions & 0 deletions test/lib/utils.property.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Property-Based Tests for src/lib/utils.ts
*
* Verifies invariants that should hold for any input to slugify, regardless
* of the characters present.
*/

import { describe, expect, test } from "bun:test";
import {
array,
constantFrom,
assert as fcAssert,
property,
string,
} from "fast-check";
import { slugify } from "../../src/lib/utils.js";
import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js";

/** Mix of valid slug chars, separators, scope/path glyphs, whitespace, and unicode */
const messyChars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +
"_-/\\@.: \tCaféñü漢";

const messyInputArb = array(constantFrom(...messyChars.split("")), {
minLength: 0,
maxLength: 30,
}).map((chars) => chars.join(""));

const VALID_SLUG_RE = /^[a-z0-9_-]*$/;

describe("property: slugify", () => {
test("output contains only [a-z0-9_-]", () => {
fcAssert(
property(messyInputArb, (input) => {
expect(slugify(input)).toMatch(VALID_SLUG_RE);
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});

test("output never starts or ends with a hyphen", () => {
fcAssert(
property(messyInputArb, (input) => {
const out = slugify(input);
if (out.length > 0) {
expect(out.startsWith("-")).toBe(false);
expect(out.endsWith("-")).toBe(false);
}
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});

test("output never contains consecutive hyphens", () => {
fcAssert(
property(messyInputArb, (input) => {
expect(slugify(input).includes("--")).toBe(false);
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});

test("idempotent: slugify(slugify(x)) === slugify(x)", () => {
fcAssert(
property(messyInputArb, (input) => {
const once = slugify(input);
const twice = slugify(once);
expect(twice).toBe(once);
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});

test("any arbitrary string still produces a valid slug", () => {
// Broader coverage with the unconstrained string arbitrary — catches
// anything the curated charset above might miss.
fcAssert(
property(string(), (input) => {
expect(slugify(input)).toMatch(VALID_SLUG_RE);
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});

test("path/scope separators always become hyphens between alnum runs", () => {
// For inputs of the form "<a>/<b>" or "<a>\<b>" where both halves contain
// valid slug chars, the separator must produce a hyphen in the output —
// never a silent mash-up like "<a><b>".
const segmentChars = "abcdefghijklmnopqrstuvwxyz0123456789";
const segmentArb = array(constantFrom(...segmentChars.split("")), {
minLength: 1,
maxLength: 10,
}).map((chars) => chars.join(""));

fcAssert(
property(segmentArb, segmentArb, constantFrom("/", "\\"), (a, b, sep) => {
expect(slugify(`${a}${sep}${b}`)).toBe(`${a}-${b}`);
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});
});
113 changes: 113 additions & 0 deletions test/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Tests for src/lib/utils.ts
*
* Note: Core invariants (charset, idempotency, no leading/trailing/consecutive
* hyphens) are tested via property-based tests in utils.property.test.ts.
* These tests focus on specific inputs documented in JSDoc and regression
* cases for the npm-scope / monorepo path bug (CLI-1XX).
*/

import { describe, expect, test } from "bun:test";
import { isAllDigits, slugify } from "../../src/lib/utils.js";

describe("slugify", () => {
describe("JSDoc examples (canonical alignment)", () => {
test('slugify("My Cool App") → "my-cool-app"', () => {
expect(slugify("My Cool App")).toBe("my-cool-app");
});

test('slugify("my-app") → "my-app"', () => {
expect(slugify("my-app")).toBe("my-app");
});

test('slugify("Café Project") → "cafe-project"', () => {
expect(slugify("Café Project")).toBe("cafe-project");
});

test('slugify("my_app") → "my_app"', () => {
expect(slugify("my_app")).toBe("my_app");
});
});

describe("npm scoped package names", () => {
// Regression for the t3tools/web monorepo report — silently stripping
// `/` produced unreadable mashups like `t3toolsweb` instead of a useful
// `t3tools-web` slug.
test('slugify("@t3tools/web") → "t3tools-web"', () => {
expect(slugify("@t3tools/web")).toBe("t3tools-web");
});

test('slugify("@scope/pkg") → "scope-pkg"', () => {
expect(slugify("@scope/pkg")).toBe("scope-pkg");
});

test('slugify("@my-org/some-package") → "my-org-some-package"', () => {
expect(slugify("@my-org/some-package")).toBe("my-org-some-package");
});
});

describe("monorepo path-style names", () => {
test('slugify("packages/api") → "packages-api"', () => {
expect(slugify("packages/api")).toBe("packages-api");
});

test('slugify("apps/api/web") → "apps-api-web"', () => {
expect(slugify("apps/api/web")).toBe("apps-api-web");
});

test('slugify("apps\\\\api") → "apps-api"', () => {
expect(slugify("apps\\api")).toBe("apps-api");
});

test('slugify("@scope/My App") → "scope-my-app"', () => {
expect(slugify("@scope/My App")).toBe("scope-my-app");
});
});

describe("edge cases", () => {
test('slugify("") → ""', () => {
expect(slugify("")).toBe("");
});

test('slugify("///") → ""', () => {
expect(slugify("///")).toBe("");
});

test('slugify("@@@") → ""', () => {
expect(slugify("@@@")).toBe("");
});

test('slugify("@/foo") → "foo"', () => {
expect(slugify("@/foo")).toBe("foo");
});

test('slugify("///foo///") → "foo"', () => {
expect(slugify("///foo///")).toBe("foo");
});

test('slugify("---foo---") → "foo"', () => {
expect(slugify("---foo---")).toBe("foo");
});

test("collapses runs of slashes/spaces/hyphens", () => {
expect(slugify("foo // bar -- baz")).toBe("foo-bar-baz");
});
});
});

describe("isAllDigits", () => {
test("returns true for pure digits", () => {
expect(isAllDigits("0")).toBe(true);
expect(isAllDigits("123456")).toBe(true);
});

test("returns false for non-digit strings", () => {
expect(isAllDigits("PROJECT-ABC")).toBe(false);
expect(isAllDigits("abc123")).toBe(false);
expect(isAllDigits("123abc")).toBe(false);
expect(isAllDigits("")).toBe(false);
expect(isAllDigits("12.3")).toBe(false);
expect(isAllDigits("-1")).toBe(false);
expect(isAllDigits(" 123")).toBe(false);
});
});
Loading