import * as path from 'path'; import * as ts from 'typescript'; import { describe, expect, it } from 'vitest'; import { checkSourceFile } from './engine.js'; function checkCode(code: string) { const fileName = 'test.ts'; const throwsPath = path.resolve(__dirname, 'throws.ts'); const throwsSource = ts.sys.readFile(throwsPath)!; const ThrowsPromisePath = path.resolve(__dirname, 'throws-promise.ts'); const ThrowsPromiseSource = ts.sys.readFile(ThrowsPromisePath)!; const compilerHost = ts.createCompilerHost({ target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, strict: true, }); const originalGetSourceFile = compilerHost.getSourceFile.bind(compilerHost); compilerHost.getSourceFile = (name, languageVersion, onError) => { if (name === fileName) { return ts.createSourceFile(name, code, languageVersion, true); } if (name === 'throws.ts' || name.endsWith('/throws.ts')) { return ts.createSourceFile(name, throwsSource, languageVersion, true); } if (name === 'throws-promise.ts' || name.endsWith('/throws-promise.ts')) { return ts.createSourceFile(name, ThrowsPromiseSource, languageVersion, true); } return originalGetSourceFile(name, languageVersion, onError); }; compilerHost.fileExists = (name) => { if ( name === fileName || name === 'throws.ts' || name.endsWith('/throws.ts') || name === 'throws-promise.ts' || name.endsWith('/throws-promise.ts') ) return true; return ts.sys.fileExists(name); }; const program = ts.createProgram( [fileName], { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, strict: true, }, compilerHost, ); const sourceFile = program.getSourceFile(fileName)!; const checker = program.getTypeChecker(); return checkSourceFile(sourceFile, checker); } describe('throws-transformer engine', () => { it('reports unhandled errors from a Throws-annotated function call', () => { const results = checkCode(` import { Throws } from './throws'; class MyError extends Error {} function risky(): Throws { throw new MyError(); } function caller(): string { return risky(); } `); expect(results.length).toBeGreaterThan(0); expect(results[0].unhandledErrors).toContain('MyError'); }); it('passes when errors are caught in try/catch', () => { const results = checkCode(` import { Throws } from './throws'; class MyError extends Error {} function risky(): Throws { throw new MyError(); } function caller(): string { try { return risky(); } catch (e) { if (e instanceof MyError) { return 'fallback'; } throw e; } } `); expect(results).toHaveLength(0); }); it('passes when errors are propagated in the return type', () => { const results = checkCode(` import { Throws } from './throws'; class MyError extends Error {} function risky(): Throws { throw new MyError(); } function caller(): Throws { return risky(); } `); expect(results).toHaveLength(0); }); it('reports errors not propagated in the return type', () => { const results = checkCode(` import { Throws } from './throws'; class NetworkError extends Error {} class SyntaxError extends Error {} function risky(): Throws { throw new NetworkError(); } function caller(): Throws { return risky(); } `); expect(results.length).toBeGreaterThan(0); expect(results[0].unhandledErrors).toContain('SyntaxError'); }); it('ignores throws with @throws-transformer ignore comment', () => { const results = checkCode(` import { Throws } from './throws'; class MyError extends Error {} function risky(): Throws { // @throws-transformer ignore throw new Error('assertion'); throw new MyError(); } `); expect(results).toHaveLength(0); }); it('reports throwing an undeclared error type in a sync Throws function', () => { const results = checkCode(` import { Throws } from './throws'; class NotFoundError extends Error {} class NetworkError extends Error {} class ValidationError extends Error {} interface User { id: string; name: string; email: string; } function fetchUser(id: string): Throws { throw new NotFoundError(id); } function getUserEmail(id: string): Throws { const user = fetchUser(id); if (!user.email.includes('@livekit.com')) { throw new ValidationError("not allowed"); } if (!user) { throw new NotFoundError('not found'); } return user.email; } `); const throwErrors = results.filter((r) => r.functionName === ''); expect(throwErrors.length).toBe(1); expect(throwErrors[0].unhandledErrors).toContain('ValidationError'); }); it('reports throwing an undeclared error type in an async Promise> function', () => { const results = checkCode(` import { Throws } from './throws'; class NotFoundError extends Error {} class NetworkError extends Error {} class ValidationError extends Error {} interface User { id: string; name: string; email: string; } async function fetchUser(id: string): Promise> { throw new NotFoundError(id); } async function getUserEmailAsync(id: string): Promise> { const user = await fetchUser(id); if (!user.email.includes('@livekit.com')) { throw new ValidationError("not allowed"); } return user.email; } `); const throwErrors = results.filter((r) => r.functionName === ''); expect(throwErrors.length).toBe(1); expect(throwErrors[0].unhandledErrors).toContain('ValidationError'); }); it('does not report errors for functions without Throws annotation', () => { const results = checkCode(` function plain(): string { return 'hello'; } function caller(): string { return plain(); } `); expect(results).toHaveLength(0); }); it('reports unhandled errors from a ThrowsPromise-returning function', () => { const results = checkCode(` import {ThrowsPromise} from './throws-promise'; import { Throws } from './throws'; class MyError extends Error {} function risky(): Promise> { return ThrowsPromise.reject(new MyError()); } async function caller(): Promise { return await risky(); } `); expect(results.length).toBeGreaterThan(0); expect(results.some((r) => r.unhandledErrors.includes('MyError'))).toBe(true); }); it('passes when ThrowsPromise errors are propagated via Throws return type', () => { const results = checkCode(` import { Throws } from './throws'; import {ThrowsPromise} from './throws-promise'; class MyError extends Error {} function risky(): Promise> { return ThrowsPromise.reject(new MyError()); } async function caller(): Promise> { return await risky(); } `); expect(results).toHaveLength(0); }); it('passes when ThrowsPromise errors are caught', () => { const results = checkCode(` import { Throws } from './throws'; import {ThrowsPromise} from './throws-promise'; class MyError extends Error {} function risky(): Promise> { return ThrowsPromise.reject(new MyError()); } async function caller(): Promise> { return await risky().catch((e) => console.debug(e)); } `); expect(results).toHaveLength(0); }); it('reports when only a subset of ThrowsPromise errors are caught', () => { const results = checkCode(` import { Throws } from './throws'; import {ThrowsPromise} from './throws-promise'; class MyError extends Error {} class NetworkError extends Error {} function risky(): Promise> { return ThrowsPromise.reject(Math.random() >= 0.5 ? new MyError() : new NetworkError()); } async function caller(): Promise> { return await risky().catch((e) => { if (e instanceof MyError) { console.log(e); return 'default'; } throw e; }); } `); expect(results.length).toBeGreaterThan(0); expect(results.some((r) => r.unhandledErrors.includes('NetworkError'))).toBe(true); }); it('reports unhandled errors from ThrowsPromise constructed with typed reject', () => { const results = checkCode(` import {ThrowsPromise} from './throws-promise'; import { Throws } from './throws'; class NetworkError extends Error {} class NotFoundError extends Error {} function fetchUser(id: string): Promise> { return new ThrowsPromise((resolve, reject) => { if (!id) { reject(new NotFoundError('not found')); return; } resolve('alice'); }); } async function caller(): Promise { return await fetchUser('123'); } `); expect(results.length).toBeGreaterThan(0); expect(results.some((r) => r.unhandledErrors.includes('NetworkError'))).toBe(true); }); it('reports unhandled errors from a function using ThrowsPromise.reject()', () => { const results = checkCode(` import { Throws } from './throws'; import {ThrowsPromise} from './throws-promise'; class NetworkError extends Error {} function fetchData(): Promise> { return Math.random() > 0.5 ? ThrowsPromise.resolve('ok') : ThrowsPromise.reject(new NetworkError('connection refused')); } async function caller(): Promise { return await fetchData(); } `); expect(results.length).toBeGreaterThan(0); expect(results.some((r) => r.unhandledErrors.includes('NetworkError'))).toBe(true); }); it('reports unhandled errors from ThrowsPromise.all()', () => { const results = checkCode(` import {ThrowsPromise} from './throws-promise'; import { Throws } from './throws'; class NetworkError extends Error {} class ParseError extends Error {} function fetchUser(): Promise> { return ThrowsPromise.reject(new NetworkError()); } function parseConfig(): Promise> { return ThrowsPromise.reject(new ParseError()); } async function loadDashboard(): Promise { return await ThrowsPromise.all([fetchUser(), parseConfig()]); } `); expect(results.length).toBeGreaterThan(0); const allErrors = results.flatMap((r) => r.unhandledErrors); expect(allErrors.some((e) => e.includes('NetworkError') || e.includes('ParseError'))).toBe( true, ); }); });