diff --git a/.github/workflows/build-test-ci.yml b/.github/workflows/build-test-ci.yml index 7305336..e27ec97 100644 --- a/.github/workflows/build-test-ci.yml +++ b/.github/workflows/build-test-ci.yml @@ -30,6 +30,8 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm- - run: pnpm build + - name: Dist smoke (declaration-packaging regression guard) + run: pnpm exec vitest run --config vitest.dist.config.ts - run: pnpm test:coverage - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v6.0.1 diff --git a/package.json b/package.json index c19c0c2..d56d485 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "preview": "vite preview", "test": "vitest", "test:coverage": "vitest --coverage", + "test:dist": "pnpm build && vitest run --config vitest.dist.config.ts", "lint": "prettier --check .", "lint:fix": "prettier --write ." }, diff --git a/test/dist-smoke.test.ts b/test/dist-smoke.test.ts new file mode 100644 index 0000000..a716b4a --- /dev/null +++ b/test/dist-smoke.test.ts @@ -0,0 +1,127 @@ +// Smoke tests against the BUILT package in dist/ (not the lib/ source). +// +// These guard the packaging regression fixed in #39: the published tarball +// shipped no type declarations despite "types": "dist/main.d.ts", and nothing +// in the source-level suite (lib/main.test.ts imports from ./main) could catch +// it. Here we assert the real shipped artefacts: +// 1. the JS bundles named by package.json main/module exist and import, and +// the runtime export surface (decodeWebhook, WebhookEventType) works; +// 2. the declaration files exist where package.json "types" points, and a +// consumer importing the event types from the package root type-checks. +// +// The suite requires a prior `pnpm build`; run via the `test:dist` script, +// which builds first. + +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); +const distDir = join(repoRoot, "dist"); + +// Dynamic import() needs a file:// URL, not a bare filesystem path: on Windows a +// drive-letter path (C:\...) is read as a URL scheme and throws +// ERR_UNSUPPORTED_ESM_URL_SCHEME. pathToFileURL is a no-op-shaped fix on POSIX. +const importPath = (rel: string) => pathToFileURL(join(repoRoot, rel)).href; + +// Read package.json so the test follows whatever entry points the package +// advertises, rather than hard-coding filenames. +const pkg = JSON.parse( + execFileSync("node", [ + "-e", + `process.stdout.write(require("fs").readFileSync(${JSON.stringify( + join(repoRoot, "package.json"), + )}, "utf8"))`, + ]).toString(), +) as { main: string; module: string; types: string }; + +beforeAll(() => { + if (!existsSync(distDir)) { + throw new Error( + `dist/ not found at ${distDir}. Run \`pnpm build\` first (or use \`pnpm test:dist\`).`, + ); + } +}); + +describe("dist runtime (built bundle)", () => { + it("ships the JS entry points named by package.json", () => { + expect(existsSync(join(repoRoot, pkg.module))).toBe(true); // ESM + expect(existsSync(join(repoRoot, pkg.main))).toBe(true); // CJS + }); + + it("imports the built ESM bundle and exposes the runtime surface", async () => { + const mod = await import(importPath(pkg.module)); + expect(typeof mod.decodeWebhook).toBe("function"); + expect(typeof mod.WebhookEventType).toBe("object"); + expect(mod.WebhookEventType.userCreated).toBe("user.created"); + }); + + it("decodeWebhook from the bundle resolves null for an empty token", async () => { + const mod = await import(importPath(pkg.module)); + await expect(mod.decodeWebhook("")).resolves.toBeNull(); + }); +}); + +describe("dist declarations (#39)", () => { + it("ships the declaration file named by package.json types", () => { + expect(pkg.types).toBeTruthy(); + expect(existsSync(join(repoRoot, pkg.types))).toBe(true); + }); + + it("a consumer importing event types from the package root type-checks", () => { + // Spawn tsc against a throwaway consumer that imports from the built + // declarations. This reproduces #39's exact failure mode end to end: + // before the fix, this import errored TS2307 (no declarations shipped). + const work = mkdtempSync(join(tmpdir(), "kinde-webhooks-dts-")); + try { + writeFileSync( + join(work, "consume.ts"), + [ + `import type {`, + ` UserUpdatedWebhookEvent,`, + ` OrganizationCreatedWebhookEvent,`, + `} from "@kinde/webhooks";`, + `const a = (x: UserUpdatedWebhookEvent) => x;`, + `const b = (y: OrganizationCreatedWebhookEvent) => y;`, + `void a;`, + `void b;`, + ``, + ].join("\n"), + ); + writeFileSync( + join(work, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + moduleResolution: "Bundler", + module: "ESNext", + target: "ES2022", + noEmit: true, + skipLibCheck: false, + ignoreDeprecations: "6.0", + paths: { + "@kinde/webhooks": [join(repoRoot, pkg.types)], + }, + }, + include: ["consume.ts"], + }), + ); + + // Invoke tsc via Node against its JS entry point, NOT the + // node_modules/.bin/tsc shim: on Windows that shim is a `.cmd` wrapper + // that execFileSync (no shell) cannot resolve. The TS package's "bin" + // entry is the portable, OS-neutral target. + const tscJs = join(repoRoot, "node_modules", "typescript", "bin", "tsc"); + // Throws (non-zero exit) if the import fails to resolve / type-check. + execFileSync("node", [tscJs, "-p", join(work, "tsconfig.json")], { + stdio: "pipe", + }); + } finally { + rmSync(work, { recursive: true, force: true }); + } + }); +}); diff --git a/vite.config.ts b/vite.config.ts index ef4b2ff..fe4305c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,11 +12,15 @@ export default defineConfig({ fileName: "webhooks", }, target: "es2015", - outDir: "../dist", + outDir: "dist", emptyOutDir: true, }, - root: "lib", base: "", - resolve: { alias: { src: resolve(__dirname, "./lib") } }, - plugins: [dts({ insertTypesEntry: true, outDir: "../dist" })], + plugins: [ + dts({ + insertTypesEntry: true, + include: ["lib"], + exclude: ["lib/**/*.test.ts", "lib/**/*.spec.ts"], + }), + ], }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..992a609 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // The default `test` script runs the source suite (lib/). The dist smoke + // suite (test/dist-smoke.test.ts) requires a prior build and is run + // separately via the `test:dist` script, so it is excluded here. + exclude: ["**/node_modules/**", "**/dist/**", "test/**"], + }, +}); diff --git a/vitest.dist.config.ts b/vitest.dist.config.ts new file mode 100644 index 0000000..ea5cb7d --- /dev/null +++ b/vitest.dist.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Dist smoke suite only. Runs against the BUILT dist/ (see test/), so it + // must be preceded by a build (the `test:dist` script does this). + include: ["test/**/*.test.ts"], + }, +});