diff --git a/packages/config/package.json b/packages/config/package.json index e05ced6a042..a15ebdc66b4 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,9 @@ { "name": "config", "version": "0.0.0", + "scripts": { + "test": "vitest run" + }, "exports": { "./*": "./*", "./vite": "./vite" @@ -17,7 +20,8 @@ "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-tailwindcss": "^3.12.0", - "eslint-utils": "^3.0.0" + "eslint-utils": "^3.0.0", + "vitest": "~2.1.9" }, "dependencies": { "@vitejs/plugin-react": "^4.0.3", diff --git a/packages/config/vite/relativeAliasResolver.test.ts b/packages/config/vite/relativeAliasResolver.test.ts new file mode 100644 index 00000000000..0c7ac910de5 --- /dev/null +++ b/packages/config/vite/relativeAliasResolver.test.ts @@ -0,0 +1,70 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import resolver, { + clearRelativeAliasResolverCacheForTesting, +} from "./relativeAliasResolver"; + +let tempDir: string; + +beforeEach(async () => { + clearRelativeAliasResolverCacheForTesting(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "cap-config-vite-")); +}); + +afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); +}); + +const resolveAlias = async (source: string, importer: string) => { + const result = await resolver.customResolver?.(source, importer, {}); + if (typeof result !== "string") throw new Error("Expected string resolution"); + return result; +}; + +describe("relativeAliasResolver", () => { + it("resolves ~/ imports from a package src directory", async () => { + const srcDir = path.join(tempDir, "pkg", "src"); + await fs.mkdir(path.join(srcDir, "components"), { recursive: true }); + await fs.writeFile(path.join(srcDir, "components", "Button.tsx"), ""); + + await expect( + resolveAlias( + "~/components/Button", + path.join(srcDir, "pages", "index.tsx"), + ), + ).resolves.toBe(path.join(srcDir, "components", "Button.tsx")); + }); + + it("normalizes Windows-style importers before resolving from src", async () => { + const srcDir = path.join(tempDir, "pkg", "src"); + await fs.mkdir(path.join(srcDir, "components"), { recursive: true }); + await fs.writeFile(path.join(srcDir, "components", "Card.tsx"), ""); + + const windowsImporter = path + .join(srcDir, "pages", "index.tsx") + .replaceAll(path.sep, "\\"); + + await expect( + resolveAlias("~/components/Card", windowsImporter), + ).resolves.toBe(path.join(srcDir, "components", "Card.tsx")); + }); + + it("resolves ~/ imports from the nearest package root", async () => { + const pkgDir = path.join(tempDir, "pkg"); + await fs.mkdir(path.join(pkgDir, "src", "utils"), { recursive: true }); + await fs.writeFile(path.join(pkgDir, "package.json"), "{}"); + await fs.writeFile(path.join(pkgDir, "src", "utils", "index.ts"), ""); + + await expect( + resolveAlias("~/src/utils", path.join(pkgDir, "tests", "unit.test.ts")), + ).resolves.toBe(path.join(pkgDir, "src", "utils", "index.ts")); + }); + + it("stops at the filesystem root when no package.json can be found", async () => { + await expect( + resolveAlias("~/missing/file", path.join(tempDir, "loose", "test.ts")), + ).rejects.toThrow("Failed to resolve import path ~/missing/file"); + }); +}); diff --git a/packages/config/vite/relativeAliasResolver.ts b/packages/config/vite/relativeAliasResolver.ts index 1681ff55836..0ea5b202c82 100644 --- a/packages/config/vite/relativeAliasResolver.ts +++ b/packages/config/vite/relativeAliasResolver.ts @@ -4,32 +4,43 @@ import type { Alias } from "vite"; const pkgJsonCache = new Map(); +const isPathRoot = (value: string) => path.dirname(value) === value; + +export const clearRelativeAliasResolverCacheForTesting = () => { + pkgJsonCache.clear(); +}; + const resolver: Alias = { find: /^(~\/.+)/, replacement: "$1", async customResolver(source, importer) { let root: null | string = null; + const normalizedImporter = importer?.replace(/\\/g, "/"); const [_, sourcePath] = source.split("~/"); - if (importer?.includes("/src/")) { - const [pkg] = importer?.split("/src/"); + if (normalizedImporter?.includes("/src/")) { + const [pkg] = normalizedImporter.split("/src/"); - root = `${pkg!}/src`; + root = path.normalize(`${pkg}/src`); } else { - let parent = importer!; + if (!importer) throw new Error(`Failed to resolve import path ${source}`); - while (parent !== "/") { + let parent = importer; + + while (!isPathRoot(parent)) { parent = path.dirname(parent); let hasPkgJson = pkgJsonCache.get(parent); if (hasPkgJson === undefined) try { - await fs.stat(`${parent}/package.json`); - pkgJsonCache.set(parent, (hasPkgJson = true)); + await fs.stat(path.join(parent, "package.json")); + hasPkgJson = true; + pkgJsonCache.set(parent, hasPkgJson); } catch { - pkgJsonCache.set(parent, (hasPkgJson = false)); + hasPkgJson = false; + pkgJsonCache.set(parent, hasPkgJson); } if (hasPkgJson) { @@ -44,13 +55,22 @@ const resolver: Alias = { ); } - const absolutePath = `${root}/${sourcePath}`; + const absolutePath = path.join(root, sourcePath); const folderItems = await fs.readdir(path.join(absolutePath, "../")); + const basename = sourcePath.split("/").at(-1); + + if (!basename) + throw new Error( + `Failed to resolve import path ${source} in file ${importer}`, + ); - const item = folderItems.find((i) => - i.startsWith(sourcePath.split("/").at(-1)!), - )!; + const item = folderItems.find((i) => i.startsWith(basename)); + + if (!item) + throw new Error( + `Failed to resolve import path ${source} in file ${importer}`, + ); const fullPath = absolutePath + path.extname(item); @@ -63,7 +83,12 @@ const resolver: Alias = { const indexFile = directoryItems.find((i) => i.startsWith("index")); - return `${absolutePath}/${indexFile}`; + if (!indexFile) + throw new Error( + `Failed to resolve index file for ${source} in file ${importer}`, + ); + + return path.join(absolutePath, indexFile); } else { return fullPath; } diff --git a/packages/config/vitest.config.ts b/packages/config/vitest.config.ts new file mode 100644 index 00000000000..61251dfabd9 --- /dev/null +++ b/packages/config/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/utils/package.json b/packages/utils/package.json index 82f842d4c57..ee1e804d5b3 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -5,6 +5,7 @@ ".": "./src/index.ts" }, "scripts": { + "test": "vitest run", "typecheck": "tsc -b", "build": "tsdown" }, @@ -16,7 +17,8 @@ "react-dom": "^19.1.1", "react-router-dom": "^6.18.0", "tsconfig": "workspace:*", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "~2.1.9" }, "dependencies": { "@aws-sdk/client-s3": "^3.485.0", diff --git a/packages/utils/src/helpers.test.ts b/packages/utils/src/helpers.test.ts new file mode 100644 index 00000000000..c40a031e500 --- /dev/null +++ b/packages/utils/src/helpers.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + calculateStrokeDashoffset, + classNames, + getDisplayProgress, + getProgressCircleConfig, + isEmailAllowedByRestriction, + uuidFormat, + uuidParse, +} from "./helpers"; + +describe("helpers", () => { + it("parses and formats UUIDs", () => { + const formatted = "123e4567-e89b-12d3-a456-426614174000"; + const compact = "123e4567e89b12d3a456426614174000"; + + expect(uuidParse(formatted)).toBe(compact); + expect(uuidFormat(compact)).toBe(formatted); + }); + + it("calculates circular progress values", () => { + const { radius, circumference } = getProgressCircleConfig(); + + expect(radius).toBe(8); + expect(circumference).toBe(2 * Math.PI * 8); + expect(calculateStrokeDashoffset(25, 80)).toBe(60); + }); + + it("prefers upload progress over processing progress", () => { + expect(getDisplayProgress(42, 10)).toBe(42); + expect(getDisplayProgress(undefined, 10)).toBe(10); + }); + + it("matches email restrictions by exact address or domain", () => { + expect(isEmailAllowedByRestriction("Member@Cap.so", "member@cap.so")).toBe( + true, + ); + expect(isEmailAllowedByRestriction("hello@cap.so", "cap.so")).toBe(true); + expect(isEmailAllowedByRestriction("hello@example.com", "cap.so")).toBe( + false, + ); + expect(isEmailAllowedByRestriction("hello@example.com", "")).toBe(true); + }); + + it("merges conditional Tailwind class names", () => { + expect(classNames("px-2", "px-4", false && "hidden")).toBe("px-4"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce77b06b4f8..7beb690e3d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -952,6 +952,9 @@ importers: eslint-utils: specifier: ^3.0.0 version: 3.0.0(eslint@8.57.1) + vitest: + specifier: ~2.1.9 + version: 2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(terser@5.44.0) packages/database: dependencies: @@ -1323,6 +1326,9 @@ importers: typescript: specifier: ^5.8.3 version: 5.8.3 + vitest: + specifier: ~2.1.9 + version: 2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(terser@5.44.0) packages/web-api-contract: dependencies: