Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
Expand Down
141 changes: 141 additions & 0 deletions scripts/package-exports.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, unknown>;
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,
);
});
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
/* Build for a library */
"module": "ESNext",
"moduleResolution": "bundler",
"rootDir": ".",
"outDir": "dist",
"sourceMap": true,
"declaration": true,
Expand Down
2 changes: 1 addition & 1 deletion tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading