react-native-builder-bob
Version:
CLI to build JavaScript files for React Native libraries
263 lines • 10.7 kB
JavaScript
import os from 'node:os';
import path from 'node:path';
import { decode } from '@jridgewell/sourcemap-codec';
import fs from 'fs-extra';
import { beforeEach, expect, test, vi } from 'vitest';
import build from "../targets/typescript.js";
import { spawn } from "../utils/spawn.js";
const tsc = path.resolve(import.meta.dirname, '../../../../node_modules/.bin', process.platform === 'win32' ? 'tsc.cmd' : 'tsc');
const report = {
info: vi.fn(),
warn: vi.fn(),
success: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
vi.mocked(report.warn).mockClear();
});
const readDeclarationMap = async (filepath) => {
const value = await fs.readJSON(filepath);
if (value == null ||
typeof value !== 'object' ||
!('mappings' in value) ||
typeof value.mappings !== 'string' ||
!('sources' in value) ||
!Array.isArray(value.sources) ||
!value.sources.every((source) => typeof source === 'string')) {
throw new Error('Invalid declaration map.');
}
return {
mappings: value.mappings,
sources: value.sources,
};
};
const buildLibrary = async (root, { files, compilerOptions, packageJson, variants = { module: true }, }) => {
await fs.writeJSON(path.join(root, 'package.json'), {
name: 'library',
version: '1.0.0',
type: 'module',
exports: {
'.': {
types: './lib/typescript/src/index.d.ts',
},
},
...packageJson,
});
await fs.writeJSON(path.join(root, 'tsconfig.json'), {
compilerOptions: {
module: 'ESNext',
moduleResolution: 'Bundler',
rootDir: '.',
strict: true,
target: 'ESNext',
...compilerOptions,
},
include: ['src/**/*'],
});
await Promise.all(Object.entries(files).map(async ([name, content]) => fs.outputFile(path.join(root, name), content)));
await build({
root,
source: path.join(root, 'src'),
output: path.join(root, 'lib/typescript'),
report,
options: { project: 'tsconfig.json', tsc },
esm: true,
variants,
});
};
const typeCheckConsumer = async (root, index) => {
const consumer = path.join(root, 'consumer');
await fs.ensureSymlink(root, path.join(consumer, 'node_modules/library'));
await fs.writeJSON(path.join(consumer, 'package.json'), {
type: 'module',
});
await fs.writeJSON(path.join(consumer, 'tsconfig.json'), {
compilerOptions: {
module: 'NodeNext',
moduleResolution: 'NodeNext',
strict: true,
target: 'ESNext',
},
include: ['index.ts'],
});
await fs.outputFile(path.join(consumer, 'index.ts'), index);
try {
await spawn(tsc, ['--noEmit', '--project', 'tsconfig.json'], {
cwd: consumer,
});
return undefined;
}
catch (error) {
if (error != null &&
typeof error === 'object' &&
'stdout' in error &&
typeof error.stdout === 'string') {
return error.stdout;
}
throw error;
}
};
test('adds extensions to declarations for NodeNext resolution', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-'));
try {
await buildLibrary(root, {
compilerOptions: { allowImportingTsExtensions: true },
files: {
'src/index.ts': [
"export { type Foo } from './foo';",
"export * from './star';",
"export { type Bar } from './nested';",
"export { type Baz } from './explicit.ts';",
"export type UsesImportType = import('./foo').Foo;",
].join('\n'),
'src/foo.ts': 'export type Foo = { value: string };\n',
'src/star.ts': 'export type Star = { value: string };\n',
'src/nested/index.ts': 'export type Bar = { value: string };\n',
'src/explicit.ts': 'export type Baz = { value: string };\n',
},
});
const declaration = await fs.readFile(path.join(root, 'lib/typescript/src/index.d.ts'), 'utf-8');
expect(declaration).toContain("from './foo.js'");
expect(declaration).toContain("from './star.js'");
expect(declaration).toContain("from './nested/index.js'");
expect(declaration).toContain("from './explicit.js'");
expect(declaration).toContain("import('./foo.js').Foo");
const declarationMap = await readDeclarationMap(path.join(root, 'lib/typescript/src/index.d.ts.map'));
expect(declarationMap.mappings).toEqual(expect.any(String));
expect(declarationMap.sources.some((source) => source.endsWith('/src/index.ts'))).toBe(true);
const firstLine = declaration.split('\n')[0];
const firstLineColumns = decode(declarationMap.mappings)[0]?.map((segment) => segment[0]);
expect(firstLineColumns).toContain(firstLine?.indexOf(';'));
const stdout = await typeCheckConsumer(root, [
"import type { Bar, Baz, Foo, Star, UsesImportType } from 'library';",
'',
'type Test = [Bar, Baz, Foo, Star, UsesImportType];',
].join('\n'));
expect(stdout).toBeUndefined();
}
finally {
await fs.remove(root);
}
});
test('keeps codegen spec imports unchanged', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-'));
try {
await buildLibrary(root, {
packageJson: {
codegenConfig: { name: 'Lib', type: 'all', jsSrcsDir: 'src' },
},
files: {
'src/index.ts': [
"export { default as Foo } from './FooNativeComponent';",
"export { type Bar } from './bar';",
].join('\n'),
'src/FooNativeComponent.ts': [
'declare function codegenNativeComponent<T>(name: string): T;',
'export type FooProps = { value: string };',
"export default codegenNativeComponent<FooProps>('Foo');",
].join('\n'),
'src/bar.ts': 'export type Bar = { value: string };\n',
},
});
const declaration = await fs.readFile(path.join(root, 'lib/typescript/src/index.d.ts'), 'utf-8');
expect(declaration).toContain("from './FooNativeComponent'");
expect(declaration).not.toContain("from './FooNativeComponent.js'");
// Non-codegen imports are still rewritten as usual
expect(declaration).toContain("from './bar.js'");
}
finally {
await fs.remove(root);
}
});
test('keeps platform-specific declaration imports extensionless', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-'));
try {
await buildLibrary(root, {
files: {
'src/index.ts': "export { type Platform } from './platform';\n",
'src/platform.ts': 'export type Platform = { value: string };\n',
'src/platform.ios.ts': 'export type Platform = { value: string };\n',
},
});
const declaration = await fs.readFile(path.join(root, 'lib/typescript/src/index.d.ts'), 'utf-8');
expect(declaration).toContain("from './platform'");
expect(declaration).not.toContain("from './platform.js'");
const stdout = await typeCheckConsumer(root, [
"import type { Platform } from 'library';",
'',
'type Test = Platform;',
].join('\n'));
expect(stdout).toContain('Relative import paths need explicit file extensions');
}
finally {
await fs.remove(root);
}
});
test('resolves package imports when package.json has the same type', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-'));
try {
await buildLibrary(root, {
compilerOptions: { customConditions: ['library-source'] },
packageJson: {
imports: {
'#internal/*': {
'library-source': './src/internal/*.ts',
types: './lib/typescript/src/internal/*.d.ts',
},
},
},
files: {
'src/index.ts': "export { type Foo } from '#internal/foo';\n",
'src/internal/foo.ts': 'export type Foo = { value: string };\n',
},
});
expect(await fs.pathExists(path.join(root, 'lib/typescript/package.json'))).toBe(false);
expect(report.warn).not.toHaveBeenCalled();
const stdout = await typeCheckConsumer(root, ["import type { Foo } from 'library';", '', 'type Test = Foo;'].join('\n'));
expect(stdout).toBeUndefined();
}
finally {
await fs.remove(root);
}
});
test('warns about package imports when package.json has a different type', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-'));
try {
await buildLibrary(root, {
packageJson: {
type: undefined,
imports: { '#internal/*': './src/internal/*.ts' },
},
files: { 'src/index.ts': 'export type Foo = string;\n' },
});
expect(await fs.readJSON(path.join(root, 'lib/typescript/package.json'))).toEqual({ type: 'module' });
expect(report.warn).toHaveBeenCalledWith(expect.stringContaining("won't apply to the files in"));
}
finally {
await fs.remove(root);
}
});
test('keeps package.json for commonjs declarations in a dual package setup', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-'));
try {
await buildLibrary(root, {
packageJson: {
exports: {
'.': {
types: './lib/typescript/module/src/index.d.ts',
},
},
imports: { '#internal/*': './src/internal/*.ts' },
},
files: { 'src/index.ts': 'export type Foo = string;\n' },
variants: { commonjs: true, module: true },
});
expect(await fs.pathExists(path.join(root, 'lib/typescript/module/package.json'))).toBe(false);
expect(await fs.readJSON(path.join(root, 'lib/typescript/commonjs/package.json'))).toEqual({ type: 'commonjs' });
expect(report.warn).not.toHaveBeenCalled();
}
finally {
await fs.remove(root);
}
});
//# sourceMappingURL=typescript.test.js.map