build: migrate to pnpm and add vitest testing infrastructure

- Replace Yarn with pnpm as package manager
- Add Vitest with React Testing Library and happy-dom
- Configure test setup and add test scripts (test, test:ui, test:run)
- Add IPCard component and update lookup components
- Remove unused react-ogp dependency
- Add type-check script
This commit is contained in:
2025-10-22 00:34:53 -05:00
parent ae81ce3ba0
commit 09cd0bf49b
12 changed files with 5739 additions and 4351 deletions

View File

@@ -1 +0,0 @@
nodeLinker: node-modules

View File

@@ -6,7 +6,11 @@
"build": "next build",
"dev": "next dev",
"lint": "next lint",
"start": "next start"
"start": "next start",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@headlessui/react": "^2.0.3",
@@ -19,7 +23,6 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.42.1",
"react-ogp": "^0.0.3",
"react-timeago": "^7.2.0",
"sass": "^1.57.1",
"true-myth": "^7.1.0",
@@ -27,6 +30,8 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^18.11.18",
"@types/prettier": "^2.7.2",
"@types/react": "^18.0.26",
@@ -34,18 +39,21 @@
"@types/react-timeago": "^4.1.7",
"@typescript-eslint/eslint-plugin": "^5.47.1",
"@typescript-eslint/parser": "^5.47.1",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.7",
"eslint": "^8.30.0",
"eslint-config-next": "13.1.1",
"happy-dom": "^20.0.8",
"postcss": "^8.4.14",
"prettier": "^2.8.1",
"prettier-plugin-tailwindcss": "^0.2.1",
"tailwindcss": "^3.2.0",
"type-fest": "^4.18.2",
"typescript": "^4.9.4"
"typescript": "^4.9.4",
"vitest": "^3.2.4"
},
"ct3aMetadata": {
"initVersion": "7.2.0"
},
"packageManager": "yarn@4.2.2"
"packageManager": "pnpm@9.0.0"
}

