From 25fe42b7412f6b63ff017577cefc96623ae073e9 Mon Sep 17 00:00:00 2001 From: YLfjuk Date: Sun, 18 Oct 2026 17:42:06 +0300 Subject: [PATCH 1/3] fix: compile existing type assertions in normal test commands --- .github/workflows/validation.yml | 3 ++ README.md | 7 ++- package.json | 1 + packages/core/package.json | 2 +- packages/core/tsconfig.test.json | 7 +++ packages/core/vitest.config.ts | 7 +++ .../types/__tests__/union-to-tuple.spec.ts | 18 +++++-- packages/types/package.json | 2 +- packages/types/tsconfig.test.json | 7 +++ packages/types/vitest.config.ts | 7 +++ packages/utils/package.json | 2 +- packages/utils/tsconfig.test.json | 7 +++ packages/utils/vitest.config.ts | 7 +++ scripts/check-type-test-discovery.mjs | 51 +++++++++++++++++++ 14 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 packages/core/tsconfig.test.json create mode 100644 packages/types/tsconfig.test.json create mode 100644 packages/utils/tsconfig.test.json create mode 100644 scripts/check-type-test-discovery.mjs 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/README.md b/README.md index f21c6ad..d0337d5 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,17 @@ 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. + +`test:discovery` temporarily adds an invalid `expectTypeOf` assertion to each package and verifies that both the package compiler command and root Vitest command report it. It removes the temporary files even when the check fails. ## 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__/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..d3ebde8 --- /dev/null +++ b/scripts/check-type-test-discovery.mjs @@ -0,0 +1,51 @@ +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 fixtures = []; + +function pnpm(...args) { + const result = spawnSync(process.execPath, [process.env.npm_execpath, ...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 { + for (const { path } of fixtures) unlinkSync(path); +} From 6a89e5d55fb0462fda8220d273a4e1cfe32979b1 Mon Sep 17 00:00:00 2001 From: YLfjuk Date: Sun, 18 Oct 2026 20:51:26 +0300 Subject: [PATCH 2/3] test: harden type assertion discovery guard --- .gitignore | 3 +++ README.md | 2 +- .../types/__tests__/last-of-union.spec.ts | 20 +++++++++++++---- scripts/check-type-test-discovery.mjs | 22 +++++++++++++++++-- 4 files changed, 40 insertions(+), 7 deletions(-) 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 d0337d5..61bcda3 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Oxfmt and Oxlint handle formatting and linting, Vitest runs tests and coverage, 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. -`test:discovery` temporarily adds an invalid `expectTypeOf` assertion to each package and verifies that both the package compiler command and root Vitest command report it. It removes the temporary files even when the check fails. +`pnpm run test:discovery` temporarily adds an invalid `expectTypeOf` assertion to each package and verifies that both the package compiler command and root Vitest command report it. Its `finally` block attempts to remove every temporary file on success or a handled failure. Forced termination or filesystem errors can leave files behind. Before rerunning tests, manually delete any `*-typecheck-discovery-*.spec.ts` files in `packages/types/__tests__`, `packages/utils/__tests__`, and `packages/core/tests`. These generated files are ignored by Git. ## Releases 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/scripts/check-type-test-discovery.mjs b/scripts/check-type-test-discovery.mjs index d3ebde8..be33af4 100644 --- a/scripts/check-type-test-discovery.mjs +++ b/scripts/check-type-test-discovery.mjs @@ -5,10 +5,15 @@ 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(process.execPath, [process.env.npm_execpath, ...args], { + const result = spawnSync(command, [...prefixArgs, ...args], { cwd: root, encoding: "utf8", timeout: 60_000, @@ -47,5 +52,18 @@ try { } console.log("Root Vitest rejected the invalid assertion in all three projects"); } finally { - for (const { path } of fixtures) unlinkSync(path); + 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; + } } From 528e5bb69d990166aa60f783cf9aa6d50ebd6cb8 Mon Sep 17 00:00:00 2001 From: YLfjuk Date: Tue, 20 Oct 2026 11:28:01 +0300 Subject: [PATCH 3/3] docs: remove discovery guard README paragraph --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 61bcda3..c3159ed 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,6 @@ Oxfmt and Oxlint handle formatting and linting, Vitest runs tests and coverage, 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. -`pnpm run test:discovery` temporarily adds an invalid `expectTypeOf` assertion to each package and verifies that both the package compiler command and root Vitest command report it. Its `finally` block attempts to remove every temporary file on success or a handled failure. Forced termination or filesystem errors can leave files behind. Before rerunning tests, manually delete any `*-typecheck-discovery-*.spec.ts` files in `packages/types/__tests__`, `packages/utils/__tests__`, and `packages/core/tests`. These generated files are ignored by Git. - ## Releases Add a changeset with `pnpm run plan`. Merges to `main` update a release pull request; merging that pull request publishes changed packages through npm trusted publishing.