diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c35405dd5..993ed893f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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, ""); diff --git a/test/lib/utils.property.test.ts b/test/lib/utils.property.test.ts new file mode 100644 index 000000000..2dc04f0e4 --- /dev/null +++ b/test/lib/utils.property.test.ts @@ -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 "/" or "\" where both halves contain + // valid slug chars, the separator must produce a hyphen in the output — + // never a silent mash-up like "". + 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 } + ); + }); +}); diff --git a/test/lib/utils.test.ts b/test/lib/utils.test.ts new file mode 100644 index 000000000..af9bd16bb --- /dev/null +++ b/test/lib/utils.test.ts @@ -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); + }); +});