Skip to content
Open
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
19 changes: 19 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ jobs:
run: |
yarn build

- name: Check public API surface
if: ${{ success() }}
run: |
if ! git ls-files --error-unmatch api/surface.d.ts > /dev/null 2>&1; then
echo "::error::api/surface.d.ts is not tracked. Without it this check passes silently — restore the file."
exit 1
fi
yarn api:snapshot
if ! git diff --quiet api/surface.d.ts; then
echo "::error::Public API surface changed. Run 'yarn build && yarn api:snapshot' and commit api/surface.d.ts."
git diff api/surface.d.ts
exit 1
fi

- name: Check published package shape
if: ${{ success() }}
run: |
yarn package:check

- name: Run test
if: ${{ success() }}
timeout-minutes: 15
Expand Down
7,228 changes: 7,228 additions & 0 deletions api/surface.d.ts

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"clean-release": "rm -rf ./dist",
"build": "yarn clean-release; rollup -c && yarn build:post",
"build:post": "node ./scripts/post_build.js;",
"api:snapshot": "node ./scripts/api_surface_cli.mjs",
"package:check": "publint run dist --pack false --level error && attw --pack dist --exclude-entrypoints ./dist/index.css ./lame.all --ignore-rules false-esm internal-resolution-error",
"start": "rollup -c -w",
"reset": "yarn cache clean; yarn install",
"prepublishOnly": "yarn build",
Expand All @@ -51,7 +53,7 @@
"lint:fix": "yarn eslint --fix",
"eslint": "eslint 'src/**/*.ts*'",
"stylelint": "stylelint 'src/**'",
"typecheck": "tsc --noEmit -p tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p scripts/tsconfig.json",
"generate-component": "plop",
"inspect": "yarn test; yarn lint;",
"storybook": "storybook dev -p 6006",
Expand Down Expand Up @@ -84,6 +86,7 @@
},
"homepage": "https://sendbird.com",
"devDependencies": {
"@arethetypeswrong/cli": "0.18.5",
"@babel/core": "^7.23.2",
"@babel/eslint-parser": "^7.22.15",
"@babel/plugin-proposal-class-properties": "^7.18.6",
Expand Down Expand Up @@ -133,6 +136,7 @@
"plop": "^2.5.3",
"postcss": "^8.5.3",
"postcss-rtlcss": "^5.3.0",
"publint": "0.3.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rollup": "^4.59.0",
Expand Down
195 changes: 195 additions & 0 deletions scripts/__tests__/api_surface.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join, relative } from 'path';

import {
collectDeclarations,
entryDeclaration,
renderSnapshot,
resolveSpecifier,
} from '../api_surface.mjs';

let typesDir: string;

function write(path: string, body: string) {
const full = join(typesDir, path);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, body);
return full;
}

const reachable = (...entries: string[]) =>
collectDeclarations(typesDir, entries).map((f) => relative(typesDir, f).replace(/\\/g, '/'));

beforeEach(() => {
typesDir = mkdtempSync(join(tmpdir(), 'api-surface-'));
});

afterEach(() => {
rmSync(typesDir, { recursive: true, force: true });
});

describe('entryDeclaration', () => {
it('maps a source entry to its emitted declaration', () => {
expect(relative(typesDir, entryDeclaration(typesDir, 'src/modules/App/index.tsx'))).toBe(
join('modules', 'App', 'index.d.ts'),
);
});
});

describe('resolveSpecifier', () => {
it('resolves a sibling module', () => {
const from = write('a.d.ts', '');
const target = write('b.d.ts', '');
expect(resolveSpecifier(from, './b')).toBe(target);
});

it('resolves a directory through its index', () => {
const from = write('a.d.ts', '');
const target = write('nested/index.d.ts', '');
expect(resolveSpecifier(from, './nested')).toBe(target);
});

it('ignores package specifiers', () => {
const from = write('a.d.ts', '');
expect(resolveSpecifier(from, '@sendbird/chat')).toBeNull();
});

it('returns null when nothing matches', () => {
const from = write('a.d.ts', '');
expect(resolveSpecifier(from, './missing')).toBeNull();
});
});

