diff --git a/package.json b/package.json index 5b00900..68991b1 100644 --- a/package.json +++ b/package.json @@ -29,16 +29,19 @@ "!src/**/*.test.ts" ], "type": "module", - "main": "./dist/index.mjs", + "main": "./dist/index.cjs", "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", + "types": "./dist/index.d.cts", "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, "publishConfig": { "exports": { - ".": "./dist/index.mjs", + ".": { + "require": "./dist/index.cjs", + "import": "./dist/index.mjs" + }, "./package.json": "./package.json" } }, diff --git a/scripts/package-exports.test.ts b/scripts/package-exports.test.ts new file mode 100644 index 0000000..519a0d0 --- /dev/null +++ b/scripts/package-exports.test.ts @@ -0,0 +1,141 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { publishConfig } from '../package.json'; + +const execFileAsync = promisify(execFile); + +const rootDir = fileURLToPath(new URL('..', import.meta.url)); + +// Typed wider than the current package.json shape so that a malformed exports +// map surfaces as a test failure rather than a compile error in this file. +const rootExport: string | { require?: string; import?: string } = publishConfig.exports['.']; +const conditions = typeof rootExport === 'string' ? { import: rootExport } : rootExport; +const cjsEntry = conditions.require ? path.resolve(rootDir, conditions.require) : undefined; +const esmEntry = conditions.import ? path.resolve(rootDir, conditions.import) : undefined; + +const cjsReady = cjsEntry !== undefined && existsSync(cjsEntry); +const esmReady = esmEntry !== undefined && existsSync(esmEntry); + +const PUBLIC_API_SAMPLE = ['StackOneToolSet', 'BaseTool', 'StackOneError'] as const; + +const CONSUMER_SOURCE = [ + "import { StackOneToolSet } from '@stackone/ai';", + '', + "export const toolset = new StackOneToolSet({ apiKey: 'test' });", + '', +].join('\n'); + +/** + * Packs the project and unpacks it as `node_modules/@stackone/ai` of a minimal + * consumer project inside `tempDir`, returning the consumer directory. + * + * Packing goes through `pnpm pack` because it applies the publishConfig + * override for the exports map, producing the same package.json a consumer + * gets from the registry. Lifecycle scripts are disabled: the prepack script + * runs a full build, which must not happen inside the test. + */ +async function createConsumerFixture(tempDir: string): Promise { + await execFileAsync( + 'pnpm', + ['--config.ignore-scripts=true', 'pack', '--pack-destination', tempDir], + { cwd: rootDir }, + ); + const tarballName = (await readdir(tempDir)).find((file) => file.endsWith('.tgz')); + if (!tarballName) { + throw new Error('pnpm pack did not produce a tarball'); + } + await execFileAsync('tar', ['-xzf', tarballName], { cwd: tempDir }); + + const consumerDir = path.join(tempDir, 'consumer'); + const scopeDir = path.join(consumerDir, 'node_modules', '@stackone'); + await mkdir(scopeDir, { recursive: true }); + await rename(path.join(tempDir, 'package'), path.join(scopeDir, 'ai')); + + await writeFile( + path.join(consumerDir, 'package.json'), + JSON.stringify({ name: 'consumer', private: true }), + ); + await writeFile( + path.join(consumerDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'node16', + moduleResolution: 'node16', + strict: true, + noEmit: true, + skipLibCheck: true, + }, + }), + ); + // The .cts consumer exercises CommonJS resolution and the .mts consumer + // exercises ESM resolution under the Node16 tsconfig. + await writeFile(path.join(consumerDir, 'consumer.cts'), CONSUMER_SOURCE); + await writeFile(path.join(consumerDir, 'consumer.mts'), CONSUMER_SOURCE); + return consumerDir; +} + +/** + * Verifies the published package is consumable from both CommonJS and ESM, + * including TypeScript projects using Node16 module resolution. + * Regression coverage for https://github.com/StackOneHQ/stackone-ai-node/issues/350. + * + * Tests that exercise the build output are skipped when `dist/` is missing + * (run `pnpm build` first); CI always builds before testing. + */ +describe('published package output', () => { + it('declares require and import conditions for the root export', () => { + expect(conditions.require, 'publishConfig exports["."] must have a require condition').toMatch( + /\.cjs$/, + ); + expect(conditions.import, 'publishConfig exports["."] must have an import condition').toMatch( + /\.mjs$/, + ); + }); + + it.skipIf(!cjsReady)('exposes the public API from the CJS entry via require()', () => { + const requireFromTest = createRequire(import.meta.url); + const moduleExports = requireFromTest(String(cjsEntry)) as Record; + for (const exportName of PUBLIC_API_SAMPLE) { + expect(moduleExports[exportName], exportName).toBeTypeOf('function'); + } + }); + + it.skipIf(!esmReady)('exposes the public API from the ESM entry via import()', async () => { + const moduleExports = (await import(pathToFileURL(String(esmEntry)).href)) as Record< + string, + unknown + >; + for (const exportName of PUBLIC_API_SAMPLE) { + expect(moduleExports[exportName], exportName).toBeTypeOf('function'); + } + }); + + it.skipIf(!esmReady)( + 'type-checks for consumers using Node16 module resolution', + async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'stackone-node16-')); + try { + const consumerDir = await createConsumerFixture(tempDir); + try { + // The consumer fixture has no TypeScript install of its own, so tsc + // runs from the repository's pnpm context. + await execFileAsync('pnpm', ['exec', 'tsc', '-p', consumerDir], { cwd: rootDir }); + } catch (error) { + const execError = (error ?? {}) as { stdout?: string; stderr?: string }; + const details = + `${execError.stdout ?? ''}${execError.stderr ?? ''}`.trim() || String(error); + throw new Error(`tsc failed for the Node16 consumer fixture:\n${details}`); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); diff --git a/tsconfig.json b/tsconfig.json index ae1f0ce..27e1621 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ /* Build for a library */ "module": "ESNext", "moduleResolution": "bundler", + "rootDir": ".", "outDir": "dist", "sourceMap": true, "declaration": true, diff --git a/tsdown.config.ts b/tsdown.config.ts index 1ba916c..edb5ee5 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ entry: ['./src/index.ts'], outDir: 'dist', - format: 'esm', + format: ['esm', 'cjs'], clean: true, sourcemap: true, treeshake: true,