From f6631d0b8d5fd366e014f6e8fdc7162bcb36644e Mon Sep 17 00:00:00 2001 From: Jan Krivanek Date: Fri, 24 Apr 2026 15:52:17 +0200 Subject: [PATCH] Generate tests in dtslint with Vanilla --- packages/dtslint/src/index-helpers.ts | 120 ++++++++ packages/dtslint/src/index.ts | 118 +------- packages/dtslint/src/lint.ts | 18 +- packages/dtslint/test/checks.test.ts | 199 ++++++++++++++ packages/dtslint/test/createProgram.test.ts | 61 +++++ packages/dtslint/test/index-helpers.test.ts | 258 ++++++++++++++++++ packages/dtslint/test/lint.test.ts | 159 +++++++++++ .../dtslint/test/typescript-installer.test.ts | 28 ++ packages/dtslint/test/util.test.ts | 112 ++++++++ 9 files changed, 958 insertions(+), 115 deletions(-) create mode 100644 packages/dtslint/src/index-helpers.ts create mode 100644 packages/dtslint/test/checks.test.ts create mode 100644 packages/dtslint/test/createProgram.test.ts create mode 100644 packages/dtslint/test/index-helpers.test.ts create mode 100644 packages/dtslint/test/lint.test.ts create mode 100644 packages/dtslint/test/typescript-installer.test.ts create mode 100644 packages/dtslint/test/util.test.ts diff --git a/packages/dtslint/src/index-helpers.ts b/packages/dtslint/src/index-helpers.ts new file mode 100644 index 0000000000..3927306e6c --- /dev/null +++ b/packages/dtslint/src/index-helpers.ts @@ -0,0 +1,120 @@ +import { deepEquals } from "@definitelytyped/utils"; +import fs from "fs"; +import { basename, dirname, join as joinPaths } from "path"; + +/** @internal */ +export function findDTRoot(dirPath: string) { + let path = dirPath; + while (basename(path) !== "types" && dirname(path) !== "." && dirname(path) !== "/" && dirname(path) !== path) { + path = dirname(path); + } + return dirname(path); +} + +/** @internal */ +export function assertPathIsInDefinitelyTyped(dirPath: string, dtRoot: string): void { + // TODO: It's not clear whether this assertion makes sense, and it's broken on Azure Pipelines (perhaps because DT isn't cloned into DefinitelyTyped) + // Re-enable it later if it makes sense. + // if (basename(dtRoot) !== "DefinitelyTyped")) { + if (!fs.existsSync(joinPaths(dtRoot, "types"))) { + throw new Error( + "Since this type definition includes a header (a comment starting with `// Type definitions for`), " + + "assumed this was a DefinitelyTyped package.\n" + + "But it is not in a `DefinitelyTyped/types/xxx` directory: " + + dirPath, + ); + } +} + +/** + * Starting at some point in time, npm has banned all new packages whose names + * contain the word `download`. However, some older packages exist that still + * contain this name. + * @NOTE for contributors: The list of literal exceptions below should ONLY be + * extended with packages for which there already exists a corresponding type + * definition package in the `@types` scope. More information: + * https://github.com/microsoft/DefinitelyTyped-tools/pull/381. + * @internal + */ +export function assertPathIsNotBanned(packageName: string) { + if ( + /(^|\W)download($|\W)/.test(packageName) && + packageName !== "download" && + packageName !== "downloadjs" && + packageName !== "s3-download-stream" + ) { + // Since npm won't release their banned-words list, we'll have to manually add to this list. + throw new Error(`${packageName}: Contains the word 'download', which is banned by npm.`); + } +} + +/** @internal */ +export function combineErrorsAndWarnings(errors: string[], warnings: string[]): Error | string { + const message = errors.concat(warnings).join("\n\n"); + return errors.length ? new Error(message) : message; +} + +/** @internal */ +export function checkExpectedFiles(dirPath: string, isLatest: boolean): { errors: string[] } { + const errors = []; + + if (isLatest) { + const expectedNpmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"]; + + if (basename(dirname(dirPath)) === "types") { + for (const subdir of fs.readdirSync(dirPath, { withFileTypes: true })) { + if (subdir.isDirectory() && /^v(\d+)(\.(\d+))?$/.test(subdir.name)) { + expectedNpmIgnore.push(`/${subdir.name}/`); + } + } + } else { + const thisDir = `/${basename(dirPath)}/`; + const parentNpmIgnorePath = joinPaths(dirname(dirPath), ".npmignore"); + if (!fs.existsSync(parentNpmIgnorePath)) { + errors.push(`${dirPath}: Missing parent '.npmignore'`); + } else { + const parentNpmIgnore = tryReadFileSync(parentNpmIgnorePath)?.trim().split(/\r?\n/); + if (!parentNpmIgnore || !parentNpmIgnore.includes(thisDir)) { + errors.push(`${dirPath}: Parent package '.npmignore' should contain ${thisDir}`); + } + } + } + + const expectedNpmIgnoreAsString = expectedNpmIgnore.join("\n"); + const npmIgnorePath = joinPaths(dirPath, ".npmignore"); + if (!fs.existsSync(npmIgnorePath)) { + errors.push(`${dirPath}: Missing '.npmignore'; should contain:\n${expectedNpmIgnoreAsString}`); + } + + const actualNpmIgnore = tryReadFileSync(npmIgnorePath)?.trim().split(/\r?\n/); + if (!actualNpmIgnore || !deepEquals(actualNpmIgnore, expectedNpmIgnore)) { + errors.push(`${dirPath}: Incorrect '.npmignore'; should be:\n${expectedNpmIgnoreAsString}`); + } + + if (fs.existsSync(joinPaths(dirPath, "OTHER_FILES.txt"))) { + errors.push( + `${dirPath}: Should not contain 'OTHER_FILES.txt'. All files matching "**/*.d.{ts,cts,mts,*.ts}" are automatically included.`, + ); + } + } + + if (!fs.existsSync(joinPaths(dirPath, "index.d.ts"))) { + errors.push(`${dirPath}: Must contain 'index.d.ts'.`); + } + + if (fs.existsSync(joinPaths(dirPath, "tslint.json"))) { + errors.push( + `${dirPath}: Should not contain 'tslint.json'. This file is no longer required; place all lint-related options into .eslintrc.json.`, + ); + } + + return { errors }; +} + +function tryReadFileSync(path: string): string | undefined { + try { + return fs.readFileSync(path, "utf-8"); + } catch { + return undefined; + } +} diff --git a/packages/dtslint/src/index.ts b/packages/dtslint/src/index.ts index 64ca690d68..39a50f85bc 100644 --- a/packages/dtslint/src/index.ts +++ b/packages/dtslint/src/index.ts @@ -2,15 +2,22 @@ import { getTypesVersions } from "@definitelytyped/header-parser"; import { AllTypeScriptVersion, TypeScriptVersion } from "@definitelytyped/typescript-versions"; -import { deepEquals, readJson } from "@definitelytyped/utils"; +import { readJson } from "@definitelytyped/utils"; import fs from "fs"; -import { basename, dirname, join as joinPaths, resolve } from "path"; +import { join as joinPaths, resolve } from "path"; import { checkNpmVersionAndGetMatchingImplementationPackage, checkPackageJson, checkTsconfig, runAreTheTypesWrong, } from "./checks"; +import { + assertPathIsInDefinitelyTyped, + assertPathIsNotBanned, + checkExpectedFiles, + combineErrorsAndWarnings, + findDTRoot, +} from "./index-helpers"; import { TsVersion, lint } from "./lint"; import { getCompilerOptions, packageDirectoryNameWithVersionFromPath, packageNameFromPath } from "./util"; import assert = require("assert"); @@ -261,10 +268,6 @@ async function runTests( return result; } -function combineErrorsAndWarnings(errors: string[], warnings: string[]): Error | string { - const message = errors.concat(warnings).join("\n\n"); - return errors.length ? new Error(message) : message; -} function maxVersion(v1: AllTypeScriptVersion, v2: TypeScriptVersion): TypeScriptVersion { // Note: For v1 to be later than v2, it must be a current Typescript version. So the type assertion is safe. @@ -306,48 +309,6 @@ async function testTypesVersion( return { errors }; } -function findDTRoot(dirPath: string) { - let path = dirPath; - while (basename(path) !== "types" && dirname(path) !== "." && dirname(path) !== "/") { - path = dirname(path); - } - return dirname(path); -} - -function assertPathIsInDefinitelyTyped(dirPath: string, dtRoot: string): void { - // TODO: It's not clear whether this assertion makes sense, and it's broken on Azure Pipelines (perhaps because DT isn't cloned into DefinitelyTyped) - // Re-enable it later if it makes sense. - // if (basename(dtRoot) !== "DefinitelyTyped")) { - if (!fs.existsSync(joinPaths(dtRoot, "types"))) { - throw new Error( - "Since this type definition includes a header (a comment starting with `// Type definitions for`), " + - "assumed this was a DefinitelyTyped package.\n" + - "But it is not in a `DefinitelyTyped/types/xxx` directory: " + - dirPath, - ); - } -} - -/** - * Starting at some point in time, npm has banned all new packages whose names - * contain the word `download`. However, some older packages exist that still - * contain this name. - * @NOTE for contributors: The list of literal exceptions below should ONLY be - * extended with packages for which there already exists a corresponding type - * definition package in the `@types` scope. More information: - * https://github.com/microsoft/DefinitelyTyped-tools/pull/381. - */ -function assertPathIsNotBanned(packageName: string) { - if ( - /(^|\W)download($|\W)/.test(packageName) && - packageName !== "download" && - packageName !== "downloadjs" && - packageName !== "s3-download-stream" - ) { - // Since npm won't release their banned-words list, we'll have to manually add to this list. - throw new Error(`${packageName}: Contains the word 'download', which is banned by npm.`); - } -} export function assertPackageIsNotDeprecated(packageName: string, notNeededPackages: string) { const unneeded = JSON.parse(notNeededPackages).packages; @@ -365,66 +326,5 @@ if (require.main === module) { }); } -function checkExpectedFiles(dirPath: string, isLatest: boolean): { errors: string[] } { - const errors = []; - - if (isLatest) { - const expectedNpmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"]; - - if (basename(dirname(dirPath)) === "types") { - for (const subdir of fs.readdirSync(dirPath, { withFileTypes: true })) { - if (subdir.isDirectory() && /^v(\d+)(\.(\d+))?$/.test(subdir.name)) { - expectedNpmIgnore.push(`/${subdir.name}/`); - } - } - } else { - const thisDir = `/${basename(dirPath)}/`; - const parentNpmIgnorePath = joinPaths(dirname(dirPath), ".npmignore"); - if (!fs.existsSync(parentNpmIgnorePath)) { - errors.push(`${dirPath}: Missing parent '.npmignore'`); - } else { - const parentNpmIgnore = tryReadFileSync(parentNpmIgnorePath)?.trim().split(/\r?\n/); - if (!parentNpmIgnore || !parentNpmIgnore.includes(thisDir)) { - errors.push(`${dirPath}: Parent package '.npmignore' should contain ${thisDir}`); - } - } - } - - const expectedNpmIgnoreAsString = expectedNpmIgnore.join("\n"); - const npmIgnorePath = joinPaths(dirPath, ".npmignore"); - if (!fs.existsSync(npmIgnorePath)) { - errors.push(`${dirPath}: Missing '.npmignore'; should contain:\n${expectedNpmIgnoreAsString}`); - } - - const actualNpmIgnore = tryReadFileSync(npmIgnorePath)?.trim().split(/\r?\n/); - if (!actualNpmIgnore || !deepEquals(actualNpmIgnore, expectedNpmIgnore)) { - errors.push(`${dirPath}: Incorrect '.npmignore'; should be:\n${expectedNpmIgnoreAsString}`); - } - - if (fs.existsSync(joinPaths(dirPath, "OTHER_FILES.txt"))) { - errors.push( - `${dirPath}: Should not contain 'OTHER_FILES.txt'. All files matching "**/*.d.{ts,cts,mts,*.ts}" are automatically included.`, - ); - } - } - - if (!fs.existsSync(joinPaths(dirPath, "index.d.ts"))) { - errors.push(`${dirPath}: Must contain 'index.d.ts'.`); - } - - if (fs.existsSync(joinPaths(dirPath, "tslint.json"))) { - errors.push( - `${dirPath}: Should not contain 'tslint.json'. This file is no longer required; place all lint-related options into .eslintrc.json.`, - ); - } - return { errors }; -} -function tryReadFileSync(path: string): string | undefined { - try { - return fs.readFileSync(path, "utf-8"); - } catch { - return undefined; - } -} diff --git a/packages/dtslint/src/lint.ts b/packages/dtslint/src/lint.ts index e560b21290..aef55be84c 100644 --- a/packages/dtslint/src/lint.ts +++ b/packages/dtslint/src/lint.ts @@ -136,31 +136,36 @@ export function isExternalDependency(file: TsType.SourceFile, dirPath: string, p return !startsWithDirectory(file.fileName, dirPath) || program.isSourceFileFromExternalLibrary(file); } -function normalizePath(file: string) { +/** @internal */ +export function normalizePath(file: string) { // replaces '\' with '/' and forces all DOS drive letters to be upper-case return normalize(file) .replace(/\\/g, "/") .replace(/^[a-z](?=:)/, (c) => c.toUpperCase()); } -function isTypesVersionPath(fileName: string, dirPath: string) { +/** @internal */ +export function isTypesVersionPath(fileName: string, dirPath: string) { const normalFileName = normalizePath(fileName); const normalDirPath = normalizePath(dirPath); const subdirPath = withoutStart(normalFileName, normalDirPath); return subdirPath && /^\/ts\d+\.\d/.test(subdirPath); } -function startsWithDirectory(filePath: string, dirPath: string): boolean { +/** @internal */ +export function startsWithDirectory(filePath: string, dirPath: string): boolean { const normalFilePath = normalizePath(filePath); const normalDirPath = normalizePath(dirPath).replace(/\/$/, ""); return normalFilePath.startsWith(normalDirPath + "/") || normalFilePath.startsWith(normalDirPath + "\\"); } -interface Err { +/** @internal */ +export interface Err { pos: number; message: string; } -function testNoLintDisables(text: string): Err | undefined { +/** @internal */ +export function testNoLintDisables(text: string): Err | undefined { const disabler = "eslint-disable"; let lastIndex = 0; while (true) { @@ -182,7 +187,8 @@ function testNoLintDisables(text: string): Err | undefined { } } -function range(minVersion: TsVersion, maxVersion: TsVersion): readonly TsVersion[] { +/** @internal */ +export function range(minVersion: TsVersion, maxVersion: TsVersion): readonly TsVersion[] { if (minVersion === "local") { assert(maxVersion === "local"); return ["local"]; diff --git a/packages/dtslint/test/checks.test.ts b/packages/dtslint/test/checks.test.ts new file mode 100644 index 0000000000..645cce0936 --- /dev/null +++ b/packages/dtslint/test/checks.test.ts @@ -0,0 +1,199 @@ +/// +import { CompilerOptionsRaw, checkTsconfig } from "../src/checks"; + +describe("checks - additional coverage", () => { + const base: CompilerOptionsRaw = { + module: "commonjs", + lib: ["es6"], + noImplicitAny: true, + noImplicitThis: true, + strictNullChecks: true, + strictFunctionTypes: true, + types: [], + noEmit: true, + forceConsistentCasingInFileNames: true, + }; + function based(extra: object) { + return { compilerOptions: { ...base, ...extra }, files: ["index.d.ts", "base.test.ts"] }; + } + + describe("checkTsconfig - mustHave validations", () => { + it("errors when noEmit is false", () => { + const errors = checkTsconfig(based({ noEmit: false })); + expect(errors).toContainEqual(expect.stringContaining("noEmit")); + }); + + it("errors when noEmit is missing", () => { + const opts = { ...base }; + delete (opts as any).noEmit; + const errors = checkTsconfig({ compilerOptions: opts, files: ["index.d.ts", "test.ts"] }); + expect(errors).toContainEqual(expect.stringContaining("noEmit")); + }); + + it("errors when forceConsistentCasingInFileNames is false", () => { + const errors = checkTsconfig(based({ forceConsistentCasingInFileNames: false })); + expect(errors).toContainEqual(expect.stringContaining("forceConsistentCasingInFileNames")); + }); + + it("errors when types is not empty array", () => { + const errors = checkTsconfig(based({ types: ["node"] })); + expect(errors).toContainEqual(expect.stringContaining("types")); + }); + }); + + describe("checkTsconfig - strict mode", () => { + it("allows strict: true without individual strict options", () => { + const compilerOptions = { + module: "commonjs", + lib: ["es6"], + strict: true, + types: [], + noEmit: true, + forceConsistentCasingInFileNames: true, + }; + const errors = checkTsconfig({ compilerOptions, files: ["index.d.ts", "test.ts"] }); + expect(errors).toEqual([]); + }); + + it("errors when strict is false", () => { + const compilerOptions = { + module: "commonjs", + lib: ["es6"], + strict: false, + types: [], + noEmit: true, + forceConsistentCasingInFileNames: true, + }; + const errors = checkTsconfig({ compilerOptions, files: ["index.d.ts", "test.ts"] }); + expect(errors).toContainEqual(expect.stringContaining('When "strict" is present, it must be set to `true`.')); + }); + + it("throws when strict is true and noImplicitAny is also set", () => { + const compilerOptions = { + module: "commonjs", + lib: ["es6"], + strict: true, + noImplicitAny: true, + types: [], + noEmit: true, + forceConsistentCasingInFileNames: true, + }; + expect(() => checkTsconfig({ compilerOptions, files: ["index.d.ts", "test.ts"] })).toThrow( + '"noImplicitAny" to not be set when "strict" is `true`', + ); + }); + + it("throws when strict is true and strictNullChecks is also set", () => { + const compilerOptions = { + module: "commonjs", + lib: ["es6"], + strict: true, + strictNullChecks: true, + types: [], + noEmit: true, + forceConsistentCasingInFileNames: true, + }; + expect(() => checkTsconfig({ compilerOptions, files: ["index.d.ts", "test.ts"] })).toThrow( + '"strictNullChecks" to not be set when "strict" is `true`', + ); + }); + + it("errors when not strict and noImplicitAny is missing", () => { + const opts = { ...base }; + delete (opts as any).noImplicitAny; + const errors = checkTsconfig({ compilerOptions: opts, files: ["index.d.ts", "test.ts"] }); + expect(errors).toContainEqual(expect.stringContaining('"noImplicitAny": true')); + }); + + it("errors when not strict and strictFunctionTypes is missing", () => { + const opts = { ...base }; + delete (opts as any).strictFunctionTypes; + const errors = checkTsconfig({ compilerOptions: opts, files: ["index.d.ts", "test.ts"] }); + expect(errors).toContainEqual(expect.stringContaining('"strictFunctionTypes": true')); + }); + }); + + describe("checkTsconfig - module values", () => { + it("errors for module: 'amd'", () => { + const errors = checkTsconfig(based({ module: "amd" })); + expect(errors).toContainEqual( + expect.stringContaining('When "module" is present, it must be set to "commonjs" or "node16"'), + ); + }); + + it("errors for module: 'esnext'", () => { + const errors = checkTsconfig(based({ module: "esnext" })); + expect(errors).toContainEqual( + expect.stringContaining('When "module" is present, it must be set to "commonjs" or "node16"'), + ); + }); + + it("allows module: 'commonjs' (case-insensitive)", () => { + const errors = checkTsconfig(based({ module: "CommonJS" })); + expect(errors).toEqual([]); + }); + + it("allows module: 'Node16' (case-insensitive)", () => { + const errors = checkTsconfig(based({ module: "Node16" })); + expect(errors).toEqual([]); + }); + }); + + describe("checkTsconfig - lib", () => { + it("errors when lib is missing", () => { + const opts = { ...base }; + delete (opts as any).lib; + const errors = checkTsconfig({ compilerOptions: opts, files: ["index.d.ts", "test.ts"] }); + expect(errors).toContainEqual(expect.stringContaining('Must specify "lib"')); + }); + }); + + describe("checkTsconfig - types with entries", () => { + it("errors when types array has entries", () => { + const errors = checkTsconfig(based({ types: ["node", "jest"] })); + expect(errors).toContainEqual(expect.stringContaining("reference types")); + }); + }); + + describe("checkTsconfig - allowed optional options", () => { + it("allows target", () => { + expect(checkTsconfig(based({ target: "es2020" }))).toEqual([]); + }); + + it("allows jsx", () => { + expect(checkTsconfig(based({ jsx: "react" }))).toEqual([]); + }); + + it("allows jsxFactory", () => { + expect(checkTsconfig(based({ jsxFactory: "h" }))).toEqual([]); + }); + + it("allows jsxImportSource", () => { + expect(checkTsconfig(based({ jsxImportSource: "preact" }))).toEqual([]); + }); + + it("allows experimentalDecorators", () => { + expect(checkTsconfig(based({ experimentalDecorators: true }))).toEqual([]); + }); + + it("allows noUnusedLocals", () => { + expect(checkTsconfig(based({ noUnusedLocals: true }))).toEqual([]); + }); + + it("allows noUnusedParameters", () => { + expect(checkTsconfig(based({ noUnusedParameters: true }))).toEqual([]); + }); + + it("allows esModuleInterop", () => { + expect(checkTsconfig(based({ esModuleInterop: true }))).toEqual([]); + }); + + it("allows allowSyntheticDefaultImports", () => { + expect(checkTsconfig(based({ allowSyntheticDefaultImports: true }))).toEqual([]); + }); + + it("allows noUncheckedIndexedAccess", () => { + expect(checkTsconfig(based({ noUncheckedIndexedAccess: true }))).toEqual([]); + }); + }); +}); diff --git a/packages/dtslint/test/createProgram.test.ts b/packages/dtslint/test/createProgram.test.ts new file mode 100644 index 0000000000..36ed467ba7 --- /dev/null +++ b/packages/dtslint/test/createProgram.test.ts @@ -0,0 +1,61 @@ +/// +import { createProgram } from "../src/createProgram"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +describe("createProgram", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dtslint-createprogram-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("creates a program from a valid tsconfig", () => { + const indexFile = path.join(tmpDir, "index.d.ts"); + fs.writeFileSync(indexFile, "export declare const x: number;"); + const tsconfigPath = path.join(tmpDir, "tsconfig.json"); + fs.writeFileSync( + tsconfigPath, + JSON.stringify({ + compilerOptions: { strict: true, noEmit: true }, + files: ["index.d.ts"], + }), + ); + + const program = createProgram(tsconfigPath); + expect(program).toBeDefined(); + const sourceFiles = program.getSourceFiles(); + const userFiles = sourceFiles.filter((f) => !program.isSourceFileDefaultLibrary(f)); + expect(userFiles.length).toBeGreaterThanOrEqual(1); + expect(userFiles.some((f) => f.fileName.includes("index.d.ts"))).toBe(true); + }); + + it("throws for a tsconfig with read errors", () => { + expect(() => createProgram(path.join(tmpDir, "nonexistent.json"))).toThrow(); + }); + + it("throws for a tsconfig with invalid JSON", () => { + const tsconfigPath = path.join(tmpDir, "tsconfig.json"); + fs.writeFileSync(tsconfigPath, "{ invalid json }}}"); + expect(() => createProgram(tsconfigPath)).toThrow(); + }); + + it("ignores TS18003 (no inputs found) warning", () => { + const tsconfigPath = path.join(tmpDir, "tsconfig.json"); + fs.writeFileSync( + tsconfigPath, + JSON.stringify({ + compilerOptions: { strict: true, noEmit: true }, + }), + ); + + // Should not throw despite no input files + const program = createProgram(tsconfigPath); + expect(program).toBeDefined(); + }); +}); diff --git a/packages/dtslint/test/index-helpers.test.ts b/packages/dtslint/test/index-helpers.test.ts new file mode 100644 index 0000000000..16e3ea2ea6 --- /dev/null +++ b/packages/dtslint/test/index-helpers.test.ts @@ -0,0 +1,258 @@ +/// +import { + findDTRoot, + assertPathIsNotBanned, + combineErrorsAndWarnings, + assertPathIsInDefinitelyTyped, + checkExpectedFiles, +} from "../src/index-helpers"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +describe("index-helpers", () => { + describe("findDTRoot", () => { + it("finds root for a path with /types/ directory", () => { + // findDTRoot walks up until basename is "types", then returns dirname + const result = findDTRoot("/home/user/DefinitelyTyped/types/jquery"); + expect(result).toBe("/home/user/DefinitelyTyped"); + }); + + it("finds root for a versioned subpackage path", () => { + const result = findDTRoot("/home/user/DefinitelyTyped/types/react/v16"); + expect(result).toBe("/home/user/DefinitelyTyped"); + }); + + it("returns parent of '.' for non-types path (stops at '.')", () => { + // When no 'types' directory is found, it stops when dirname is "." or "/" + const result = findDTRoot("some/random/path"); + expect(result).toBe("."); + }); + }); + + describe("assertPathIsNotBanned", () => { + it("allows regular package names", () => { + expect(() => assertPathIsNotBanned("lodash")).not.toThrow(); + }); + + it("allows 'download' (exempted)", () => { + expect(() => assertPathIsNotBanned("download")).not.toThrow(); + }); + + it("allows 'downloadjs' (exempted)", () => { + expect(() => assertPathIsNotBanned("downloadjs")).not.toThrow(); + }); + + it("allows 's3-download-stream' (exempted)", () => { + expect(() => assertPathIsNotBanned("s3-download-stream")).not.toThrow(); + }); + + it("bans packages containing 'download' as a word boundary", () => { + expect(() => assertPathIsNotBanned("my-download-tool")).toThrow("banned by npm"); + }); + + it("bans packages starting with 'download-'", () => { + expect(() => assertPathIsNotBanned("download-helper")).toThrow("banned by npm"); + }); + + it("bans packages ending with '-download'", () => { + expect(() => assertPathIsNotBanned("file-download")).toThrow("banned by npm"); + }); + + it("allows packages where 'download' is part of a larger word", () => { + // 'downloads' ends with a word character, so ($|\W) won't match for the 's' + // But actually /(^|\W)download($|\W)/ checks for word boundaries + // 'mydownloader' - 'download' is embedded, but preceded by 'y' (a word char), so (^|\W) fails + expect(() => assertPathIsNotBanned("mydownloader")).not.toThrow(); + }); + }); + + describe("combineErrorsAndWarnings", () => { + it("returns empty string when no errors and no warnings", () => { + const result = combineErrorsAndWarnings([], []); + expect(result).toBe(""); + }); + + it("returns warning string when no errors", () => { + const result = combineErrorsAndWarnings([], ["warning1", "warning2"]); + expect(typeof result).toBe("string"); + expect(result as string).toContain("warning1"); + expect(result as string).toContain("warning2"); + }); + + it("returns Error when there are errors", () => { + const result = combineErrorsAndWarnings(["error1"], []); + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toContain("error1"); + }); + + it("returns Error with both errors and warnings", () => { + const result = combineErrorsAndWarnings(["error1"], ["warning1"]); + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toContain("error1"); + expect((result as Error).message).toContain("warning1"); + }); + + it("separates messages with double newlines", () => { + const result = combineErrorsAndWarnings(["err1", "err2"], []); + expect((result as Error).message).toBe("err1\n\nerr2"); + }); + }); + + describe("assertPathIsInDefinitelyTyped", () => { + it("throws when types directory does not exist under dtRoot", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dtslint-test-")); + try { + expect(() => assertPathIsInDefinitelyTyped("/some/path", tmpDir)).toThrow( + "not in a `DefinitelyTyped/types/xxx` directory", + ); + } finally { + fs.rmdirSync(tmpDir); + } + }); + + it("does not throw when types directory exists under dtRoot", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dtslint-test-")); + const typesDir = path.join(tmpDir, "types"); + fs.mkdirSync(typesDir); + try { + expect(() => assertPathIsInDefinitelyTyped("/some/path", tmpDir)).not.toThrow(); + } finally { + fs.rmdirSync(typesDir); + fs.rmdirSync(tmpDir); + } + }); + }); + + describe("checkExpectedFiles", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dtslint-expected-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("errors when index.d.ts is missing (isLatest=false)", () => { + const result = checkExpectedFiles(tmpDir, false); + expect(result.errors).toContainEqual(expect.stringContaining("Must contain 'index.d.ts'")); + }); + + it("no errors for missing index.d.ts when index.d.ts exists (isLatest=false)", () => { + fs.writeFileSync(path.join(tmpDir, "index.d.ts"), "export {};"); + const result = checkExpectedFiles(tmpDir, false); + expect(result.errors.filter((e) => e.includes("index.d.ts"))).toEqual([]); + }); + + it("errors when tslint.json exists", () => { + fs.writeFileSync(path.join(tmpDir, "index.d.ts"), "export {};"); + fs.writeFileSync(path.join(tmpDir, "tslint.json"), "{}"); + const result = checkExpectedFiles(tmpDir, false); + expect(result.errors).toContainEqual(expect.stringContaining("Should not contain 'tslint.json'")); + }); + + it("errors when .npmignore is missing (isLatest=true, under types/)", () => { + // Create a structure: /types/mypackage/ + const typesDir = path.join(tmpDir, "types"); + fs.mkdirSync(typesDir); + const pkgDir = path.join(typesDir, "mypackage"); + fs.mkdirSync(pkgDir); + fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};"); + + const result = checkExpectedFiles(pkgDir, true); + expect(result.errors).toContainEqual(expect.stringContaining("Missing '.npmignore'")); + }); + + it("errors when .npmignore is incorrect (isLatest=true, under types/)", () => { + const typesDir = path.join(tmpDir, "types"); + fs.mkdirSync(typesDir); + const pkgDir = path.join(typesDir, "mypackage"); + fs.mkdirSync(pkgDir); + fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};"); + fs.writeFileSync(path.join(pkgDir, ".npmignore"), "wrong content"); + + const result = checkExpectedFiles(pkgDir, true); + expect(result.errors).toContainEqual(expect.stringContaining("Incorrect '.npmignore'")); + }); + + it("no .npmignore errors when correct (isLatest=true, under types/)", () => { + const typesDir = path.join(tmpDir, "types"); + fs.mkdirSync(typesDir); + const pkgDir = path.join(typesDir, "mypackage"); + fs.mkdirSync(pkgDir); + fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};"); + const npmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"].join("\n"); + fs.writeFileSync(path.join(pkgDir, ".npmignore"), npmIgnore); + + const result = checkExpectedFiles(pkgDir, true); + const npmIgnoreErrors = result.errors.filter((e) => e.includes(".npmignore")); + expect(npmIgnoreErrors).toEqual([]); + }); + + it("includes version directories in expected .npmignore", () => { + const typesDir = path.join(tmpDir, "types"); + fs.mkdirSync(typesDir); + const pkgDir = path.join(typesDir, "mypackage"); + fs.mkdirSync(pkgDir); + fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};"); + fs.mkdirSync(path.join(pkgDir, "v16")); + // The .npmignore should include /v16/ + const npmIgnoreWithVersion = [ + "*", + "!**/*.d.ts", + "!**/*.d.cts", + "!**/*.d.mts", + "!**/*.d.*.ts", + "/v16/", + ].join("\n"); + fs.writeFileSync(path.join(pkgDir, ".npmignore"), npmIgnoreWithVersion); + + const result = checkExpectedFiles(pkgDir, true); + const npmIgnoreErrors = result.errors.filter((e) => e.includes(".npmignore")); + expect(npmIgnoreErrors).toEqual([]); + }); + + it("errors when OTHER_FILES.txt exists (isLatest=true)", () => { + const typesDir = path.join(tmpDir, "types"); + fs.mkdirSync(typesDir); + const pkgDir = path.join(typesDir, "mypackage"); + fs.mkdirSync(pkgDir); + fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export {};"); + const npmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"].join("\n"); + fs.writeFileSync(path.join(pkgDir, ".npmignore"), npmIgnore); + fs.writeFileSync(path.join(pkgDir, "OTHER_FILES.txt"), "some files"); + + const result = checkExpectedFiles(pkgDir, true); + expect(result.errors).toContainEqual(expect.stringContaining("OTHER_FILES.txt")); + }); + + it("checks parent .npmignore for versioned subdirectory (isLatest=true)", () => { + // Structure: /mypackage/v16/ (parent is not "types") + const pkgDir = path.join(tmpDir, "mypackage"); + fs.mkdirSync(pkgDir); + const versionDir = path.join(pkgDir, "v16"); + fs.mkdirSync(versionDir); + fs.writeFileSync(path.join(versionDir, "index.d.ts"), "export {};"); + // No parent .npmignore + const result = checkExpectedFiles(versionDir, true); + expect(result.errors).toContainEqual(expect.stringContaining("Missing parent '.npmignore'")); + }); + + it("errors when parent .npmignore doesn't contain versioned dir entry", () => { + const pkgDir = path.join(tmpDir, "mypackage"); + fs.mkdirSync(pkgDir); + const versionDir = path.join(pkgDir, "v16"); + fs.mkdirSync(versionDir); + fs.writeFileSync(path.join(versionDir, "index.d.ts"), "export {};"); + const npmIgnore = ["*", "!**/*.d.ts", "!**/*.d.cts", "!**/*.d.mts", "!**/*.d.*.ts"].join("\n"); + fs.writeFileSync(path.join(versionDir, ".npmignore"), npmIgnore); + // Parent .npmignore without /v16/ entry + fs.writeFileSync(path.join(pkgDir, ".npmignore"), "* \n!**/*.d.ts"); + + const result = checkExpectedFiles(versionDir, true); + expect(result.errors).toContainEqual(expect.stringContaining("should contain /v16/")); + }); + }); +}); diff --git a/packages/dtslint/test/lint.test.ts b/packages/dtslint/test/lint.test.ts new file mode 100644 index 0000000000..1826a8e1e0 --- /dev/null +++ b/packages/dtslint/test/lint.test.ts @@ -0,0 +1,159 @@ +/// +import { testNoLintDisables, normalizePath, isTypesVersionPath, startsWithDirectory, range } from "../src/lint"; +import { TypeScriptVersion } from "@definitelytyped/typescript-versions"; + +describe("lint", () => { + describe("normalizePath", () => { + it("replaces backslashes with forward slashes", () => { + expect(normalizePath("C:\\Users\\test\\file.ts")).toContain("/"); + expect(normalizePath("C:\\Users\\test\\file.ts")).not.toContain("\\"); + }); + + it("uppercases drive letters", () => { + const result = normalizePath("c:/Users/test/file.ts"); + expect(result).toMatch(/^C:/); + }); + + it("keeps already-uppercased drive letters", () => { + const result = normalizePath("C:/Users/test/file.ts"); + expect(result).toMatch(/^C:/); + }); + + it("handles Unix-style paths", () => { + const result = normalizePath("/home/user/file.ts"); + expect(result).toBe("/home/user/file.ts"); + }); + }); + + describe("isTypesVersionPath", () => { + it("returns truthy for ts-versioned subdirectory", () => { + expect(isTypesVersionPath("/types/react/ts3.6/index.d.ts", "/types/react")).toBeTruthy(); + }); + + it("returns truthy for ts5.0 subdirectory", () => { + expect(isTypesVersionPath("/types/node/ts5.0/index.d.ts", "/types/node")).toBeTruthy(); + }); + + it("returns falsy for non-ts-versioned path", () => { + expect(isTypesVersionPath("/types/react/index.d.ts", "/types/react")).toBeFalsy(); + }); + + it("returns falsy for file not under dirPath", () => { + expect(isTypesVersionPath("/types/other/ts3.6/index.d.ts", "/types/react")).toBeFalsy(); + }); + + it("returns falsy for non-matching version pattern", () => { + expect(isTypesVersionPath("/types/react/v16/index.d.ts", "/types/react")).toBeFalsy(); + }); + }); + + describe("startsWithDirectory", () => { + it("returns true when file is in directory", () => { + expect(startsWithDirectory("/types/react/index.d.ts", "/types/react")).toBe(true); + }); + + it("returns false when file is not in directory", () => { + expect(startsWithDirectory("/types/other/index.d.ts", "/types/react")).toBe(false); + }); + + it("returns false for partial directory name match", () => { + expect(startsWithDirectory("/types/react-native/index.d.ts", "/types/react")).toBe(false); + }); + + it("handles trailing slash in dirPath", () => { + expect(startsWithDirectory("/types/react/index.d.ts", "/types/react/")).toBe(true); + }); + + it("handles Windows-style paths", () => { + expect(startsWithDirectory("C:\\types\\react\\index.d.ts", "C:\\types\\react")).toBe(true); + }); + }); + + describe("testNoLintDisables", () => { + it("returns undefined for text without eslint-disable", () => { + expect(testNoLintDisables("const x = 1;\nconst y = 2;")).toBeUndefined(); + }); + + it("allows eslint-disable-line", () => { + expect(testNoLintDisables("const x = 1; // eslint-disable-line")).toBeUndefined(); + }); + + it("allows eslint-disable-next-line", () => { + expect(testNoLintDisables("// eslint-disable-next-line\nconst x = 1;")).toBeUndefined(); + }); + + it("allows eslint-disable with specific rule (space then rulename)", () => { + expect(testNoLintDisables("// eslint-disable no-console")).toBeUndefined(); + }); + + it("forbids bare eslint-disable (end of string)", () => { + const result = testNoLintDisables("// eslint-disable"); + expect(result).toBeDefined(); + expect(result!.message).toContain("forbidden"); + }); + + it("forbids eslint-disable with newline immediately after", () => { + const result = testNoLintDisables("// eslint-disable\nconst x = 1;"); + expect(result).toBeDefined(); + expect(result!.message).toContain("forbidden"); + }); + + it("forbids eslint-disable followed by space and star (wildcard)", () => { + const result = testNoLintDisables("/* eslint-disable */"); + expect(result).toBeDefined(); + expect(result!.message).toContain("forbidden"); + }); + + it("reports correct position for eslint-disable", () => { + const text = "const x = 1;\n/* eslint-disable */"; + const result = testNoLintDisables(text); + expect(result).toBeDefined(); + expect(result!.pos).toBe(text.indexOf("eslint-disable")); + }); + + it("handles multiple eslint-disable occurrences, first allowed, second forbidden", () => { + const text = "// eslint-disable-line\n/* eslint-disable */"; + const result = testNoLintDisables(text); + expect(result).toBeDefined(); + expect(result!.pos).toBe(text.lastIndexOf("eslint-disable")); + }); + }); + + describe("range", () => { + it("returns ['local'] when both min and max are 'local'", () => { + expect(range("local", "local")).toEqual(["local"]); + }); + + it("returns [latest] when both min and max are latest", () => { + expect(range(TypeScriptVersion.latest, TypeScriptVersion.latest)).toEqual([TypeScriptVersion.latest]); + }); + + it("returns supported versions from min to max inclusive", () => { + const supported = TypeScriptVersion.supported; + if (supported.length >= 2) { + const min = supported[0]; + const max = supported[1]; + const result = range(min, max); + expect(result).toContain(min); + expect(result).toContain(max); + expect(result.length).toBe(2); + } + }); + + it("returns versions from min through latest when max is latest", () => { + const supported = TypeScriptVersion.supported; + const min = supported[0]; + const result = range(min, TypeScriptVersion.latest); + expect(result[0]).toBe(min); + expect(result[result.length - 1]).toBe(TypeScriptVersion.latest); + // Should include all supported versions plus latest + expect(result.length).toBe(supported.length + 1); + }); + + it("returns single element when min equals max in supported", () => { + const v = TypeScriptVersion.supported[0]; + const result = range(v, v); + expect(result).toEqual([v]); + }); + }); +}); diff --git a/packages/dtslint/test/typescript-installer.test.ts b/packages/dtslint/test/typescript-installer.test.ts new file mode 100644 index 0000000000..fd4a427b69 --- /dev/null +++ b/packages/dtslint/test/typescript-installer.test.ts @@ -0,0 +1,28 @@ +/// +import { typeScriptPath } from "../src/typescript-installer"; +import * as typeScriptPackages from "@definitelytyped/typescript-packages"; +import { TypeScriptVersion } from "@definitelytyped/typescript-versions"; + +describe("typescript-installer", () => { + describe("typeScriptPath", () => { + it("returns local path with /typescript.js appended when version is 'local'", () => { + const result = typeScriptPath("local", "/my/local/ts"); + expect(result).toBe("/my/local/ts/typescript.js"); + }); + + it("returns resolved path for a supported TypeScript version", () => { + const version = TypeScriptVersion.supported[0]; + const result = typeScriptPath(version, undefined); + // Should return a path resolved by typeScriptPackages + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("returns the same result as typeScriptPackages.resolve for non-local versions", () => { + const version = TypeScriptVersion.latest; + const expected = typeScriptPackages.resolve(version); + const result = typeScriptPath(version, undefined); + expect(result).toBe(expected); + }); + }); +}); diff --git a/packages/dtslint/test/util.test.ts b/packages/dtslint/test/util.test.ts new file mode 100644 index 0000000000..59d64a5849 --- /dev/null +++ b/packages/dtslint/test/util.test.ts @@ -0,0 +1,112 @@ +/// +import { packageNameFromPath, packageDirectoryNameWithVersionFromPath, getCompilerOptions } from "../src/util"; +import fs from "fs"; +import path from "path"; + +describe("util", () => { + describe("packageNameFromPath", () => { + it("returns basename for regular package path", () => { + expect(packageNameFromPath("/home/user/DefinitelyTyped/types/jquery")).toBe("jquery"); + }); + + it("returns basename for Windows-style path", () => { + expect(packageNameFromPath("C:\\DefinitelyTyped\\types\\lodash")).toBe("lodash"); + }); + + it("returns parent name for versioned directory (v2)", () => { + expect(packageNameFromPath("/home/user/DefinitelyTyped/types/react/v16")).toBe("react"); + }); + + it("returns parent name for versioned directory with minor (v0.5)", () => { + expect(packageNameFromPath("/home/user/DefinitelyTyped/types/express/v0.5")).toBe("express"); + }); + + it("returns parent name for ts-versioned directory (ts3.6)", () => { + expect(packageNameFromPath("/home/user/DefinitelyTyped/types/node/ts3.6")).toBe("node"); + }); + + it("returns parent name for ts-versioned directory (ts5.0)", () => { + expect(packageNameFromPath("/home/user/DefinitelyTyped/types/node/ts5.0")).toBe("node"); + }); + + it("returns basename when it does not match version pattern", () => { + expect(packageNameFromPath("/home/user/DefinitelyTyped/types/some-package")).toBe("some-package"); + }); + + it("returns basename for scoped-like package names", () => { + expect(packageNameFromPath("/home/user/DefinitelyTyped/types/foo__bar")).toBe("foo__bar"); + }); + }); + + describe("packageDirectoryNameWithVersionFromPath", () => { + it("returns just package name for non-versioned path", () => { + expect(packageDirectoryNameWithVersionFromPath("/home/user/DefinitelyTyped/types/jquery")).toBe("jquery"); + }); + + it("returns name/version for versioned path", () => { + expect(packageDirectoryNameWithVersionFromPath("/home/user/DefinitelyTyped/types/react/v16")).toBe("react/v16"); + }); + + it("returns name/version for minor-versioned path", () => { + expect(packageDirectoryNameWithVersionFromPath("/home/user/DefinitelyTyped/types/express/v0.5")).toBe( + "express/v0.5", + ); + }); + + it("returns just package name for ts-versioned path", () => { + // ts3.6 is not a version directory for packageDirectoryNameWithVersionFromPath + expect(packageDirectoryNameWithVersionFromPath("/home/user/DefinitelyTyped/types/node/ts3.6")).toBe("node"); + }); + + it("handles Windows paths", () => { + expect(packageDirectoryNameWithVersionFromPath("C:\\DefinitelyTyped\\types\\lodash")).toBe("lodash"); + }); + }); + + describe("getCompilerOptions", () => { + it("throws when tsconfig does not exist", () => { + expect(() => getCompilerOptions("/nonexistent/tsconfig.json")).toThrow("does not exist"); + }); + + it("reads and parses tsconfig.json", () => { + const tmpDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "dtslint-test-")); + const tsconfigPath = path.join(tmpDir, "tsconfig.json"); + fs.writeFileSync( + tsconfigPath, + JSON.stringify({ + compilerOptions: { strict: true, target: "es6" }, + files: ["index.d.ts"], + }), + ); + try { + const result = getCompilerOptions(tsconfigPath); + expect(result.compilerOptions).toBeDefined(); + expect(result.files).toEqual(["index.d.ts"]); + } finally { + fs.unlinkSync(tsconfigPath); + fs.rmdirSync(tmpDir); + } + }); + + it("reads tsconfig.json with comments", () => { + const tmpDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "dtslint-test-")); + const tsconfigPath = path.join(tmpDir, "tsconfig.json"); + fs.writeFileSync( + tsconfigPath, + `{ + // This is a comment + "compilerOptions": { "strict": true }, + "files": ["index.d.ts"] + }`, + ); + try { + const result = getCompilerOptions(tsconfigPath); + expect(result.compilerOptions).toBeDefined(); + expect(result.compilerOptions.strict).toBe(true); + } finally { + fs.unlinkSync(tsconfigPath); + fs.rmdirSync(tmpDir); + } + }); + }); +});