diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index 1e65098..19cd4b4 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -39,6 +39,9 @@ jobs: - name: Lint run: pnpm run lint + - name: Check type test discovery + run: pnpm run test:discovery + - name: Check types run: pnpm run test:types diff --git a/.gitignore b/.gitignore index 88d91a2..b05f56c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ pnpm-debug.log* # tests coverage/ +/packages/types/__tests__/types-typecheck-discovery-*.spec.ts +/packages/utils/__tests__/utils-typecheck-discovery-*.spec.ts +/packages/core/tests/core-typecheck-discovery-*.spec.ts diff --git a/README.md b/README.md index f21c6ad..c3159ed 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,15 @@ pnpm install --frozen-lockfile pnpm run fmt:check pnpm run lint pnpm run test:types +pnpm run test:discovery pnpm run test pnpm run coverage pnpm run build ``` -Oxfmt and Oxlint handle formatting and linting, Vitest runs tests and coverage, TypeScript checks declarations, and tsdown builds and validates publishable packages with publint and Are the Types Wrong. +Oxfmt and Oxlint handle formatting and linting, Vitest runs tests and coverage, TypeScript checks source and test files, and tsdown builds and validates publishable packages with publint and Are the Types Wrong. + +Each package's `tsconfig.test.json` includes its source and existing `.spec.ts` tests. Both `test:types` and Vitest use it, so `test`, `test:v`, and `coverage` check type assertions too. The source-only `tsconfig.json` files remain separate for package builds. ## Releases diff --git a/package.json b/package.json index 38268e8..7530fa4 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dev": "turbo run dev", "test": "turbo test", "test:types": "turbo run test:types", + "test:discovery": "node scripts/check-type-test-discovery.mjs", "test:v": "vitest --typecheck", "coverage": "vitest run --coverage", "fmt": "oxfmt .", diff --git a/packages/core/package.json b/packages/core/package.json index 3fca7ca..986c182 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "build": "tsdown", "dev": "tsdown --watch", "test": "vitest run --typecheck", - "test:types": "tsc --noEmit" + "test:types": "tsc -p tsconfig.test.json" }, "dependencies": { "@ylfjuk-core/types": "workspace:*", diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json new file mode 100644 index 0000000..38d4c42 --- /dev/null +++ b/packages/core/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "tests"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 25cb7e5..1e4aa7e 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -7,6 +7,13 @@ const { } = baseTsconfig; export default defineProject({ + test: { + typecheck: { + enabled: true, + include: ["**/*.spec.ts"], + tsconfig: "tsconfig.test.json", + }, + }, resolve: { conditions, }, diff --git a/packages/types/__tests__/last-of-union.spec.ts b/packages/types/__tests__/last-of-union.spec.ts index aa38517..1800281 100644 --- a/packages/types/__tests__/last-of-union.spec.ts +++ b/packages/types/__tests__/last-of-union.spec.ts @@ -1,12 +1,24 @@ import { describe, expectTypeOf, it } from "vitest"; +import type { IsDisjointUnion } from "../src/is-disjoint-union"; import type { LastOfUnion } from "../src/last-of-union"; describe("LastOfUnion", () => { - it("should extract the last element in a union", () => { - type Actual = LastOfUnion<1 | 2 | 3 | 4 | 5 | "bob" | 6>; - type Expected = 6; + it("should extract one member from a union", () => { + type Members = 1 | 2 | 3 | 4 | 5 | "bob" | 6; + type Actual = LastOfUnion; - expectTypeOf().toEqualTypeOf(); + // Union ordering is not guaranteed by TypeScript. + expectTypeOf().toExtend(); + expectTypeOf().not.toBeNever(); + expectTypeOf>().toEqualTypeOf(); + }); + + it("should preserve a single member", () => { + expectTypeOf>().toEqualTypeOf<"bob">(); + }); + + it("should return never for an empty union", () => { + expectTypeOf>().toBeNever(); }); }); diff --git a/packages/types/__tests__/union-to-tuple.spec.ts b/packages/types/__tests__/union-to-tuple.spec.ts index 0ab50f4..66d9475 100644 --- a/packages/types/__tests__/union-to-tuple.spec.ts +++ b/packages/types/__tests__/union-to-tuple.spec.ts @@ -3,10 +3,20 @@ import { describe, expectTypeOf, it } from "vitest"; import type { UnionToTuple } from "../src/union-to-tuple"; describe("UnionToTuple", () => { - it("should convert a union into an ordered tuple", () => { - type Actual = UnionToTuple<1 | 2 | 3 | 4 | 5 | "bob" | 6>; - type Expected = [1, 2, 3, 4, 5, "bob", 6]; + it("should include each union member once in a tuple", () => { + type Members = 1 | 2 | 3 | 4 | 5 | "bob" | 6; + type Actual = UnionToTuple; - expectTypeOf().toEqualTypeOf(); + // Union ordering is not guaranteed by TypeScript. + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<7>(); + }); + + it("should convert never into an empty tuple", () => { + expectTypeOf>().toEqualTypeOf<[]>(); + }); + + it("should convert a single member into a one-element tuple", () => { + expectTypeOf>().toEqualTypeOf<["bob"]>(); }); }); diff --git a/packages/types/package.json b/packages/types/package.json index 0a2bcf7..af33bec 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -28,7 +28,7 @@ "scripts": { "build": "pnpm run test && pnpm run test:types && publint && attw --pack --profile esm-only", "test": "vitest run --typecheck", - "test:types": "tsc --noEmit" + "test:types": "tsc -p tsconfig.test.json" }, "peerDependencies": { "typescript": "^7.0.2" diff --git a/packages/types/tsconfig.test.json b/packages/types/tsconfig.test.json new file mode 100644 index 0000000..e964fcc --- /dev/null +++ b/packages/types/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "__tests__"] +} diff --git a/packages/types/vitest.config.ts b/packages/types/vitest.config.ts index 25cb7e5..1e4aa7e 100644 --- a/packages/types/vitest.config.ts +++ b/packages/types/vitest.config.ts @@ -7,6 +7,13 @@ const { } = baseTsconfig; export default defineProject({ + test: { + typecheck: { + enabled: true, + include: ["**/*.spec.ts"], + tsconfig: "tsconfig.test.json", + }, + }, resolve: { conditions, }, diff --git a/packages/utils/package.json b/packages/utils/package.json index a0bf369..deb40ea 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -30,7 +30,7 @@ "build": "tsdown", "dev": "tsdown --watch", "test": "vitest run --typecheck", - "test:types": "tsc --noEmit", + "test:types": "tsc -p tsconfig.test.json", "lint": "oxlint" }, "dependencies": { diff --git a/packages/utils/tsconfig.test.json b/packages/utils/tsconfig.test.json new file mode 100644 index 0000000..e964fcc --- /dev/null +++ b/packages/utils/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "__tests__"] +} diff --git a/packages/utils/vitest.config.ts b/packages/utils/vitest.config.ts index 25cb7e5..1e4aa7e 100644 --- a/packages/utils/vitest.config.ts +++ b/packages/utils/vitest.config.ts @@ -7,6 +7,13 @@ const { } = baseTsconfig; export default defineProject({ + test: { + typecheck: { + enabled: true, + include: ["**/*.spec.ts"], + tsconfig: "tsconfig.test.json", + }, + }, resolve: { conditions, }, diff --git a/scripts/check-type-test-discovery.mjs b/scripts/check-type-test-discovery.mjs new file mode 100644 index 0000000..be33af4 --- /dev/null +++ b/scripts/check-type-test-discovery.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { unlinkSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const npmExecPath = process.env.npm_execpath; +assert.ok(npmExecPath, "Run this check via `pnpm run test:discovery` (npm_execpath is unset)."); +const [command, ...prefixArgs] = /\.[cm]?js$/i.test(npmExecPath) + ? [process.execPath, npmExecPath] + : [npmExecPath]; +const fixtures = []; + +function pnpm(...args) { + const result = spawnSync(command, [...prefixArgs, ...args], { + cwd: root, + encoding: "utf8", + timeout: 60_000, + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, + }); + assert.ifError(result.error); + assert.equal(result.status, 1, result.stdout + result.stderr); + return result.stdout + result.stderr; +} + +try { + for (const [name, directory] of [ + ["types", "__tests__"], + ["utils", "__tests__"], + ["core", "tests"], + ]) { + const filename = `${name}-typecheck-discovery-${randomUUID()}.spec.ts`; + const path = new URL(`../packages/${name}/${directory}/${filename}`, import.meta.url); + writeFileSync( + path, + 'import { expectTypeOf, it } from "vitest";\nit("typecheck discovery", () => {\nexpectTypeOf().toEqualTypeOf();\n});\n', + { flag: "wx" }, + ); + fixtures.push({ filename, path }); + const output = pnpm("--dir", `packages/${name}`, "run", "test:types"); + assert.ok(output.includes(`${filename}(3,`) && output.includes("error TS2344"), output); + console.log(`${name}: test:types rejected the invalid assertion`); + } + + const output = pnpm("exec", "vitest", "run", ...fixtures.map(({ filename }) => filename)); + for (const { filename } of fixtures) { + assert.match( + output, + new RegExp(`FAIL[^\n]*${filename.replaceAll(".", "\\.")}[^\n]*\r?\nTypeCheckError:`), + ); + } + console.log("Root Vitest rejected the invalid assertion in all three projects"); +} finally { + const errors = []; + for (const { path } of fixtures) { + try { + unlinkSync(path); + } catch (error) { + errors.push(error); + } + } + if (errors.length) { + console.error( + new AggregateError(errors, "Could not remove all typecheck discovery fixtures"), + ); + process.exitCode = 1; + } +}