5517
pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import type { FunctionComponent } from "react";
import DomainCard from "@/components/lookup/DomainCard";
import IPCard from "@/components/lookup/IPCard";
import type {
Domain,
AutonomousNumber,
@@ -25,9 +26,10 @@ const Generic: FunctionComponent<ObjectProps> = ({ data, url }: ObjectProps) =>
switch (data.objectClassName) {
case "domain":
return <DomainCard url={url} data={data} />;
case "ip network":
return <IPCard url={url} data={data} />;
case "autnum":
case "entity":
case "ip network":
case "nameserver":
default:
return (

View File

@@ -0,0 +1,61 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { IpNetwork } from "@/types";
import Events from "@/components/lookup/Events";
import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard";
export type IPCardProps = {
data: IpNetwork;
url?: string;
};
const IPCard: FunctionComponent<IPCardProps> = ({
data,
url,
}: IPCardProps) => {
return (
<AbstractCard
data={data}
url={url}
header={
<>
<span className="font-mono tracking-tighter">IP NETWORK</span>
<span className="font-mono tracking-wide">
{data.startAddress}
{data.startAddress !== data.endAddress && ` - ${data.endAddress}`}
</span>
<span className="whitespace-nowrap">({data.handle})</span>
</>
}
>
<dl>
<Property title="Name">{data.name}</Property>
<Property title="Handle">{data.handle}</Property>
<Property title="IP Version">{data.ipVersion.toUpperCase()}</Property>
<Property title="Start Address">{data.startAddress}</Property>
<Property title="End Address">{data.endAddress}</Property>
<Property title="Type">{data.type}</Property>
{data.country && (
<Property title="Country">{data.country}</Property>
)}
{data.parentHandle && (
<Property title="Parent Handle">{data.parentHandle}</Property>
)}
<Property title="Events">
<Events key={0} data={data.events} />
</Property>
<PropertyList title="Status">
{data.status.map((status, index) => (
<PropertyList.Item key={index} title={status}>
{status}
</PropertyList.Item>
))}
</PropertyList>
</dl>
</AbstractCard>
);
};
export default IPCard;

View File

@@ -40,3 +40,92 @@ export function truncated(input: string, maxLength: number, ellipsis = "...") {
export function preventDefault(event: SyntheticEvent | Event) {
event.preventDefault();
}
/**
* Convert an IPv4 address string to a 32-bit integer
*/
function ipv4ToInt(ip: string): number {
const parts = ip.split('.').map(Number);
if (parts.length !== 4) return 0;
const [a, b, c, d] = parts;
if (a === undefined || b === undefined || c === undefined || d === undefined) return 0;
return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
}
/**
* Check if an IPv4 address falls within a CIDR range
* @param ip The IP address to check (e.g., "192.168.1.1")
* @param cidr The CIDR range to check against (e.g., "192.168.0.0/16")
* @returns true if the IP is within the CIDR range
*/
export function ipv4InCIDR(ip: string, cidr: string): boolean {
const [rangeIp, prefixLenStr] = cidr.split('/');
const prefixLen = parseInt(prefixLenStr ?? '', 10);
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
return false;
}
const ipInt = ipv4ToInt(ip);
const rangeInt = ipv4ToInt(rangeIp);
const mask = (0xFFFFFFFF << (32 - prefixLen)) >>> 0;
return (ipInt & mask) === (rangeInt & mask);
}
/**
* Convert an IPv6 address to a BigInt representation
*/
function ipv6ToBigInt(ip: string): bigint {
// Expand :: notation
const expandedIp = expandIPv6(ip);
const parts = expandedIp.split(':');
let result = BigInt(0);
for (const part of parts) {
result = (result << BigInt(16)) | BigInt(parseInt(part, 16));
}
return result;
}
/**
* Expand IPv6 address shorthand notation
*/
function expandIPv6(ip: string): string {
if (ip.includes('::')) {
const [left, right] = ip.split('::');
const leftParts = left ? left.split(':') : [];
const rightParts = right ? right.split(':') : [];
const missingParts = 8 - leftParts.length - rightParts.length;
const middleParts: string[] = Array(missingParts).fill('0') as string[];
const allParts = [...leftParts, ...middleParts, ...rightParts];
return allParts.map((p: string) => p.padStart(4, '0')).join(':');
}
return ip.split(':').map((p: string) => p.padStart(4, '0')).join(':');
}
/**
* Check if an IPv6 address falls within a CIDR range
* @param ip The IPv6 address to check (e.g., "2001:db8::1")
* @param cidr The CIDR range to check against (e.g., "2001:db8::/32")
* @returns true if the IP is within the CIDR range
*/
export function ipv6InCIDR(ip: string, cidr: string): boolean {
const [rangeIp, prefixLenStr] = cidr.split('/');
const prefixLen = parseInt(prefixLenStr ?? '', 10);
if (!rangeIp || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 128) {
return false;
}
try {
const ipInt = ipv6ToBigInt(ip);
const rangeInt = ipv6ToBigInt(rangeIp);
const maxMask = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF');
const mask = (maxMask << BigInt(128 - prefixLen)) & maxMask;
return (ipInt & mask) === (rangeInt & mask);
} catch (e) {
return false;
}
}

View File

@@ -17,7 +17,7 @@ import {
RegisterSchema,
RootRegistryEnum,
} from "@/schema";
import { truncated } from "@/helpers";
import { truncated, ipv4InCIDR, ipv6InCIDR } from "@/helpers";
import type { ZodSchema } from "zod";
import type { ParsedGeneric } from "@/components/lookup/Generic";
import { Maybe, Result } from "true-myth";
@@ -118,10 +118,30 @@ const useLookup = (warningHandler?: WarningHandler) => {
}
}
throw new Error(`No matching domain found.`);
case "ip4":
throw new Error(`No matching ip4 found.`);
case "ip6":
throw new Error(`No matching ip6 found.`);
case "ip4": {
// Extract the IP address without CIDR suffix for matching
const ipAddress = lookupTarget.split('/')[0] ?? lookupTarget;
for (const bootstrapItem of bootstrap.services) {
// bootstrapItem[0] contains CIDR ranges like ["1.0.0.0/8", "2.0.0.0/8"]
if (bootstrapItem[0].some((cidr) => ipv4InCIDR(ipAddress, cidr))) {
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
break typeSwitch;
}
}
throw new Error(`No matching IPv4 registry found for ${lookupTarget}.`);
}
case "ip6": {
// Extract the IP address without CIDR suffix for matching
const ipAddress = lookupTarget.split('/')[0] ?? lookupTarget;
for (const bootstrapItem of bootstrap.services) {
// bootstrapItem[0] contains CIDR ranges like ["2001:0200::/23", "2001:0400::/23"]
if (bootstrapItem[0].some((cidr) => ipv6InCIDR(ipAddress, cidr))) {
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
break typeSwitch;
}
}
throw new Error(`No matching IPv6 registry found for ${lookupTarget}.`);
}
case "entity":
throw new Error(`No matching entity found.`);
case "autnum":
@@ -132,7 +152,11 @@ const useLookup = (warningHandler?: WarningHandler) => {
if (url == null) throw new Error("No lookup target was resolved.");
return `${url}${type}/${lookupTarget}`;
// Map internal types to RDAP endpoint paths
// ip4 and ip6 both use the 'ip' endpoint in RDAP
const rdapPath = type === "ip4" || type === "ip6" ? "ip" : type;
return `${url}${rdapPath}/${lookupTarget}`;
}
useEffect(() => {

View File

@@ -4,7 +4,6 @@ import { useState } from "react";
import Generic from "@/components/lookup/Generic";
import type { MetaParsedGeneric } from "@/hooks/useLookup";
import useLookup from "@/hooks/useLookup";
import { OGP } from "react-ogp";
import LookupInput from "@/components/form/LookupInput";
import ErrorCard from "@/components/common/ErrorCard";
import { Maybe } from "true-myth";
@@ -22,13 +21,12 @@ const Index: NextPage = () => {
<>
<Head>
<title>rdap.xevion.dev</title>
<OGP
url="https://rdap.xevion.dev"
title="RDAP | by Xevion.dev"
description="A custom, private RDAP lookup client built by Xevion."
siteName="rdap.xevion.dev"
type="website"
/>
<meta name="description" content="A custom, private RDAP lookup client built by Xevion." />
<meta property="og:url" content="https://rdap.xevion.dev" />
<meta property="og:title" content="RDAP | by Xevion.dev" />
<meta property="og:description" content="A custom, private RDAP lookup client built by Xevion." />
<meta property="og:site_name" content="rdap.xevion.dev" />
<meta property="og:type" content="website" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="keywords"

View File

@@ -107,12 +107,12 @@ export const IpNetworkSchema = z.object({
ipVersion: z.enum(["v4", "v6"]),
name: z.string(),
type: z.string(),
country: z.string(),
parentHandle: z.string(),
country: z.string().optional(),
parentHandle: z.string().optional(),
status: z.string().array(),
entities: z.array(EntitySchema),
remarks: z.any(),
links: z.any(),
entities: z.array(EntitySchema).optional(),
remarks: z.any().optional(),
links: z.any().optional(),
port43: z.any().optional(),
events: z.array(EventSchema),
});

1
src/test/setup.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

15
vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./src/test/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

4326
yarn.lock
View File

File diff suppressed because it is too large Load Diff