diff --git a/packages/cli/package.json b/packages/cli/package.json index 91490b31..71fcc3c7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,9 @@ "cli": "node dist/cli.js", "cli:init": "node dist/cli.js init", "render-mock": "tsx --watch src/mock-render.tsx", - "typecheck": "tsc --noEmit true" + "typecheck": "tsc --noEmit true", + "test:unit": "vitest run", + "test:coverage": "vitest run --coverage" }, "files": ["dist"], "dependencies": { diff --git a/packages/cli/src/components/__snapshots__/table.test.tsx.snap b/packages/cli/src/components/__snapshots__/table.test.tsx.snap new file mode 100644 index 00000000..e9cbd32c --- /dev/null +++ b/packages/cli/src/components/__snapshots__/table.test.tsx.snap @@ -0,0 +1,33 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Table Component with mocked terminal dimensions > renders the table with insufficient terminal width 1`] = ` +"┌─────────┬────────────────────────────────┬─────┐ +│ Name │ Description │ Age │ +├─────────┼────────────────────────────────┼─────┤ +│ Alice │ Develops and maintains │ 20 │ +│ │ software, writes code, tests │ │ +│ │ new features, and fixes bugs. │ │ +│ Bob │ Creates robust applications by │ 30 │ +│ │ programming, testing, and │ │ +│ │ deploying software solutions. │ │ +│ Charlie │ Responsible for designing and │ 27 │ +│ │ implementing user interfaces, │ │ +│ │ ensuring a seamless user │ │ +│ │ experience. │ │ +└─────────┴────────────────────────────────┴─────┘ +" +`; + +exports[`Table Component with mocked terminal dimensions > renders the table with sufficient terminal width 1`] = ` +"┌─────────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬─────┐ +│ Name │ Description │ Age │ +├─────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼─────┤ +│ Alice │ Develops and maintains software, writes code, tests new features, and fixes bugs. │ 20 │ +│ Bob │ Creates robust applications by programming, testing, and deploying software solutions. │ 30 │ +│ Charlie │ Responsible for designing and implementing user interfaces, ensuring a seamless user experience. │ 27 │ +└─────────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴─────┘ + + + +" +`; diff --git a/packages/cli/src/components/audit-licenses/verbose-view.tsx b/packages/cli/src/components/audit-licenses/verbose-view.tsx index 8e0c9039..9fa90365 100644 --- a/packages/cli/src/components/audit-licenses/verbose-view.tsx +++ b/packages/cli/src/components/audit-licenses/verbose-view.tsx @@ -30,8 +30,8 @@ const columns: Column[] = [ { title: "status", accessor: "status", - cell: (rowData: VerboseViewData) => ( - {rowData.status} + cell: (content) => ( + {content} ), }, { diff --git a/packages/cli/src/components/table.test.tsx b/packages/cli/src/components/table.test.tsx new file mode 100644 index 00000000..20489698 --- /dev/null +++ b/packages/cli/src/components/table.test.tsx @@ -0,0 +1,77 @@ +import { Text } from "ink"; +import { render } from "ink-testing-library"; +import { describe, expect, it, vi } from "vitest"; +import { type Column, Table } from "./table.js"; + +type RowData = { + name: string; + description: string; + age: string; +}; + +vi.mock("../hooks/use-terminal-dimensions.js", async () => { + const actual = await vi.importActual("../hooks/use-terminal-dimensions.js"); + return { + ...actual, + useTerminalDimensions: vi.fn(), + }; +}); + +describe("Table Component with mocked terminal dimensions", () => { + const columns: Column[] = [ + { title: "Name", accessor: "name" }, + { + title: "Description", + accessor: "description", + cell: (content) => {content}, + }, + { title: "Age", accessor: "age" }, + ]; + + const data: RowData[] = [ + { + name: "Alice", + description: + "Develops and maintains software, writes code, tests new features, and fixes bugs.", + age: "20", + }, + { + name: "Bob", + description: + "Creates robust applications by programming, testing, and deploying software solutions.", + age: "30", + }, + { + name: "Charlie", + description: + "Responsible for designing and implementing user interfaces, ensuring a seamless user experience.", + age: "27", + }, + ]; + + it("renders the table with sufficient terminal width", async () => { + const { useTerminalDimensions } = await import( + "../hooks/use-terminal-dimensions.js" + ); + // @ts-expect-error + useTerminalDimensions.mockReturnValue([350, 20]); + + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); + + it("renders the table with insufficient terminal width", async () => { + const { useTerminalDimensions } = await import( + "../hooks/use-terminal-dimensions.js" + ); + // @ts-expect-error + useTerminalDimensions.mockReturnValue([50, 20]); + + const { lastFrame } = render(
); + const output = lastFrame(); + + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/components/table.tsx b/packages/cli/src/components/table.tsx index fc3490a1..aec89d67 100644 --- a/packages/cli/src/components/table.tsx +++ b/packages/cli/src/components/table.tsx @@ -1,10 +1,12 @@ import { Box, Static, Text } from "ink"; import { type ReactNode, useMemo } from "react"; +import { useTerminalDimensions } from "../hooks/use-terminal-dimensions.js"; +import { calculateColumnWidths, splitIntoLines } from "../utils/table-utils.js"; export interface Column> { title: string; accessor: keyof T; - cell?: (rowData: T) => ReactNode; + cell?: (value: string) => ReactNode; // This should not change length of the content in cell, only styling } interface TableProps> { @@ -16,17 +18,14 @@ export function Table>({ columns, data, }: TableProps) { - const columnsWithWidth = useMemo(() => { - const columnsWithWidth = []; - for (const column of columns) { - const columnWidth = Math.max( - ...data.map((row) => (row[column.accessor] ?? "").length), - column.title.length, - ); - columnsWithWidth.push({ ...column, width: columnWidth }); - } - return columnsWithWidth; - }, [columns, data]); + const [terminalWidth] = useTerminalDimensions(); + + const availableSpace = terminalWidth - (3 * columns.length + 1); + + const columnsWithWidth = useMemo( + () => calculateColumnWidths(columns, data, availableSpace), + [columns, data, availableSpace], + ); const createHorizontalLine = ( leftChar: string, @@ -52,14 +51,15 @@ export function Table>({ {columnsWithWidth.map((columnWithWidth) => { - const content = columnWithWidth.title.padEnd( - columnWithWidth.width, - " ", - ); + const content = columnWithWidth.title; + const padding = " ".repeat(columnWithWidth.width - content.length + 1); + return ( {" "} - {content} {"│"} + {content} + {padding} + {"│"} ); })} @@ -70,25 +70,49 @@ export function Table>({ {topLine}, headerRow, {headerSeparator}, - ...data.map((row, rowIndex) => ( - - - {columnsWithWidth.map((columnWithWidth, colIndex) => { - const cellValue = row[columnWithWidth.accessor]?.toString() ?? ""; - const cellContent = columnWithWidth.cell ? ( - columnWithWidth.cell(row) - ) : ( - {cellValue.padEnd(columnWithWidth.width, " ")} - ); - return ( - - {" "} - {cellContent} {"│"} - - ); - })} - - )), + ...data.flatMap((row, rowIndex) => { + const rowLines = columnsWithWidth.map((columnWithWidth) => { + const cellValue = row[columnWithWidth.accessor]?.toString() ?? ""; + return splitIntoLines(cellValue, columnWithWidth.width); + }); + + const maxLines = rowLines.reduce( + (max, lines) => Math.max(max, lines.length), + 0, + ); + + return Array.from({ length: maxLines }, (_, lineIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: statically rendered component + + + {columnsWithWidth.map((columnWithWidth, colIndex) => { + const lines = rowLines[colIndex]; + const lineText = lines?.[lineIndex] || ""; + + const cellContent = columnWithWidth.cell ? ( + columnWithWidth.cell(lineText) + ) : ( + {lineText} + ); + + const padding = " ".repeat( + columnWithWidth.width - lineText.length + 1, + ); + + return ( + + {" "} + {cellContent} + {padding} + {"│"} + + ); + })} + + )); + }), {bottomLine}, ]; diff --git a/packages/cli/src/hooks/use-terminal-dimensions.ts b/packages/cli/src/hooks/use-terminal-dimensions.ts new file mode 100644 index 00000000..d4117b8f --- /dev/null +++ b/packages/cli/src/hooks/use-terminal-dimensions.ts @@ -0,0 +1,23 @@ +import { useStdout } from "ink"; +import { useCallback, useEffect, useState } from "react"; + +export function useTerminalDimensions() { + const { stdout } = useStdout(); + const [dimensions, setDimensions] = useState<[number, number]>([ + stdout.columns, + stdout.rows, + ]); + + const handleResize = useCallback(() => { + setDimensions([stdout.columns, stdout.rows]); + }, [stdout]); + + useEffect(() => { + stdout.on("resize", handleResize); + return () => { + stdout.off("resize", handleResize); + }; + }, [stdout, handleResize]); + + return dimensions; +} diff --git a/packages/cli/src/utils/table-utils.test.ts b/packages/cli/src/utils/table-utils.test.ts new file mode 100644 index 00000000..0c49b1e0 --- /dev/null +++ b/packages/cli/src/utils/table-utils.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { Column } from "../components/table.js"; +import { calculateColumnWidths, splitIntoLines } from "./table-utils.js"; + +describe("table-utils", () => { + describe("calculateColumnWidths", () => { + it("should calculate the correct widths for simple input", () => { + const columns: Column<{ name: string; age: string }>[] = [ + { title: "Name", accessor: "name" }, + { title: "Age", accessor: "age" }, + ]; + const data = [ + { name: "Alice", age: "30" }, + { name: "Bob", age: "25" }, + ]; + const availableSpace = 20; + + const expected = [ + { ...columns[0], width: 5, contentWidth: 5, titleWidth: 4 }, + { ...columns[1], width: 3, contentWidth: 2, titleWidth: 3 }, + ]; + + const result = calculateColumnWidths(columns, data, availableSpace); + + expect(result).toEqual(expected); + }); + + it("should handle empty data", () => { + const columns: Column<{ name: string; age: string }>[] = [ + { title: "Name", accessor: "name" }, + { title: "Age", accessor: "age" }, + ]; + const data: [] = []; + const availableSpace = 20; + + const expected = [ + { ...columns[0], width: 4, contentWidth: 0, titleWidth: 4 }, + { ...columns[1], width: 3, contentWidth: 0, titleWidth: 3 }, + ]; + + const result = calculateColumnWidths(columns, data, availableSpace); + + expect(result).toEqual(expected); + }); + + it("should shrink columns correctly when total width exceeds terminal width", () => { + const columns: Column<{ id: string; username: string; email: string }>[] = + [ + { title: "ID", accessor: "id" }, + { title: "Username", accessor: "username" }, + { title: "Email", accessor: "email" }, + ]; + const data = [ + { id: "1", username: "johnsmith", email: "john@example.com" }, + { id: "2", username: "janedoe", email: "jane@example.com" }, + ]; + const availableSpace = 20; + + const result = calculateColumnWidths(columns, data, availableSpace); + + const totalWidth = result.reduce((acc, column) => acc + column.width, 0); + expect(totalWidth).toBeLessThanOrEqual(availableSpace); + + expect(result[0]?.width).toBe(2); // same as "ID".length + expect(result[1]?.width).toBe(9); // same as "johnsmith".length + expect(result[2]?.width).toBe(9); // fill remaining space + }); + }); + + describe("splitIntoLines", () => { + it("handles empty string", () => { + expect(splitIntoLines("", 10)).toEqual([]); + }); + + it("handles string shorter than width", () => { + const text = "Hello"; + expect(splitIntoLines(text, 10)).toEqual([text]); + }); + + it("splits string at exact width when no spaces near boundary", () => { + const text = "HelloWorld"; + expect(splitIntoLines(text, 5)).toEqual(["Hello", "World"]); + }); + + it("splits string without cutting words", () => { + const text = "Hello beautiful world"; + expect(splitIntoLines(text, 10)).toEqual(["Hello", "beautiful", "world"]); + }); + + it("handles string where width ends at a space", () => { + const text = "Hello beautiful world"; + expect(splitIntoLines(text, 16)).toEqual(["Hello beautiful", "world"]); + }); + + it("cuts to width when no spaces are found", () => { + const text = "HelloBeautifulWorld"; + expect(splitIntoLines(text, 10)).toEqual(["HelloBeaut", "ifulWorld"]); + }); + }); +}); diff --git a/packages/cli/src/utils/table-utils.ts b/packages/cli/src/utils/table-utils.ts new file mode 100644 index 00000000..bb1f76de --- /dev/null +++ b/packages/cli/src/utils/table-utils.ts @@ -0,0 +1,145 @@ +import type { Column } from "../components/table.js"; + +const getContentWidth = >( + column: Column, + data: T[], +) => { + if (data.length === 0) { + return 0; + } + return Math.max(...data.map((row) => (row[column.accessor] ?? "").length)); +}; + +const getTitleWidth = >(column: Column) => + column.title.length; + +const calculateInitialColumnWidth = >( + column: Column, + data: T[], +) => Math.max(getContentWidth(column, data), getTitleWidth(column)); + +const isMaximallyShrunk = >( + column: Column & { width: number; titleWidth: number }, +) => column.width === column.titleWidth; + +const allColumnsShrinked = >( + columns: (Column & { + width: number; + contentWidth: number; + titleWidth: number; + })[], +) => { + return columns.every(isMaximallyShrunk); +}; + +const findWidestColumnUnshrinked = >( + columns: (Column & { + width: number; + contentWidth: number; + titleWidth: number; + })[], +) => { + const unshrinkedColumns = columns + .map((column, index) => ({ + column, + index, + })) + .filter(({ column }) => !isMaximallyShrunk(column)); + + if (!unshrinkedColumns[0]) { + return null; + } + + return unshrinkedColumns.reduce( + (acc, { column, index }) => + column.width > (acc.column?.width ?? 0) ? { column, index } : acc, + { column: unshrinkedColumns[0].column, index: 0 }, + ); +}; + +const shrinkColumns = >( + columns: (Column & { + width: number; + contentWidth: number; + titleWidth: number; + })[], + availableSpace: number, +) => { + const totalColumnWidth = columns.reduce( + (acc, column) => acc + column.width, + 0, + ); + + if (totalColumnWidth <= availableSpace || allColumnsShrinked(columns)) { + return columns; + } + + const widestColumnResult = findWidestColumnUnshrinked(columns); + if (!widestColumnResult) { + return columns; + } + + const { column: widestColumn, index } = widestColumnResult; + + const restColumnsWidth = totalColumnWidth - widestColumn.width; + const remainingSpace = availableSpace - restColumnsWidth; + + const newWidth = (() => { + if (remainingSpace > 0 && widestColumn.titleWidth < remainingSpace) { + return remainingSpace; + } + return widestColumn.titleWidth; + })(); + + return shrinkColumns( + [ + ...columns.slice(0, index), + { + ...widestColumn, + width: newWidth, + }, + ...columns.slice(index + 1), + ], + availableSpace, + ); +}; + +export const calculateColumnWidths = >( + columns: Column[], + data: T[], + availableSpace: number, +): (Column & { width: number })[] => { + const initialColumnWidths = columns.map((column) => ({ + ...column, + contentWidth: getContentWidth(column, data), + titleWidth: getTitleWidth(column), + width: calculateInitialColumnWidth(column, data), + })); + + return shrinkColumns(initialColumnWidths, availableSpace); +}; + +export function splitIntoLines(text: string, width: number): string[] { + const lines: string[] = []; + let startIndex = 0; + + while (startIndex < text.length) { + if (startIndex + width >= text.length) { + lines.push(text.substring(startIndex)); + break; + } + + const endIndex = startIndex + width; + const lastSpace = text.lastIndexOf(" ", endIndex); + + if (lastSpace > startIndex) { + lines.push(text.substring(startIndex, lastSpace)); + startIndex = lastSpace + 1; + } else { + lines.push(text.substring(startIndex, endIndex)); + startIndex = endIndex; + } + } + + return lines; +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index eb10e225..a0cf2add 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["./src"], + entry: ["./src/**/*.ts", "./src/**/*.tsx"], publicDir: "./public", format: ["esm"], dts: true, diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 00000000..0f48dc23 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["./**/*.{test,spec}.?(c|m)[jt]s?(x)"], + coverage: { + reporter: ["text", "lcov"], // lcov for Coveralls + reportsDirectory: "./coverage", + }, + }, +}); diff --git a/packages/core/src/file-utils.ts b/packages/core/src/file-utils.ts index be688571..3025c56e 100644 --- a/packages/core/src/file-utils.ts +++ b/packages/core/src/file-utils.ts @@ -10,8 +10,7 @@ const packageLicenseObjectSchema = z .object({ type: z.string(), url: z.string().optional(), - }) - .transform((license) => license.type); + }; const licenseFieldSchema = z.union([z.string(), packageLicenseObjectSchema]); diff --git a/packages/core/src/license-finder/find-license-in-package-json.test.ts b/packages/core/src/license-finder/find-license-in-package-json.test.ts new file mode 100644 index 00000000..8c3efbfa --- /dev/null +++ b/packages/core/src/license-finder/find-license-in-package-json.test.ts @@ -0,0 +1,69 @@ +import { licenseMap } from "@license-auditor/data"; +import { describe, expect, it } from "vitest"; +import type { PackageJsonType } from "../file-utils.js"; +import { findLicenseInPackageJson } from "./find-license-in-package-json.js"; + +describe("findLicenseInPackageJson", () => { + it("should return license from the 'license' field as a single string", () => { + const packageJson: PackageJsonType = { + name: "test-package", + version: "1.0.0", + license: "MIT", + }; + + const result = findLicenseInPackageJson(packageJson); + + const expectedLicense = licenseMap.get("MIT"); + + expect(result.licenses).toEqual([expectedLicense]); + }); + + it("should return licenses from the 'licenses' field as an array of objects", () => { + const packageJson: PackageJsonType = { + name: "test-package", + version: "1.0.0", + licenses: [ + { + type: "MIT", + url: "https://opensource.org/licenses/MIT", + }, + { + type: "ISC", + url: "https://opensource.org/licenses/ISC", + }, + ], + }; + + const result = findLicenseInPackageJson(packageJson); + + const mitLicense = licenseMap.get("MIT"); + const iscLicense = licenseMap.get("ISC"); + + expect(result.licenses).toEqual([mitLicense, iscLicense]); + }); + + it("should return licenses from the 'licenses' field as an array of strings", () => { + const packageJson: PackageJsonType = { + name: "test-package", + version: "1.0.0", + licenses: ["MIT", "ISC"], + }; + + const result = findLicenseInPackageJson(packageJson); + + const mitLicense = licenseMap.get("MIT"); + const iscLicense = licenseMap.get("ISC"); + + expect(result.licenses).toEqual([mitLicense, iscLicense]); + }); + + it("should return an empty array when neither 'license' nor 'licenses' fields are present", () => { + const packageJson: PackageJsonType = { + name: "test-package", + version: "1.0.0", + }; + + const result = findLicenseInPackageJson(packageJson); + expect(result.licenses).toEqual([]); + }); +}); diff --git a/test/e2e.test.ts b/test/e2e.test.ts index c62e10fb..3687720f 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -79,6 +79,7 @@ describe("license-auditor", () => { "node_modules/testing-license-file", { version: "1.0.0", + license: "", }, [{ name: "LICENSE-MIT", content: "MIT" }], ); @@ -102,6 +103,7 @@ describe("license-auditor", () => { "node_modules/testing-license-file", { version: "1.0.0", + license: "", }, [ { name: "LICENSE-MIT", content: "MIT" }, @@ -131,6 +133,7 @@ describe("license-auditor", () => { "node_modules/testing-license-file", { version: "1.0.0", + license: "", }, [{ name: "LICENSE-WRONG", content: "WRONG" }], ); @@ -157,6 +160,7 @@ describe("license-auditor", () => { "node_modules/testing-license-file", { version: "1.0.0", + license: "", }, [ { name: "LICENSE-MIT", content: "MIT" }, @@ -183,6 +187,7 @@ describe("license-auditor", () => { "node_modules/testing-license-file", { version: "1.0.0", + license: "", }, [ { name: "LICENSE-MIT", content: "MIT" }, diff --git a/test/utils/add-package.ts b/test/utils/add-package.ts index ae9375bf..e50f752d 100644 --- a/test/utils/add-package.ts +++ b/test/utils/add-package.ts @@ -25,7 +25,7 @@ type Package = z.infer; type Details = { version: string; - license?: string; + license: string; dependencies?: Record; };