describe('collectDeclarations', () => {
it('keeps the entry and drops modules nothing reaches', () => {
write('entry.d.ts', 'export declare const a: number;\n');
write('orphan.d.ts', 'export declare const b: number;\n');

expect(reachable('src/entry.ts')).toEqual(['entry.d.ts']);
});

it('follows a star re-export', () => {
write('entry.d.ts', "export * from './shared';\n");
write('shared.d.ts', 'export interface Shared {}\n');

expect(reachable('src/entry.ts')).toEqual(['entry.d.ts', 'shared.d.ts']);
});

it('follows a named re-export through a directory index', () => {
write('entry.d.ts', "export { thing } from './nested';\n");
write('nested/index.d.ts', "export { thing } from './thing';\n");
write('nested/thing.d.ts', 'export declare const thing: number;\n');

expect(reachable('src/entry.ts')).toEqual(['entry.d.ts', 'nested/index.d.ts', 'nested/thing.d.ts']);
});

it('follows an inline import type, which only appears in a signature', () => {
write('entry.d.ts', 'export declare const f: (p: import("./params").Params) => void;\n');
write('params.d.ts', 'export interface Params {}\n');

expect(reachable('src/entry.ts')).toEqual(['entry.d.ts', 'params.d.ts']);
});

it('terminates on a cycle', () => {
write('a.d.ts', "export * from './b';\n");
write('b.d.ts', "export * from './a';\n");

expect(reachable('src/a.ts')).toEqual(['a.d.ts', 'b.d.ts']);
});

it('merges the closures of several entries without duplicating', () => {
write('one.d.ts', "export * from './shared';\n");
write('two.d.ts', "export * from './shared';\n");
write('shared.d.ts', 'export interface Shared {}\n');

expect(reachable('src/one.ts', 'src/two.ts')).toEqual(['one.d.ts', 'shared.d.ts', 'two.d.ts']);
});

it('skips an entry whose declaration was never emitted', () => {
write('entry.d.ts', 'export declare const a: number;\n');

expect(reachable('src/entry.ts', 'src/never-built.ts')).toEqual(['entry.d.ts']);
});
});

describe('entry point map', () => {
const entries = {
'Channel/components/MessageInput': 'src/modules/Channel/components/MessageInputWrapper/index.tsx',
'Channel/components/MessageInputWrapper': 'src/modules/Channel/components/MessageInputWrapper/index.tsx',
App: 'src/modules/App/index.tsx',
};

const snapshotOf = (map: Record<string, string>) => {
write('modules/App/index.d.ts', 'export declare const App: unknown;\n');
write('modules/Channel/components/MessageInputWrapper/index.d.ts', 'export declare const W: unknown;\n');
return renderSnapshot(typesDir, collectDeclarations(typesDir, Object.values(map)), map);
};

it('sorts by public path so the diff does not move with declaration order', () => {
const paths = snapshotOf(entries)
.split('\n')
.filter((l) => l.includes(' <- '))
.map((l) => l.slice(3).split(' <- ')[0].trim());

expect(paths).toEqual(['App', 'Channel/components/MessageInput', 'Channel/components/MessageInputWrapper']);
});

it('shows a removed path even though another path keeps its declaration reachable', () => {
const rest = { ...entries };
delete rest['Channel/components/MessageInput'];

expect(snapshotOf(rest)).not.toBe(snapshotOf(entries));
});

it('shows a renamed path', () => {
const renamed = { ...entries, 'Channel/components/MessageInputRenamed': entries['Channel/components/MessageInput'] };
delete renamed['Channel/components/MessageInput'];

expect(snapshotOf(renamed)).not.toBe(snapshotOf(entries));
});

it('shows a path repointed at a source another entry already reaches', () => {
const repointed = { ...entries, 'Channel/components/MessageInput': 'src/modules/App/index.tsx' };

expect(snapshotOf(repointed)).not.toBe(snapshotOf(entries));
});
});

