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
100 changes: 100 additions & 0 deletions packages/cli/src/navigation/__tests__/generators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,33 @@ const methods: MethodSignature[] = [
},
];

const callbackMethods: MethodSignature[] = [
{
name: 'navigateToProfile',
params: [
{ name: 'userId', type: 'string', optional: false },
{
name: 'onDismiss',
type: '() => void',
optional: false,
callback: { params: [], returnType: 'void' },
},
],
returnType: 'void',
isAsync: false,
},
];

const asyncMethods: MethodSignature[] = [
{
name: 'requestPermission',
params: [{ name: 'name', type: 'string', optional: false }],
returnType: 'Promise<boolean>',
isAsync: true,
promiseReturnType: 'boolean',
},
];

const modelMethods: MethodSignature[] = [
{
name: 'openSettings',
Expand Down Expand Up @@ -196,6 +223,79 @@ describe('navigation code generators', () => {
);
});

it('generates iOS bindings for methods with callback parameters', () => {
const swiftDelegate = generateSwiftDelegate(callbackMethods);
const objcImplementation = generateObjCImplementation(callbackMethods);

expect(swiftDelegate).toContain('import React');
expect(swiftDelegate).toContain(
'@objc func navigateToProfile(_ userId: String, onDismiss onDismiss: @escaping RCTResponseSenderBlock)'
);

expect(objcImplementation).toContain(
'- (void)navigateToProfile:(NSString *)userId onDismiss:(RCTResponseSenderBlock)onDismiss'
);
expect(objcImplementation).toContain(
'[[[BrownfieldNavigationManager shared] getDelegate] navigateToProfile:userId onDismiss:onDismiss];'
);
});

it('generates Android bindings for methods with callback parameters', () => {
const kotlinPackageName = 'com.callstack.nativebrownfieldnavigation';
const kotlinDelegate = generateKotlinDelegate(callbackMethods, kotlinPackageName);
const kotlinModule = generateKotlinModule(callbackMethods, kotlinPackageName);

expect(kotlinDelegate).toContain('import com.facebook.react.bridge.Callback');
expect(kotlinDelegate).toContain(
'fun navigateToProfile(userId: String, onDismiss: Callback)'
);

expect(kotlinModule).toContain('import com.facebook.react.bridge.Callback');
expect(kotlinModule).toContain(
'override fun navigateToProfile(userId: String, onDismiss: Callback)'
);
expect(kotlinModule).toContain(
'BrownfieldNavigationManager.getDelegate().navigateToProfile(userId, onDismiss)'
);
});

it('generates iOS async bindings that forward to the delegate instead of the not_implemented stub', () => {
const swiftDelegate = generateSwiftDelegate(asyncMethods);
const objcImplementation = generateObjCImplementation(asyncMethods);

expect(swiftDelegate).toContain('import React');
expect(swiftDelegate).toContain(
'@objc func requestPermission(_ name: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock)'
);

expect(objcImplementation).toContain(
'- (void)requestPermission:(NSString *)name resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject'
);
expect(objcImplementation).toContain(
'[[[BrownfieldNavigationManager shared] getDelegate] requestPermission:name resolve:resolve reject:reject];'
);
expect(objcImplementation).not.toContain('not_implemented');
});

it('generates Android async bindings that forward to the delegate instead of the not_implemented stub', () => {
const kotlinPackageName = 'com.callstack.nativebrownfieldnavigation';
const kotlinDelegate = generateKotlinDelegate(asyncMethods, kotlinPackageName);
const kotlinModule = generateKotlinModule(asyncMethods, kotlinPackageName);

expect(kotlinDelegate).toContain('import com.facebook.react.bridge.Promise');
expect(kotlinDelegate).toContain(
'fun requestPermission(name: String, promise: Promise)'
);

expect(kotlinModule).toContain(
'override fun requestPermission(name: String, promise: Promise)'
);
expect(kotlinModule).toContain(
'BrownfieldNavigationManager.getDelegate().requestPermission(name, promise)'
);
expect(kotlinModule).not.toContain('not_implemented');
});

it('maps known model types for Swift delegate signatures', () => {
const swiftDelegate = generateSwiftDelegate(modelMethods, {
modelTypeNames: ['DummyType'],
Expand Down
78 changes: 78 additions & 0 deletions packages/cli/src/navigation/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,84 @@ describe('parseNavigationSpec', () => {
]);
});

it('parses function-typed parameters into a callback signature', () => {
const specPath = createTempSpecFile(`
export interface BrownfieldNavigationSpec {
navigateToProfile(userId: string, onDismiss: (reason?: string) => void): void;
}
`);
tempSpecFiles.push(specPath);

const parsedSpec = parseNavigationSpec(specPath);

expect(parsedSpec.methods).toEqual([
{
name: 'navigateToProfile',
params: [
{ name: 'userId', type: 'string', optional: false },
{
name: 'onDismiss',
type: '(reason?: string) => void',
optional: false,
callback: {
params: [{ name: 'reason', type: 'string', optional: true }],
returnType: 'void',
},
},
],
returnType: 'void',
isAsync: false,
},
]);
});

it('parses Promise-returning methods into promiseReturnType and isAsync', () => {
const specPath = createTempSpecFile(`
export interface BrownfieldNavigationSpec {
requestPermission(name: string): Promise<boolean>;
}
`);
tempSpecFiles.push(specPath);

const parsedSpec = parseNavigationSpec(specPath);

expect(parsedSpec.methods).toEqual([
{
name: 'requestPermission',
params: [{ name: 'name', type: 'string', optional: false }],
returnType: 'Promise<boolean>',
isAsync: true,
promiseReturnType: 'boolean',
},
]);
});

it('throws when a parameter is typed as a Promise', () => {
const specPath = createTempSpecFile(`
export interface BrownfieldNavigationSpec {
foo(cb: Promise<string>): void;
}
`);
tempSpecFiles.push(specPath);

expect(() => parseNavigationSpec(specPath)).toThrow(
'Unsupported Promise parameter "cb" in method "foo": Promise<T> is only supported as a method return type.'
);
});

it('throws when a callback parameter has a non-void return type', () => {
const specPath = createTempSpecFile(`
export interface BrownfieldNavigationSpec {
bar(cb: () => string): void;
}
`);
tempSpecFiles.push(specPath);

expect(() => parseNavigationSpec(specPath)).toThrow(
'Unsupported callback parameter "cb" in method "bar": callback return type "string" is not supported. Use a void callback or model the result as a Promise-returning navigation method.'
);
});
Comment on lines +132 to +208

it('throws when no valid spec interface is present', () => {
const specPath = createTempSpecFile(`
export interface NavigationSpec {
Expand Down
107 changes: 73 additions & 34 deletions packages/cli/src/navigation/generators/android.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MethodSignature } from '../types.js';
import type { MethodParam, MethodSignature } from '../types.js';

const TS_TO_KOTLIN_TYPE: Record<string, string> = {
string: 'String',
Expand All @@ -12,6 +12,13 @@ interface KotlinTypeMappingOptions {
modelTypeNames?: string[];
}

function buildKotlinImports(imports: string[]): string {
if (imports.length === 0) {
return '\n';
}
return `\n${[...new Set(imports)].sort().join('\n')}\n`;
}

function mapTsTypeToKotlin(
tsType: string,
optional: boolean = false,
Expand All @@ -38,30 +45,74 @@ function mapTsTypeToKotlin(
return optional ? 'Any?' : 'Any';
}

function mapParamToKotlin(
param: MethodParam,
options: KotlinTypeMappingOptions = {},
layer: 'delegate' | 'module' = 'delegate'
): string {
return param.callback
? 'Callback'
: mapTsTypeToKotlin(param.type, param.optional, options, layer);
}
Comment on lines +48 to +56

function prepareKotlinArgs(
method: MethodSignature,
options: KotlinTypeMappingOptions = {}
): { preparedParams: string[]; args: string } {
const preparedParams: string[] = [];
const args = method.params
.map((param) => {
if (options.modelTypeNames?.includes(param.type)) {
const convertedName = `${param.name}Model`;
preparedParams.push(
` val ${convertedName} = ${param.name}${
param.optional
? `?.let(::to${param.type})`
: `.let(::to${param.type})`
}`
);
return convertedName;
}
return param.name;
})
.join(', ');

return { preparedParams, args };
}

export function generateKotlinDelegate(
methods: MethodSignature[],
kotlinPackageName: string,
options: KotlinTypeMappingOptions = {}
): string {
const hasAsyncMethod = methods.some((method) => method.isAsync);
const hasCallbackParam = methods.some((method) =>
method.params.some((param) => param.callback)
);
const imports = buildKotlinImports([
...(hasCallbackParam ? ['import com.facebook.react.bridge.Callback'] : []),
...(hasAsyncMethod ? ['import com.facebook.react.bridge.Promise'] : []),
]);

const methodSignatures = methods
.map((method) => {
const params = method.params
.map(
(param) =>
`${param.name}: ${mapTsTypeToKotlin(param.type, param.optional, options)}`
)
.join(', ');
const methodParams = method.params.map(
(param) => `${param.name}: ${mapParamToKotlin(param, options)}`
);
const params = [
...methodParams,
...(method.isAsync ? ['promise: Promise'] : []),
].join(', ');
const returnType =
method.returnType === 'void'
method.returnType === 'void' || method.isAsync
? ''
: `: ${mapTsTypeToKotlin(method.returnType, false, options, 'delegate')}`;
return ` fun ${method.name}(${params})${returnType}`;
})
.join('\n');

return `package ${kotlinPackageName}

interface BrownfieldNavigationDelegate {
${imports}interface BrownfieldNavigationDelegate {
${methodSignatures}
}
`;
Expand All @@ -73,6 +124,9 @@ export function generateKotlinModule(
options: KotlinTypeMappingOptions = {}
): string {
const hasAsyncMethod = methods.some((method) => method.isAsync);
const hasCallbackParam = methods.some((method) =>
method.params.some((param) => param.callback)
);
const hasObjectType = methods.some(
(method) =>
method.returnType.includes('Object') ||
Expand All @@ -94,8 +148,8 @@ export function generateKotlinModule(

import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactMethod${
hasAsyncMethod ? '\nimport com.facebook.react.bridge.Promise' : ''
}${hasObjectType ? '\nimport com.facebook.react.bridge.ReadableMap' : ''}
hasCallbackParam ? '\nimport com.facebook.react.bridge.Callback' : ''
}${hasAsyncMethod ? '\nimport com.facebook.react.bridge.Promise' : ''}${hasObjectType ? '\nimport com.facebook.react.bridge.ReadableMap' : ''}

class NativeBrownfieldNavigationModule(
reactContext: ReactApplicationContext
Expand All @@ -115,27 +169,10 @@ function generateSyncKotlinMethod(
): string {
const params = method.params
.map(
(param) =>
`${param.name}: ${mapTsTypeToKotlin(param.type, param.optional, options, 'module')}`
(param) => `${param.name}: ${mapParamToKotlin(param, options, 'module')}`
)
.join(', ');
const preparedParams: string[] = [];
const args = method.params
.map((param) => {
if (options.modelTypeNames?.includes(param.type)) {
const convertedName = `${param.name}Model`;
preparedParams.push(
` val ${convertedName} = ${param.name}${
param.optional
? `?.let(::to${param.type})`
: `.let(::to${param.type})`
}`
);
return convertedName;
}
return param.name;
})
.join(', ');
const { preparedParams, args } = prepareKotlinArgs(method, options);

const signature = ` @ReactMethod\n override fun ${method.name}(${params})${
method.returnType === 'void'
Expand All @@ -161,17 +198,19 @@ function generateAsyncKotlinMethod(
): string {
const paramsWithTypes = method.params
.map(
(param) =>
`${param.name}: ${mapTsTypeToKotlin(param.type, param.optional, options, 'module')}`
(param) => `${param.name}: ${mapParamToKotlin(param, options, 'module')}`
)
.join(', ');
const params =
paramsWithTypes.length > 0
? `${paramsWithTypes}, promise: Promise`
: 'promise: Promise';
const { preparedParams, args } = prepareKotlinArgs(method, options);
const delegateArgs = args.length > 0 ? `${args}, promise` : 'promise';
const preparedPrefix = preparedParams.length > 0 ? `${preparedParams.join('\n')}\n` : '';

return ` @ReactMethod
override fun ${method.name}(${params}) {
promise.reject("not_implemented", "${method.name} is not implemented")
${preparedPrefix} BrownfieldNavigationManager.getDelegate().${method.name}(${delegateArgs})
}`;
}
Loading
Loading