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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/loopover-ui-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"input-otp": "^1.4.2",
"lucide-react": "^0.577.0",
"react-day-picker": "^9.14.0",
"react-hook-form": "^7.80.0",
"react-resizable-panels": "^4.12.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
Expand Down
63 changes: 63 additions & 0 deletions test/unit/ui-kit-manifest-dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";

// #8309: @loopover/ui-kit is a real, published npm package (publishConfig.access: "public") -- every
// component that wraps an external library must declare that library in its own package.json, since a
// consumer installing the package in isolation (or a workspace member that doesn't happen to hoist the
// dependency from elsewhere, e.g. loopover-miner-ui) has no other guarantee module resolution succeeds.
// This asserts every external import across the package's (non-test) components has a matching
// dependencies/peerDependencies entry, so a future component that forgets to declare its import is
// caught here instead of only failing for a consumer outside this monorepo's hoisting.

const COMPONENTS_DIR = join("packages", "loopover-ui-kit", "src", "components");

function packageNameFromSpecifier(specifier: string): string {
const segments = specifier.split("/");
// Scoped package (@scope/name) -- the package name is the first two path segments; everything else
// (unscoped, e.g. "lucide-react", or a subpath import like "some-pkg/sub") is just the first segment.
return specifier.startsWith("@")
? segments.slice(0, 2).join("/")
: (segments[0] ?? specifier);
}

function externalImportsIn(source: string): string[] {
const specifiers: string[] = [];
for (const match of source.matchAll(
/import\s[\s\S]*?from\s+["']([^"']+)["']/g,
)) {
const specifier = match[1];
if (specifier && !specifier.startsWith("."))
specifiers.push(packageNameFromSpecifier(specifier));
}
return specifiers;
}

describe("packages/loopover-ui-kit package.json declares every component's external imports (#8309)", () => {
it("every non-test component's external import has a dependencies or peerDependencies entry", () => {
const pkg = JSON.parse(
readFileSync(join("packages", "loopover-ui-kit", "package.json"), "utf8"),
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const declared = new Set([
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.peerDependencies ?? {}),
]);

const componentFiles = readdirSync(COMPONENTS_DIR).filter(
(name) => name.endsWith(".tsx") && !name.endsWith(".test.tsx"),
);
expect(componentFiles.length).toBeGreaterThan(0); // guard against a glob/path typo silently checking nothing

const undeclared: string[] = [];
for (const file of componentFiles) {
const source = readFileSync(join(COMPONENTS_DIR, file), "utf8");
for (const importedPackage of externalImportsIn(source)) {
if (!declared.has(importedPackage))
undeclared.push(`${file}: ${importedPackage}`);
}
}
expect(undeclared).toEqual([]);
});
});
Loading