describe('renderSnapshot', () => {
it('labels every declaration with its path so a diff names the file', () => {
write('entry.d.ts', "export * from './shared';\n");
write('shared.d.ts', 'export interface Shared {}\n');

expect(renderSnapshot(typesDir, collectDeclarations(typesDir, ['src/entry.ts']))).toBe(
'// ===== entry.d.ts =====\n' +
"export * from './shared';\n" +
'// ===== shared.d.ts =====\n' +
'export interface Shared {}\n',
);
});

it('puts the entry points ahead of the declarations', () => {
write('entry.d.ts', 'export declare const a: number;\n');

const out = renderSnapshot(typesDir, collectDeclarations(typesDir, ['src/entry.ts']), {
Entry: 'src/entry.ts',
});

expect(out).toBe(
'// ===== public entry points =====\n' +
'// Entry <- src/entry.ts\n' +
'// ===== entry.d.ts =====\n' +
'export declare const a: number;\n',
);
});

it('is byte-identical across runs', () => {
write('entry.d.ts', "export * from './shared';\n");
write('shared.d.ts', 'export interface Shared {}\n');

const once = renderSnapshot(typesDir, collectDeclarations(typesDir, ['src/entry.ts']));
const twice = renderSnapshot(typesDir, collectDeclarations(typesDir, ['src/entry.ts']));
expect(once).toBe(twice);
});
});
77 changes: 77 additions & 0 deletions scripts/__tests__/package_exports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { buildExports, declarationPath } from '../package_exports.js';

const LAMEJS = { 'lame.all': 'src/_externals/lamejs/lame.all.js' };

describe('declarationPath', () => {
it('maps a .ts source onto its emitted declaration', () => {
expect(declarationPath('src/utils/isVoiceMessage.ts')).toBe('./types/utils/isVoiceMessage.d.ts');
});

it('maps a .tsx source onto its emitted declaration', () => {
expect(declarationPath('src/modules/App/index.tsx')).toBe('./types/modules/App/index.d.ts');
});

it('returns null for a source that emits no declaration', () => {
expect(declarationPath('src/_externals/lamejs/lame.all.js')).toBeNull();
});
});

describe('buildExports', () => {
it('names a declaration that the package actually ships for every typed entry', () => {
const { exports } = buildExports({ App: 'src/modules/App/index.tsx' });

expect(exports['./App']).toEqual({
types: './types/modules/App/index.d.ts',
require: './cjs/App.js',
import: './App.js',
default: './App.js',
});
});

it('omits types for an entry whose source emits no declaration', () => {
const { exports } = buildExports(LAMEJS);

expect(exports['./lame.all']).not.toHaveProperty('types');
expect(exports['./lame.all']).toEqual({
require: './cjs/lame.all.js',
import: './lame.all.js',
default: './lame.all.js',
});
});

it('omits the same entry from typesVersions', () => {
expect(buildExports(LAMEJS).typesVersions).toEqual({ '*': {} });
});

it('never names a types target outside the shipped declaration tree', () => {
const { exports, typesVersions } = buildExports({
...LAMEJS,
App: 'src/modules/App/index.tsx',
index: 'src/index.ts',
});

const targets = [
...(Object.values(exports) as Array<string | { types?: string }>)
.map((e) => (typeof e === 'string' ? undefined : e.types))
.filter((t): t is string => t !== undefined),
...Object.values(typesVersions['*']).flat(),
];

expect(targets.length).toBeGreaterThan(0);
targets.forEach((target) => expect(target).toMatch(/^\.\/types\/.+\.d\.ts$/));
});

it('maps the root entry to "." in exports and typesVersions', () => {
const { exports, typesVersions } = buildExports({ index: 'src/index.ts' });

expect(exports['.'].types).toBe('./types/index.d.ts');
expect(typesVersions['*']['.']).toEqual(['./types/index.d.ts']);
});

it('keeps package.json and the stylesheet reachable', () => {
const { exports } = buildExports({});

expect(exports['./package.json']).toBe('./package.json');
expect(exports['./dist/index.css']).toBe('./dist/index.css');
});
});
Loading
Loading