diff --git a/src/compiler/transformers/decorators-to-static/constant-resolution-utils.ts b/src/compiler/transformers/decorators-to-static/constant-resolution-utils.ts new file mode 100644 index 00000000000..f24f57fa7b6 --- /dev/null +++ b/src/compiler/transformers/decorators-to-static/constant-resolution-utils.ts @@ -0,0 +1,218 @@ +import ts from 'typescript'; + +/** + * Safely get text from a TypeScript node, handling synthetic nodes + * @param node - The node to get the text from + * @returns The text of the node + */ +export const getNodeText = (node: ts.Node): string => { + try { + // For synthetic nodes or nodes without source positions, try to get a meaningful representation + if (!node.getSourceFile() || node.pos === -1) { + if (ts.isIdentifier(node)) { + return node.text; + } else if (ts.isStringLiteral(node)) { + return `"${node.text}"`; + } else if (ts.isNumericLiteral(node)) { + return node.text; + } else if (ts.isPropertyAccessExpression(node)) { + return `${getNodeText(node.expression)}.${getNodeText(node.name)}`; + } else { + return ts.SyntaxKind[node.kind] || 'unknown'; + } + } + return node.getText(); + } catch (error) { + // Fallback for any other errors + if (ts.isIdentifier(node)) { + return node.text; + } + return ts.SyntaxKind[node.kind] || 'unknown'; + } +}; + +/** + * Helper function to extract property name from various property name types + * @param propName - The property name to extract + * @returns The property name + */ +export const getPropertyName = (propName: ts.PropertyName): string | null => { + if (ts.isIdentifier(propName)) { + return propName.text; + } else if (ts.isStringLiteral(propName)) { + return propName.text; + } else if (ts.isNumericLiteral(propName)) { + return propName.text; + } + return null; +}; + +/** + * Helper function to get computed property value at compile time + * @param expr - The expression to get the computed property value from + * @param typeChecker - The TypeScript type checker + * @param sourceFile - The source file to get the computed property value from + * @returns The computed property value + */ +export const getComputedPropertyValue = ( + expr: ts.Expression, + typeChecker: ts.TypeChecker, + sourceFile?: ts.SourceFile, +): string | null => { + const resolved = tryResolveConstantValue(expr, typeChecker, sourceFile); + return typeof resolved === 'string' ? resolved : null; +}; + +/** + * Try to resolve an imported constant from another module + * This handles cases like imported EVENT_NAMES or SHARED_EVENT + * @param expr - The expression to try to resolve the imported constant from + * @param typeChecker - The TypeScript type checker + * @returns The imported constant + */ +export const tryResolveImportedConstant = (expr: ts.Expression, typeChecker: ts.TypeChecker): any => { + const symbol = typeChecker.getSymbolAtLocation(expr); + if (!symbol) return undefined; + + // Check if this symbol comes from an import + if (symbol.flags & ts.SymbolFlags.Alias) { + const aliasedSymbol = typeChecker.getAliasedSymbol(symbol); + if (aliasedSymbol?.valueDeclaration) { + return tryResolveConstantValue(expr, typeChecker, aliasedSymbol.valueDeclaration.getSourceFile()); + } + } + + // For property access expressions on imported symbols + if (ts.isPropertyAccessExpression(expr)) { + const leftSymbol = typeChecker.getSymbolAtLocation(expr.expression); + if (leftSymbol && leftSymbol.flags & ts.SymbolFlags.Alias) { + const aliasedSymbol = typeChecker.getAliasedSymbol(leftSymbol); + if (aliasedSymbol?.valueDeclaration) { + // Try to resolve the imported object and then access the property + const importedValue = tryResolveConstantValue( + expr.expression, + typeChecker, + aliasedSymbol.valueDeclaration.getSourceFile(), + ); + if (importedValue && typeof importedValue === 'object') { + const propName = ts.isIdentifier(expr.name) ? expr.name.text : null; + if (propName && propName in importedValue) { + return importedValue[propName]; + } + } + } + } + } + + return undefined; +}; + +/** + * Try to resolve a constant value by evaluating the expression at compile time + * This handles cases like `EVENT_NAMES.CLICK` where EVENT_NAMES is a const object + * @param expr - The expression to try to resolve the constant value from + * @param typeChecker - The TypeScript type checker + * @param sourceFile - The source file to try to resolve the constant value from + * @returns The constant value + */ +export const tryResolveConstantValue = ( + expr: ts.Expression, + typeChecker: ts.TypeChecker, + sourceFile?: ts.SourceFile, +): any => { + if (ts.isPropertyAccessExpression(expr)) { + // For property access like `EVENT_NAMES.CLICK` or `EVENT_NAMES.USER.LOGIN` + // First resolve the object (left side of the dot) + const objValue = tryResolveConstantValue(expr.expression, typeChecker, sourceFile); + + if (objValue !== undefined && typeof objValue === 'object' && objValue !== null) { + // If we have an object, try to access the property + const propName = ts.isIdentifier(expr.name) ? expr.name.text : null; + if (propName && propName in objValue) { + return objValue[propName]; + } + } + + // Fallback: try to resolve using symbol table for simple property access + const objSymbol = typeChecker.getSymbolAtLocation(expr.expression); + if (objSymbol?.valueDeclaration && ts.isVariableDeclaration(objSymbol.valueDeclaration)) { + const initializer = objSymbol.valueDeclaration.initializer; + if (initializer && ts.isObjectLiteralExpression(initializer)) { + for (const prop of initializer.properties) { + if (ts.isPropertyAssignment(prop)) { + const propName = getPropertyName(prop.name); + let accessedProp: string | null = null; + const propertyName = expr.name; + if (ts.isIdentifier(propertyName)) { + accessedProp = propertyName.text; + } else { + // Use getPropertyName helper which handles all property name types safely + accessedProp = getPropertyName(propertyName); + } + + if (propName === accessedProp) { + // Recursively resolve the property value + return tryResolveConstantValue(prop.initializer, typeChecker, sourceFile); + } + } else if (ts.isShorthandPropertyAssignment(prop)) { + // Handle shorthand properties like { click } where click is a variable + const propName = ts.isIdentifier(prop.name) ? prop.name.text : null; + const accessedProp = ts.isIdentifier(expr.name) ? expr.name.text : null; + + if (propName === accessedProp) { + // For shorthand properties, the value is the same as the property name + // So we need to resolve the variable that the property refers to + return tryResolveConstantValue(prop.name, typeChecker, sourceFile); + } + } + } + } + } + } else if (ts.isIdentifier(expr)) { + // For simple identifiers like `CLICK` or `EVENT_NAME` + const symbol = typeChecker.getSymbolAtLocation(expr); + if (symbol?.valueDeclaration && ts.isVariableDeclaration(symbol.valueDeclaration)) { + const initializer = symbol.valueDeclaration.initializer; + if (initializer) { + return tryResolveConstantValue(initializer, typeChecker, sourceFile); + } + } + } else if (ts.isObjectLiteralExpression(expr)) { + // For object literals, try to resolve all properties + const obj: any = {}; + for (const prop of expr.properties) { + if (ts.isPropertyAssignment(prop)) { + const propName = ts.isIdentifier(prop.name) + ? prop.name.text + : ts.isStringLiteral(prop.name) + ? prop.name.text + : null; + if (propName) { + const propValue = tryResolveConstantValue(prop.initializer, typeChecker, sourceFile); + if (propValue !== undefined) { + obj[propName] = propValue; + } + } + } + } + return obj; + } else if (ts.isStringLiteral(expr)) { + return expr.text; + } else if (ts.isNumericLiteral(expr)) { + return Number(expr.text); + } else if (expr.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } else if (expr.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } else if (expr.kind === ts.SyntaxKind.NullKeyword) { + return null; + } else if (expr.kind === ts.SyntaxKind.UndefinedKeyword) { + return undefined; + } else if (ts.isTemplateExpression(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { + // For template literals, we could try to resolve them if they only contain constants + // For now, just return undefined as this requires more complex evaluation + return undefined; + } + + return undefined; +}; diff --git a/src/compiler/transformers/decorators-to-static/decorator-utils.ts b/src/compiler/transformers/decorators-to-static/decorator-utils.ts index 78b63d1ad6d..16f2ffb2128 100644 --- a/src/compiler/transformers/decorators-to-static/decorator-utils.ts +++ b/src/compiler/transformers/decorators-to-static/decorator-utils.ts @@ -1,7 +1,30 @@ import ts from 'typescript'; -import { objectLiteralToObjectMap } from '../transform-utils'; +import { objectLiteralToObjectMap, objectLiteralToObjectMapWithConstants } from '../transform-utils'; +import { getNodeText, tryResolveConstantValue, tryResolveImportedConstant } from './constant-resolution-utils'; +/** + * Extract the decorator name from a decorator expression + * @param decorator - The decorator to extract the name from + * @returns The name of the decorator or null if it cannot be determined + */ +export const getDecoratorName = (decorator: ts.Decorator): string | null => { + if (ts.isCallExpression(decorator.expression)) { + if (ts.isIdentifier(decorator.expression.expression)) { + return decorator.expression.expression.text; + } + } else if (ts.isIdentifier(decorator.expression)) { + return decorator.expression.text; + } + return null; +}; + +/** + * Extract the parameters from a decorator expression + * @param decorator - The decorator to extract the parameters from + * @param typeChecker - The TypeScript type checker + * @returns The parameters of the decorator + */ export const getDecoratorParameters: GetDecoratorParameters = ( decorator: ts.Decorator, typeChecker: ts.TypeChecker, @@ -9,28 +32,155 @@ export const getDecoratorParameters: GetDecoratorParameters = ( if (!ts.isCallExpression(decorator.expression)) { return []; } - return decorator.expression.arguments.map((arg) => getDecoratorParameter(arg, typeChecker)); + + // Check if this is an @Event or @Listen decorator - only apply constant resolution to these + const decoratorName = getDecoratorName(decorator); + const shouldResolveConstants = decoratorName === 'Event' || decoratorName === 'Listen'; + + return decorator.expression.arguments.map((arg) => getDecoratorParameter(arg, typeChecker, shouldResolveConstants)); }; -const getDecoratorParameter = (arg: ts.Expression, typeChecker: ts.TypeChecker): any => { +/** + * Extract the parameter from a decorator expression + * @param arg - The argument to extract the parameter from + * @param typeChecker - The TypeScript type checker + * @param shouldResolveConstants - Whether to resolve constants + * @returns The parameter of the decorator + */ +export const getDecoratorParameter = ( + arg: ts.Expression, + typeChecker: ts.TypeChecker, + shouldResolveConstants: boolean = true, +): any => { if (ts.isObjectLiteralExpression(arg)) { - return objectLiteralToObjectMap(arg); + // Use enhanced constant resolution for Event/Listen decorators, fall back to basic version for others + if (shouldResolveConstants) { + return objectLiteralToObjectMapWithConstants(arg, typeChecker); + } else { + return objectLiteralToObjectMap(arg); + } } else if (ts.isStringLiteral(arg)) { return arg.text; } else if (ts.isPropertyAccessExpression(arg) || ts.isIdentifier(arg)) { - const type = typeChecker.getTypeAtLocation(arg); - if (type !== undefined && type.isLiteral()) { - /** - * Using enums or variables require us to resolve the value for - * the computed property/identifier via the TS type checker. As long - * as the type resolves to a literal, we can grab its value to be used - * as the `@Watch()` decorator argument. - */ - return type.value; + if (shouldResolveConstants) { + const type = typeChecker.getTypeAtLocation(arg); + if (type !== undefined) { + // First check if it's a literal type (most precise) + if (type.isLiteral()) { + /** + * Using enums or variables require us to resolve the value for + * the computed property/identifier via the TS type checker. As long + * as the type resolves to a literal, we can grab its value to be used + * as the decorator argument. + */ + return type.value; + } + + // Enhanced: Also accept string/number/boolean constants even without 'as const' + // This makes the decorator resolution more robust for common use cases + if (type.flags & ts.TypeFlags.StringLiteral) { + return (type as ts.StringLiteralType).value; + } + if (type.flags & ts.TypeFlags.NumberLiteral) { + return (type as ts.NumberLiteralType).value; + } + if (type.flags & ts.TypeFlags.BooleanLiteral) { + return (type as any).intrinsicName === 'true'; + } + + // For union types, check if all members are literals of the same type + if (type.flags & ts.TypeFlags.Union) { + const unionType = type as ts.UnionType; + const literalTypes = unionType.types.filter( + (t) => t.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral), + ); + + // If it's a single literal in a union (e.g., from const assertion), use it + if (literalTypes.length === 1) { + const literalType = literalTypes[0]; + if (literalType.flags & ts.TypeFlags.StringLiteral) { + return (literalType as ts.StringLiteralType).value; + } + if (literalType.flags & ts.TypeFlags.NumberLiteral) { + return (literalType as ts.NumberLiteralType).value; + } + if (literalType.flags & ts.TypeFlags.BooleanLiteral) { + return (literalType as any).intrinsicName === 'true'; + } + } + } + + // Enhanced: Try to resolve the symbol and evaluate constant properties + // This handles cases like `EVENT_NAMES.CLICK` where EVENT_NAMES is defined without 'as const' + const symbol = typeChecker.getSymbolAtLocation(arg); + if (symbol && symbol.valueDeclaration) { + const constantValue = tryResolveConstantValue(arg, typeChecker); + if (constantValue !== undefined) { + return constantValue; + } + } + + // Try to resolve cross-module imports + const importValue = tryResolveImportedConstant(arg, typeChecker); + if (importValue !== undefined) { + return importValue; + } + } + } else { + // For non-Event/Listen decorators, try the basic literal type check for backward compatibility + const type = typeChecker.getTypeAtLocation(arg); + if (type !== undefined) { + // First check if it's a literal type (most precise) + if (type.isLiteral()) { + return type.value; + } + + // Fallback: Also check type flags for literal types (for backward compatibility) + if (type.flags & ts.TypeFlags.StringLiteral) { + return (type as ts.StringLiteralType).value; + } + if (type.flags & ts.TypeFlags.NumberLiteral) { + return (type as ts.NumberLiteralType).value; + } + if (type.flags & ts.TypeFlags.BooleanLiteral) { + return (type as any).intrinsicName === 'true'; + } + + // For union types, check if all members are literals of the same type + if (type.flags & ts.TypeFlags.Union) { + const unionType = type as ts.UnionType; + const literalTypes = unionType.types.filter( + (t) => t.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral), + ); + + // If it's a single literal in a union (e.g., from const assertion), use it + if (literalTypes.length === 1) { + const literalType = literalTypes[0]; + if (literalType.flags & ts.TypeFlags.StringLiteral) { + return (literalType as ts.StringLiteralType).value; + } + if (literalType.flags & ts.TypeFlags.NumberLiteral) { + return (literalType as ts.NumberLiteralType).value; + } + if (literalType.flags & ts.TypeFlags.BooleanLiteral) { + return (literalType as any).intrinsicName === 'true'; + } + } + } + } } } - throw new Error(`invalid decorator argument: ${arg.getText()}`); + // Graceful fallback: if we can't resolve it and it's a constant resolution attempt, + // just return the original expression text and let the runtime handle it + if (shouldResolveConstants) { + const nodeText = getNodeText(arg); + console.warn(`Could not resolve constant decorator argument: ${nodeText}. Using original expression.`); + return nodeText; + } + + const nodeText = getNodeText(arg); + throw new Error(`invalid decorator argument: ${nodeText} - must be a string literal, constant, or enum value`); }; /** diff --git a/src/compiler/transformers/decorators-to-static/event-decorator.ts b/src/compiler/transformers/decorators-to-static/event-decorator.ts index 598682c1c98..b5ce8d23a33 100644 --- a/src/compiler/transformers/decorators-to-static/event-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/event-decorator.ts @@ -6,11 +6,12 @@ import { convertValueToLiteral, createStaticGetter, getAttributeTypeInfo, + objectLiteralToObjectMapWithConstants, resolveType, retrieveTsDecorators, serializeSymbol, } from '../transform-utils'; -import { getDecoratorParameters, isDecoratorNamed } from './decorator-utils'; +import { isDecoratorNamed } from './decorator-utils'; export const eventDecoratorsToStatic = ( diagnostics: d.Diagnostic[], @@ -30,6 +31,17 @@ export const eventDecoratorsToStatic = ( } }; +const getEventDecoratorOptions = (decorator: ts.Decorator, typeChecker: ts.TypeChecker): [d.EventOptions] => { + if (!ts.isCallExpression(decorator.expression)) { + return [{}]; + } + const [arg] = decorator.expression.arguments; + if (arg && ts.isObjectLiteralExpression(arg)) { + return [objectLiteralToObjectMapWithConstants(arg, typeChecker)]; + } + return [{}]; +}; + /** * Parse a single instance of Stencil's `@Event()` decorator and generate metadata for the class member that is * decorated @@ -60,7 +72,7 @@ const parseEventDecorator = ( return null; } - const [eventOpts] = getDecoratorParameters(eventDecorator, typeChecker); + const [eventOpts] = getEventDecoratorOptions(eventDecorator, typeChecker); const symbol = typeChecker.getSymbolAtLocation(prop.name); const eventName = getEventName(eventOpts, memberName); diff --git a/src/compiler/transformers/decorators-to-static/listen-decorator.ts b/src/compiler/transformers/decorators-to-static/listen-decorator.ts index 0cee01837f6..f74983d37bf 100644 --- a/src/compiler/transformers/decorators-to-static/listen-decorator.ts +++ b/src/compiler/transformers/decorators-to-static/listen-decorator.ts @@ -22,6 +22,25 @@ export const listenDecoratorsToStatic = ( } }; +/** + * Parses the listen decorator and returns the event name and the listen options + * Allows for the event name to be a string literal, constant, or property access expression + * @param decorator - The decorator to parse + * @param typeChecker - The type checker to use + * @returns A tuple containing the event name and the listen options + */ +const getListenDecoratorOptions = (decorator: ts.Decorator, typeChecker: ts.TypeChecker): [string, d.ListenOptions] => { + if (!ts.isCallExpression(decorator.expression)) { + return ['', {}]; + } + + const [eventName, options] = getDecoratorParameters(decorator, typeChecker); + + // If options is provided, it's already parsed by getDecoratorParameters + // If eventName is resolved but options is undefined, return empty options + return [eventName || '', options || {}]; +}; + const parseListenDecorators = ( diagnostics: d.Diagnostic[], typeChecker: ts.TypeChecker, @@ -35,7 +54,7 @@ const parseListenDecorators = ( return listenDecorators.map((listenDecorator) => { const methodName = method.name.getText(); - const [listenText, listenOptions] = getDecoratorParameters(listenDecorator, typeChecker); + const [listenText, listenOptions] = getListenDecoratorOptions(listenDecorator, typeChecker); const eventNames = listenText.split(','); if (eventNames.length > 1) { diff --git a/src/compiler/transformers/test/constant-resolution-utils.spec.ts b/src/compiler/transformers/test/constant-resolution-utils.spec.ts new file mode 100644 index 00000000000..4199c56a0de --- /dev/null +++ b/src/compiler/transformers/test/constant-resolution-utils.spec.ts @@ -0,0 +1,303 @@ +import ts from 'typescript'; + +import { + getComputedPropertyValue, + getNodeText, + getPropertyName, + tryResolveConstantValue, + tryResolveImportedConstant, +} from '../decorators-to-static/constant-resolution-utils'; + +describe('constant-resolution-utils', () => { + // Create a mock TypeScript type checker for tests that need it + const createMockTypeChecker = (): ts.TypeChecker => { + return { + getSymbolAtLocation: jest.fn().mockReturnValue(undefined), + getTypeAtLocation: jest.fn().mockReturnValue(undefined), + getAliasedSymbol: jest.fn().mockReturnValue(undefined), + } as any; + }; + + describe('getNodeText', () => { + it('should return text for identifier nodes', () => { + const identifier = ts.factory.createIdentifier('testIdentifier'); + const result = getNodeText(identifier); + expect(result).toBe('testIdentifier'); + }); + + it('should return quoted text for string literal nodes', () => { + const stringLiteral = ts.factory.createStringLiteral('test string'); + const result = getNodeText(stringLiteral); + expect(result).toBe('"test string"'); + }); + + it('should return text for numeric literal nodes', () => { + const numericLiteral = ts.factory.createNumericLiteral('42'); + const result = getNodeText(numericLiteral); + expect(result).toBe('42'); + }); + + it('should handle property access expressions', () => { + const obj = ts.factory.createIdentifier('obj'); + const prop = ts.factory.createIdentifier('prop'); + const propertyAccess = ts.factory.createPropertyAccessExpression(obj, prop); + const result = getNodeText(propertyAccess); + expect(result).toBe('obj.prop'); + }); + + it('should handle synthetic nodes without source positions', () => { + const identifier = ts.factory.createIdentifier('synthetic'); + // Simulate a synthetic node by setting pos to -1 + (identifier as any).pos = -1; + const result = getNodeText(identifier); + expect(result).toBe('synthetic'); + }); + + it('should return syntax kind for unknown node types', () => { + const node = ts.factory.createVoidExpression(ts.factory.createNumericLiteral('0')); + const result = getNodeText(node); + expect(result).toContain('VoidExpression'); + }); + }); + + describe('getPropertyName', () => { + it('should extract text from identifier property names', () => { + const identifier = ts.factory.createIdentifier('propName'); + const result = getPropertyName(identifier); + expect(result).toBe('propName'); + }); + + it('should extract text from string literal property names', () => { + const stringLiteral = ts.factory.createStringLiteral('propName'); + const result = getPropertyName(stringLiteral); + expect(result).toBe('propName'); + }); + + it('should extract text from numeric literal property names', () => { + const numericLiteral = ts.factory.createNumericLiteral('42'); + const result = getPropertyName(numericLiteral); + expect(result).toBe('42'); + }); + + it('should return null for computed property names', () => { + const computedProp = ts.factory.createComputedPropertyName(ts.factory.createIdentifier('computed')); + const result = getPropertyName(computedProp); + expect(result).toBe(null); + }); + }); + + describe('getComputedPropertyValue', () => { + it('should return string value when tryResolveConstantValue returns a string', () => { + const mockTypeChecker = createMockTypeChecker(); + const identifier = ts.factory.createIdentifier('testIdentifier'); + + // Mock tryResolveConstantValue to return a string (this would be done by jest.spyOn in real scenarios) + const originalTryResolve = tryResolveConstantValue; + (tryResolveConstantValue as any) = jest.fn().mockReturnValue('resolvedString'); + + const result = getComputedPropertyValue(identifier, mockTypeChecker); + expect(result).toBe('resolvedString'); + + // Restore original function + (tryResolveConstantValue as any) = originalTryResolve; + }); + + it('should return null when tryResolveConstantValue returns non-string', () => { + const mockTypeChecker = createMockTypeChecker(); + const identifier = ts.factory.createIdentifier('testIdentifier'); + + const originalTryResolve = tryResolveConstantValue; + (tryResolveConstantValue as any) = jest.fn().mockReturnValue(42); + + const result = getComputedPropertyValue(identifier, mockTypeChecker); + expect(result).toBe(null); + + (tryResolveConstantValue as any) = originalTryResolve; + }); + + it('should return null when tryResolveConstantValue returns undefined', () => { + const mockTypeChecker = createMockTypeChecker(); + const identifier = ts.factory.createIdentifier('unknownVariable'); + + const originalTryResolve = tryResolveConstantValue; + (tryResolveConstantValue as any) = jest.fn().mockReturnValue(undefined); + + const result = getComputedPropertyValue(identifier, mockTypeChecker); + expect(result).toBe(null); + + (tryResolveConstantValue as any) = originalTryResolve; + }); + }); + + describe('tryResolveConstantValue', () => { + it('should resolve string literal values', () => { + const mockTypeChecker = createMockTypeChecker(); + const stringLiteral = ts.factory.createStringLiteral('test'); + const result = tryResolveConstantValue(stringLiteral, mockTypeChecker); + expect(result).toBe('test'); + }); + + it('should resolve numeric literal values', () => { + const mockTypeChecker = createMockTypeChecker(); + const numericLiteral = ts.factory.createNumericLiteral('42'); + const result = tryResolveConstantValue(numericLiteral, mockTypeChecker); + expect(result).toBe(42); + }); + + it('should resolve boolean literal values', () => { + const mockTypeChecker = createMockTypeChecker(); + const trueLiteral = ts.factory.createTrue(); + const falseLiteral = ts.factory.createFalse(); + + expect(tryResolveConstantValue(trueLiteral, mockTypeChecker)).toBe(true); + expect(tryResolveConstantValue(falseLiteral, mockTypeChecker)).toBe(false); + }); + + it('should resolve object literal expressions', () => { + const mockTypeChecker = createMockTypeChecker(); + const objLiteral = ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment('key1', ts.factory.createStringLiteral('value1')), + ts.factory.createPropertyAssignment('key2', ts.factory.createNumericLiteral('42')), + ]); + + const result = tryResolveConstantValue(objLiteral, mockTypeChecker); + expect(result).toEqual({ key1: 'value1', key2: 42 }); + }); + + it('should return undefined for unresolvable identifier expressions', () => { + const mockTypeChecker = createMockTypeChecker(); + const unknownId = ts.factory.createIdentifier('unknownVariable'); + const result = tryResolveConstantValue(unknownId, mockTypeChecker); + expect(result).toBe(undefined); + }); + + it('should return undefined for template expressions', () => { + const mockTypeChecker = createMockTypeChecker(); + const templateExpr = ts.factory.createTemplateExpression(ts.factory.createTemplateHead('start'), [ + ts.factory.createTemplateSpan(ts.factory.createIdentifier('variable'), ts.factory.createTemplateTail('end')), + ]); + const result = tryResolveConstantValue(templateExpr, mockTypeChecker); + expect(result).toBe(undefined); + }); + + it('should handle property access expressions with mock objects', () => { + const mockTypeChecker = createMockTypeChecker(); + + // Create a proper variable declaration mock + const objLiteral = ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment('CLICK', ts.factory.createStringLiteral('click')), + ]); + + const mockSymbol = { + valueDeclaration: ts.factory.createVariableDeclaration('EVENT_NAMES', undefined, undefined, objLiteral), + }; + + mockTypeChecker.getSymbolAtLocation = jest.fn().mockReturnValue(mockSymbol); + + const eventNamesId = ts.factory.createIdentifier('EVENT_NAMES'); + const clickId = ts.factory.createIdentifier('CLICK'); + const propertyAccess = ts.factory.createPropertyAccessExpression(eventNamesId, clickId); + + const result = tryResolveConstantValue(propertyAccess, mockTypeChecker); + expect(result).toBe('click'); + }); + + it('should handle null and undefined keywords', () => { + const mockTypeChecker = createMockTypeChecker(); + + // Create null literal expression + const nullToken = ts.factory.createNull(); + + // Create undefined expression (void 0) + const undefinedToken = ts.factory.createVoidZero(); + + expect(tryResolveConstantValue(nullToken, mockTypeChecker)).toBe(null); + expect(tryResolveConstantValue(undefinedToken, mockTypeChecker)).toBe(undefined); + }); + }); + + describe('tryResolveImportedConstant', () => { + it('should return undefined for non-imported identifiers', () => { + const mockTypeChecker = createMockTypeChecker(); + const localId = ts.factory.createIdentifier('localVariable'); + const result = tryResolveImportedConstant(localId, mockTypeChecker); + expect(result).toBe(undefined); + }); + + it('should return undefined for identifiers without symbols', () => { + const mockTypeChecker = createMockTypeChecker(); + const unknownId = ts.factory.createIdentifier('unknownImport'); + const result = tryResolveImportedConstant(unknownId, mockTypeChecker); + expect(result).toBe(undefined); + }); + + it('should handle aliased symbols', () => { + const mockTypeChecker = createMockTypeChecker(); + + // Mock an aliased symbol (from import) + const mockAliasedSymbol = { + valueDeclaration: { + getSourceFile: () => ({ fileName: 'external-module.ts' }), + }, + }; + + const mockSymbol = { + flags: ts.SymbolFlags.Alias, + valueDeclaration: undefined, + } as ts.Symbol; + + mockTypeChecker.getSymbolAtLocation = jest.fn().mockReturnValue(mockSymbol); + mockTypeChecker.getAliasedSymbol = jest.fn().mockReturnValue(mockAliasedSymbol); + + const importedId = ts.factory.createIdentifier('IMPORTED_CONST'); + const result = tryResolveImportedConstant(importedId, mockTypeChecker); + + // Since we can't fully mock the resolution without more setup, expect undefined + expect(result).toBe(undefined); + }); + + it('should handle property access on imported symbols', () => { + const mockTypeChecker = createMockTypeChecker(); + + const objId = ts.factory.createIdentifier('IMPORTED_OBJ'); + const propId = ts.factory.createIdentifier('PROP'); + const propertyAccess = ts.factory.createPropertyAccessExpression(objId, propId); + + const result = tryResolveImportedConstant(propertyAccess, mockTypeChecker); + expect(result).toBe(undefined); + }); + }); + + describe('edge cases', () => { + it('should handle malformed AST nodes gracefully', () => { + const mockTypeChecker = createMockTypeChecker(); + // Test with a node that has undefined properties + const malformedNode = {} as ts.Expression; + + const result = tryResolveConstantValue(malformedNode, mockTypeChecker); + expect(result).toBe(undefined); + }); + + it('should handle property access with non-identifier names', () => { + const mockTypeChecker = createMockTypeChecker(); + + // Create property access with computed property name + const obj = ts.factory.createIdentifier('obj'); + + // PropertyAccessExpression requires identifier, so this tests the fallback case + const propertyAccess = ts.factory.createPropertyAccessExpression(obj, obj); // Using obj as placeholder + + const result = tryResolveConstantValue(propertyAccess, mockTypeChecker); + + expect(result).toBe(undefined); + }); + + it('should handle identifiers with no symbol', () => { + const mockTypeChecker = createMockTypeChecker(); + const unknownId = ts.factory.createIdentifier('unknownVariable'); + + const result = tryResolveConstantValue(unknownId, mockTypeChecker); + expect(result).toBe(undefined); + }); + }); +}); diff --git a/src/compiler/transformers/test/constants-support.spec.ts b/src/compiler/transformers/test/constants-support.spec.ts new file mode 100644 index 00000000000..8ee0f84e921 --- /dev/null +++ b/src/compiler/transformers/test/constants-support.spec.ts @@ -0,0 +1,561 @@ +import { transpileModule } from './transpile'; + +describe('constants support in decorators', () => { + describe('@Event and @Listen decorator constant resolution', () => { + it('should work with enum values in @Event decorator', () => { + const t = transpileModule(` + enum EventType { + CUSTOM = 'customEvent' + } + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: EventType.CUSTOM }) customEvent: EventEmitter; + } + `); + + expect(t.event).toEqual({ + name: 'customEvent', + method: 'customEvent', + bubbles: true, + cancelable: true, + composed: true, + internal: false, + complexType: { + original: 'string', + resolved: 'string', + references: {}, + }, + docs: { + text: '', + tags: [], + }, + }); + }); + + it('should work with const variables in @Event decorator', () => { + const t = transpileModule(` + const EVENT_NAME = 'myCustomEvent'; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: EVENT_NAME }) customEvent: EventEmitter; + } + `); + + expect(t.event).toEqual({ + name: 'myCustomEvent', + method: 'customEvent', + bubbles: true, + cancelable: true, + composed: true, + internal: false, + complexType: { + original: 'string', + resolved: 'string', + references: {}, + }, + docs: { + text: '', + tags: [], + }, + }); + }); + + it('should work with nested object constants in @Event decorator', () => { + const t = transpileModule(` + const EVENTS = { + USER: { + LOGIN: 'userLogin', + LOGOUT: 'userLogout' + } + } as const; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: EVENTS.USER.LOGIN }) loginEvent: EventEmitter; + } + `); + + expect(t.event).toEqual({ + name: 'userLogin', + method: 'loginEvent', + bubbles: true, + cancelable: true, + composed: true, + internal: false, + complexType: { + original: 'string', + resolved: 'string', + references: {}, + }, + docs: { + text: '', + tags: [], + }, + }); + }); + + it('should work with enum values in @Listen decorator', () => { + const t = transpileModule(` + enum EventType { + CLICK = 'click' + } + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Listen(EventType.CLICK) + handleClick() { + console.log('clicked'); + } + } + `); + + expect(t.listeners).toEqual([ + { + name: 'click', + method: 'handleClick', + capture: false, + passive: false, + target: undefined, + }, + ]); + }); + + it('should work with const variables in @Listen decorator', () => { + const t = transpileModule(` + const EVENT_NAME = 'customEvent'; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Listen(EVENT_NAME) + handleEvent() { + console.log('event handled'); + } + } + `); + + expect(t.listeners).toEqual([ + { + name: 'customEvent', + method: 'handleEvent', + capture: false, + passive: false, + target: undefined, + }, + ]); + }); + + it('should work with nested object constants in @Listen decorator', () => { + const t = transpileModule(` + const EVENTS = { + USER: { + LOGIN: 'userLogin' + } + } as const; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Listen(EVENTS.USER.LOGIN) + handleLogin() { + console.log('user logged in'); + } + } + `); + + expect(t.listeners).toEqual([ + { + name: 'userLogin', + method: 'handleLogin', + capture: false, + passive: false, + target: undefined, + }, + ]); + }); + + it('should work with computed property constants', () => { + const t = transpileModule(` + const EVENTS = { LOGIN: 'computed-login-event' } as const; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: EVENTS.LOGIN }) loginEvent: EventEmitter; + } + `); + + // Should resolve the computed property constant + expect(t.event.name).toBe('computed-login-event'); + expect(t.cmp).toBeDefined(); + }); + + it('should work with deeply nested object constants in @Event decorator', () => { + const t = transpileModule(` + const APP_EVENTS = { + USER: { + AUTHENTICATION: { + LOGIN: 'user-auth-login', + LOGOUT: 'user-auth-logout' + } + } + } as const; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: APP_EVENTS.USER.AUTHENTICATION.LOGIN }) loginEvent: EventEmitter; + } + `); + + expect(t.event).toEqual({ + name: 'user-auth-login', + method: 'loginEvent', + bubbles: true, + cancelable: true, + composed: true, + internal: false, + complexType: { + original: 'string', + resolved: 'string', + references: {}, + }, + docs: { + text: '', + tags: [], + }, + }); + }); + + it('should work with template literal constants in @Event decorator', () => { + const t = transpileModule(` + const PREFIX = 'app'; + const ACTION = 'userLogin'; + const EVENT_NAME = \`\${PREFIX}:\${ACTION}\`; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: EVENT_NAME }) loginEvent: EventEmitter; + } + `); + + expect(t.event).toEqual({ + name: 'app:userLogin', + method: 'loginEvent', + bubbles: true, + cancelable: true, + composed: true, + internal: false, + complexType: { + original: 'string', + resolved: 'string', + references: {}, + }, + docs: { + text: '', + tags: [], + }, + }); + }); + + it('should work with object destructuring constants', () => { + const t = transpileModule(` + const EVENTS = { + USER_LOGIN: 'userLogin', + USER_LOGOUT: 'userLogout' + } as const; + + const { USER_LOGIN } = EVENTS; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: USER_LOGIN }) loginEvent: EventEmitter; + + @Listen(USER_LOGIN) + handleLogin() { + console.log('login handled'); + } + } + `); + + expect(t.event.name).toBe('userLogin'); + expect(t.listeners[0].name).toBe('userLogin'); + }); + + it('should work with both @Event and @Listen using the same constant', () => { + const t = transpileModule(` + const CUSTOM_EVENT = 'myCustomEvent'; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: CUSTOM_EVENT }) customEvent: EventEmitter; + + @Listen(CUSTOM_EVENT) + handleCustomEvent() { + console.log('custom event handled'); + } + } + `); + + expect(t.event.name).toBe('myCustomEvent'); + expect(t.listeners[0].name).toBe('myCustomEvent'); + }); + + it('should fall back to member name when constant cannot be resolved', () => { + const t = transpileModule(` + // This variable won't be resolvable at compile time + const dynamicEventName = Math.random() > 0.5 ? 'event1' : 'event2'; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: dynamicEventName }) fallbackEvent: EventEmitter; + } + `); + + // Should fall back to the member name when constant can't be resolved + expect(t.event.name).toBe('fallbackEvent'); + }); + + it('should handle mixed constant and literal usage', () => { + const t = transpileModule(` + const USER_EVENT = 'userAction'; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: USER_EVENT }) userEvent: EventEmitter; + @Event({ eventName: 'literalEvent' }) literalEvent: EventEmitter; + + @Listen(USER_EVENT) + handleUserEvent() {} + + @Listen('literalEvent') + handleLiteralEvent() {} + } + `); + + expect(t.events).toHaveLength(2); + expect(t.events.find((e) => e.method === 'userEvent')?.name).toBe('userAction'); + expect(t.events.find((e) => e.method === 'literalEvent')?.name).toBe('literalEvent'); + + expect(t.listeners).toHaveLength(2); + expect(t.listeners.find((l) => l.method === 'handleUserEvent')?.name).toBe('userAction'); + expect(t.listeners.find((l) => l.method === 'handleLiteralEvent')?.name).toBe('literalEvent'); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle undefined constants gracefully', () => { + const t = transpileModule(` + const UNDEFINED_CONST = undefined; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: UNDEFINED_CONST }) undefinedEvent: EventEmitter; + } + `); + + // Should fall back to member name when constant is undefined + expect(t.event.name).toBe('undefinedEvent'); + }); + + it('should handle null constants gracefully', () => { + const t = transpileModule(` + const NULL_CONST = null; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: NULL_CONST }) nullEvent: EventEmitter; + } + `); + + // Should fall back to member name when constant is null + expect(t.event.name).toBe('nullEvent'); + }); + }); + + describe('enhanced constant resolution without as const', () => { + it('should work with const variables without as const in @Listen decorator', () => { + const t = transpileModule(` + const EVENT_NAME = 'customEvent'; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Listen(EVENT_NAME) + handleEvent() { + console.log('event handled'); + } + } + `); + + expect(t.listeners).toEqual([ + { + name: 'customEvent', + method: 'handleEvent', + capture: false, + passive: false, + target: undefined, + }, + ]); + }); + + it('should work with object properties without as const in @Listen decorator', () => { + const t = transpileModule(` + const EVENT_NAMES = { + CLICK: 'click', + HOVER: 'hover' + }; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Listen(EVENT_NAMES.CLICK) + handleClick() { + console.log('clicked'); + } + + @Listen(EVENT_NAMES.HOVER) + handleHover() { + console.log('hovered'); + } + } + `); + + expect(t.listeners).toEqual([ + { + name: 'click', + method: 'handleClick', + capture: false, + passive: false, + target: undefined, + }, + { + name: 'hover', + method: 'handleHover', + capture: false, + passive: false, + target: undefined, + }, + ]); + }); + + it('should work with const variables without as const in @Event decorator', () => { + const t = transpileModule(` + const EVENT_NAME = 'myCustomEvent'; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: EVENT_NAME }) customEvent: EventEmitter; + } + `); + + expect(t.event).toEqual({ + name: 'myCustomEvent', + method: 'customEvent', + bubbles: true, + cancelable: true, + composed: true, + internal: false, + complexType: { + original: 'string', + resolved: 'string', + references: {}, + }, + docs: { + text: '', + tags: [], + }, + }); + }); + + it('should work with object properties without as const in @Event decorator', () => { + const t = transpileModule(` + const EVENT_NAMES = { + USER: { + LOGIN: 'userLogin', + LOGOUT: 'userLogout' + } + }; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: EVENT_NAMES.USER.LOGIN }) loginEvent: EventEmitter; + } + `); + + expect(t.event).toEqual({ + name: 'userLogin', + method: 'loginEvent', + bubbles: true, + cancelable: true, + composed: true, + internal: false, + complexType: { + original: 'string', + resolved: 'string', + references: {}, + }, + docs: { + text: '', + tags: [], + }, + }); + }); + + it('should work with number constants in decorators', () => { + const t = transpileModule(` + const TIMEOUT_MS = 5000; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ eventName: 'timeout', customEventInit: { timeout: TIMEOUT_MS } }) timeoutEvent: EventEmitter; + } + `); + + // Note: This test verifies the number constant is resolved, even if not directly used in eventName + expect(t.event.name).toBe('timeout'); + }); + + it('should work with boolean constants in decorators', () => { + const t = transpileModule(` + const BUBBLES = true; + const CANCELABLE = false; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Event({ + eventName: 'customEvent', + bubbles: BUBBLES, + cancelable: CANCELABLE + }) customEvent: EventEmitter; + } + `); + + expect(t.event.bubbles).toBe(true); + expect(t.event.cancelable).toBe(false); + }); + + it('should still work with as const for backward compatibility', () => { + const t = transpileModule(` + const EVENT_NAMES = { + CLICK: 'click', + HOVER: 'hover' + } as const; + + @Component({tag: 'cmp-a'}) + export class CmpA { + @Listen(EVENT_NAMES.CLICK) + handleClick() { + console.log('clicked'); + } + } + `); + + expect(t.listeners).toEqual([ + { + name: 'click', + method: 'handleClick', + capture: false, + passive: false, + target: undefined, + }, + ]); + }); + }); +}); diff --git a/src/compiler/transformers/test/decorator-utils.spec.ts b/src/compiler/transformers/test/decorator-utils.spec.ts index 6f778d41523..2aa6be1c9fd 100644 --- a/src/compiler/transformers/test/decorator-utils.spec.ts +++ b/src/compiler/transformers/test/decorator-utils.spec.ts @@ -1,6 +1,10 @@ import ts from 'typescript'; -import { getDecoratorParameters } from '../decorators-to-static/decorator-utils'; +import { + getDecoratorName, + getDecoratorParameter, + getDecoratorParameters, +} from '../decorators-to-static/decorator-utils'; describe('decorator utils', () => { describe('getDecoratorParameters', () => { @@ -50,5 +54,336 @@ describe('decorator utils', () => { expect(result).toEqual(['arg1']); }); + + describe('enhanced constant resolution', () => { + it('should resolve string literal types without as const', () => { + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => false, + flags: ts.TypeFlags.StringLiteral, + value: 'constValue', + })), + } as unknown as ts.TypeChecker; + + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('DecoratorName'), undefined, [ + ts.factory.createIdentifier('CONST_STRING'), + ]), + } as unknown as ts.Decorator; + + const result = getDecoratorParameters(decorator, typeCheckerMock); + + expect(result).toEqual(['constValue']); + }); + + it('should resolve number literal types', () => { + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => false, + flags: ts.TypeFlags.NumberLiteral, + value: 42, + })), + } as unknown as ts.TypeChecker; + + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('DecoratorName'), undefined, [ + ts.factory.createIdentifier('CONST_NUMBER'), + ]), + } as unknown as ts.Decorator; + + const result = getDecoratorParameters(decorator, typeCheckerMock); + + expect(result).toEqual([42]); + }); + + it('should resolve boolean literal types (true)', () => { + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => false, + flags: ts.TypeFlags.BooleanLiteral, + intrinsicName: 'true', + })), + } as unknown as ts.TypeChecker; + + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('DecoratorName'), undefined, [ + ts.factory.createIdentifier('CONST_TRUE'), + ]), + } as unknown as ts.Decorator; + + const result = getDecoratorParameters(decorator, typeCheckerMock); + + expect(result).toEqual([true]); + }); + + it('should resolve boolean literal types (false)', () => { + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => false, + flags: ts.TypeFlags.BooleanLiteral, + intrinsicName: 'false', + })), + } as unknown as ts.TypeChecker; + + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('DecoratorName'), undefined, [ + ts.factory.createIdentifier('CONST_FALSE'), + ]), + } as unknown as ts.Decorator; + + const result = getDecoratorParameters(decorator, typeCheckerMock); + + expect(result).toEqual([false]); + }); + + it('should resolve single literal from union type (as const pattern)', () => { + const literalType = { + flags: ts.TypeFlags.StringLiteral, + value: 'unionValue', + }; + + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => false, + flags: ts.TypeFlags.Union, + types: [literalType], + })), + } as unknown as ts.TypeChecker; + + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('DecoratorName'), undefined, [ + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('CONST_OBJ'), + ts.factory.createIdentifier('PROP'), + ), + ]), + } as unknown as ts.Decorator; + + const result = getDecoratorParameters(decorator, typeCheckerMock); + + expect(result).toEqual(['unionValue']); + }); + + it('should throw error for non-literal types with better message', () => { + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => false, + flags: ts.TypeFlags.String, // Generic string type, not literal + })), + } as unknown as ts.TypeChecker; + + // Create a proper identifier with getText method + const identifierNode = ts.factory.createIdentifier('dynamicValue'); + const mockGetText = jest.fn(() => 'dynamicValue'); + (identifierNode as any).getText = mockGetText; + + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('DecoratorName'), undefined, [ + identifierNode, + ]), + } as unknown as ts.Decorator; + + expect(() => getDecoratorParameters(decorator, typeCheckerMock)).toThrow( + 'invalid decorator argument: dynamicValue - must be a string literal, constant, or enum value', + ); + }); + + it('should throw error for union types with multiple literals', () => { + const literalType1 = { + flags: ts.TypeFlags.StringLiteral, + value: 'value1', + }; + const literalType2 = { + flags: ts.TypeFlags.StringLiteral, + value: 'value2', + }; + + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => false, + flags: ts.TypeFlags.Union, + types: [literalType1, literalType2], + })), + } as unknown as ts.TypeChecker; + + // Create a proper identifier with getText method + const identifierNode = ts.factory.createIdentifier('ambiguousUnion'); + const mockGetText = jest.fn(() => 'ambiguousUnion'); + (identifierNode as any).getText = mockGetText; + + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('DecoratorName'), undefined, [ + identifierNode, + ]), + } as unknown as ts.Decorator; + + expect(() => getDecoratorParameters(decorator, typeCheckerMock)).toThrow( + 'invalid decorator argument: ambiguousUnion - must be a string literal, constant, or enum value', + ); + }); + + it('should fallback to isLiteral for backward compatibility', () => { + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => true, + value: 'literalValue', + flags: 0, // No specific flags set + })), + } as unknown as ts.TypeChecker; + + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('DecoratorName'), undefined, [ + ts.factory.createIdentifier('enumValue'), + ]), + } as unknown as ts.Decorator; + + const result = getDecoratorParameters(decorator, typeCheckerMock); + + expect(result).toEqual(['literalValue']); + }); + }); + }); + + describe('getDecoratorName', () => { + it('should extract name from call expression decorator', () => { + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('Component'), undefined, []), + } as unknown as ts.Decorator; + + const result = getDecoratorName(decorator); + expect(result).toBe('Component'); + }); + + it('should extract name from identifier decorator', () => { + const decorator: ts.Decorator = { + expression: ts.factory.createIdentifier('Component'), + } as unknown as ts.Decorator; + + const result = getDecoratorName(decorator); + expect(result).toBe('Component'); + }); + + it('should return null for complex decorator expressions', () => { + const decorator: ts.Decorator = { + expression: ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('namespace'), + ts.factory.createIdentifier('Component'), + ), + } as unknown as ts.Decorator; + + const result = getDecoratorName(decorator); + expect(result).toBe(null); + }); + + it('should handle Event decorator', () => { + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('Event'), undefined, []), + } as unknown as ts.Decorator; + + const result = getDecoratorName(decorator); + expect(result).toBe('Event'); + }); + + it('should handle Listen decorator', () => { + const decorator: ts.Decorator = { + expression: ts.factory.createCallExpression(ts.factory.createIdentifier('Listen'), undefined, []), + } as unknown as ts.Decorator; + + const result = getDecoratorName(decorator); + expect(result).toBe('Listen'); + }); + }); + + describe('getDecoratorParameter', () => { + it('should handle string literals', () => { + const arg = ts.factory.createStringLiteral('test'); + const typeCheckerMock = {} as ts.TypeChecker; + + const result = getDecoratorParameter(arg, typeCheckerMock); + expect(result).toBe('test'); + }); + + it('should handle object literals with constant resolution enabled', () => { + const arg = ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment('key', ts.factory.createStringLiteral('value')), + ]); + const typeCheckerMock = {} as ts.TypeChecker; + + const result = getDecoratorParameter(arg, typeCheckerMock, true); + expect(result).toEqual({ key: 'value' }); + }); + + it('should handle object literals with constant resolution disabled', () => { + const arg = ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment('key', ts.factory.createStringLiteral('value')), + ]); + const typeCheckerMock = {} as ts.TypeChecker; + + const result = getDecoratorParameter(arg, typeCheckerMock, false); + expect(result).toEqual({ key: 'value' }); + }); + + it('should handle identifiers with constant resolution enabled', () => { + const arg = ts.factory.createIdentifier('CONST_VALUE'); + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => true, + value: 'resolvedValue', + })), + getSymbolAtLocation: jest.fn((): any => undefined), + } as unknown as ts.TypeChecker; + + const result = getDecoratorParameter(arg, typeCheckerMock, true); + expect(result).toBe('resolvedValue'); + }); + + it('should handle identifiers with constant resolution disabled', () => { + const arg = ts.factory.createIdentifier('CONST_VALUE'); + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => true, + value: 'resolvedValue', + })), + } as unknown as ts.TypeChecker; + + const result = getDecoratorParameter(arg, typeCheckerMock, false); + expect(result).toBe('resolvedValue'); + }); + + it('should handle property access expressions', () => { + const arg = ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('ENUM'), + ts.factory.createIdentifier('VALUE'), + ); + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => true, + value: 'enumValue', + })), + getSymbolAtLocation: jest.fn((): any => undefined), + } as unknown as ts.TypeChecker; + + const result = getDecoratorParameter(arg, typeCheckerMock); + expect(result).toBe('enumValue'); + }); + + it('should fallback gracefully for constant resolution when it fails', () => { + const arg = ts.factory.createIdentifier('unknownValue'); + const mockGetText = jest.fn(() => 'unknownValue'); + (arg as any).getText = mockGetText; + (arg as any).getSourceFile = jest.fn(() => ({ fileName: 'test.ts' })); + (arg as any).pos = 0; + + const typeCheckerMock = { + getTypeAtLocation: jest.fn(() => ({ + isLiteral: () => false, + flags: ts.TypeFlags.String, + })), + getSymbolAtLocation: jest.fn((): any => undefined), + } as unknown as ts.TypeChecker; + + const result = getDecoratorParameter(arg, typeCheckerMock, true); + expect(result).toBe('unknownValue'); + }); }); }); diff --git a/src/compiler/transformers/transform-utils.ts b/src/compiler/transformers/transform-utils.ts index 07e69cec380..7c56134f470 100644 --- a/src/compiler/transformers/transform-utils.ts +++ b/src/compiler/transformers/transform-utils.ts @@ -2,6 +2,7 @@ import { normalizePath } from '@utils'; import ts from 'typescript'; import type * as d from '../../declarations'; +import { tryResolveConstantValue, tryResolveImportedConstant } from './decorators-to-static/constant-resolution-utils'; import { StencilStaticGetter } from './decorators-to-static/decorators-constants'; import { addToLibrary, findTypeWithName, getHomeModule, getOriginalTypeName } from './type-library'; @@ -281,6 +282,13 @@ export const arrayLiteralToArray = (arr: ts.ArrayLiteralExpression) => { }); }; +/** + * Convert a TypeScript object literal expression to a JavaScript object map. + * Preserves variable references as identifiers for style processing. + * + * @param objectLiteral - The TypeScript object literal expression to convert + * @returns JavaScript object with preserved variable references + */ export const objectLiteralToObjectMap = (objectLiteral: ts.ObjectLiteralExpression) => { const properties = objectLiteral.properties; const final: ObjectMap = {}; @@ -330,11 +338,145 @@ export const objectLiteralToObjectMap = (objectLiteral: ts.ObjectLiteralExpressi } else if (escapedText === 'null') { val = null; } else { - val = getIdentifierValue((propAssignment.initializer as ts.Identifier).escapedText); + val = getIdentifierValue(escapedText); + } + break; + + case ts.SyntaxKind.PropertyAccessExpression: + val = propAssignment.initializer; + break; + + default: + val = propAssignment.initializer; + } + } + final[propName] = val; + } + + return final; +}; + +/** + * Enhanced version of objectLiteralToObjectMap that resolves constants using TypeScript type checker. + * Used specifically for decorator parameter resolution where we want to resolve constants to their values. + * + * @param objectLiteral - The TypeScript object literal expression to convert + * @param typeChecker - TypeScript type checker for resolving constants + * @returns JavaScript object with resolved constant values + */ +export const objectLiteralToObjectMapWithConstants = ( + objectLiteral: ts.ObjectLiteralExpression, + typeChecker: ts.TypeChecker, +) => { + const properties = objectLiteral.properties; + const final: ObjectMap = {}; + + for (const propAssignment of properties) { + const propName = getTextOfPropertyName(propAssignment.name); + let val: any; + + if (ts.isShorthandPropertyAssignment(propAssignment)) { + val = getIdentifierValue(propName); + } else if (ts.isPropertyAssignment(propAssignment)) { + switch (propAssignment.initializer.kind) { + case ts.SyntaxKind.ArrayLiteralExpression: + val = arrayLiteralToArray(propAssignment.initializer as ts.ArrayLiteralExpression); + break; + + case ts.SyntaxKind.ObjectLiteralExpression: + val = objectLiteralToObjectMapWithConstants( + propAssignment.initializer as ts.ObjectLiteralExpression, + typeChecker, + ); + break; + + case ts.SyntaxKind.StringLiteral: + val = (propAssignment.initializer as ts.StringLiteral).text; + break; + + case ts.SyntaxKind.NoSubstitutionTemplateLiteral: + val = (propAssignment.initializer as ts.StringLiteral).text; + break; + + case ts.SyntaxKind.TrueKeyword: + val = true; + break; + + case ts.SyntaxKind.FalseKeyword: + val = false; + break; + + case ts.SyntaxKind.Identifier: + const escapedText = (propAssignment.initializer as ts.Identifier).escapedText; + if (escapedText === 'String') { + val = String; + } else if (escapedText === 'Number') { + val = Number; + } else if (escapedText === 'Boolean') { + val = Boolean; + } else if (escapedText === 'undefined') { + val = undefined; + } else if (escapedText === 'null') { + val = null; + } else { + // Enhanced: Use our advanced constant resolution from decorator-utils + try { + // First try basic literal type check + const type = typeChecker.getTypeAtLocation(propAssignment.initializer); + if (type && type.isLiteral()) { + val = type.value; + } else { + // Try enhanced constant resolution + const constantValue = tryResolveConstantValue(propAssignment.initializer, typeChecker); + if (constantValue !== undefined) { + val = constantValue; + } else { + // Try imported constant resolution + const importValue = tryResolveImportedConstant(propAssignment.initializer, typeChecker); + if (importValue !== undefined) { + val = importValue; + } else { + // Fall back to original behavior + val = getIdentifierValue(escapedText); + } + } + } + } catch { + // Fall back to original behavior if enhanced resolution fails + val = getIdentifierValue(escapedText); + } } break; case ts.SyntaxKind.PropertyAccessExpression: + // Enhanced: Use our advanced constant resolution from decorator-utils + try { + // First try basic literal type check + const type = typeChecker.getTypeAtLocation(propAssignment.initializer); + if (type && type.isLiteral()) { + val = type.value; + } else { + // Try enhanced constant resolution + const constantValue = tryResolveConstantValue(propAssignment.initializer, typeChecker); + if (constantValue !== undefined) { + val = constantValue; + } else { + // Try imported constant resolution + const importValue = tryResolveImportedConstant(propAssignment.initializer, typeChecker); + if (importValue !== undefined) { + val = importValue; + } else { + // Fall back to original behavior + val = propAssignment.initializer; + } + } + } + } catch { + // Fall back to original behavior if enhanced resolution fails + val = propAssignment.initializer; + } + break; + default: val = propAssignment.initializer; } diff --git a/src/declarations/stencil-public-runtime.ts b/src/declarations/stencil-public-runtime.ts index 8417ab76ef1..12698c829cc 100644 --- a/src/declarations/stencil-public-runtime.ts +++ b/src/declarations/stencil-public-runtime.ts @@ -118,6 +118,21 @@ export interface EventDecorator { export interface EventOptions { /** * A string custom event name to override the default. + * Can be a string literal, const variable, or nested object constant. + * + * @example + * ```typescript + * // String literal + * @Event({ eventName: 'myEvent' }) + * + * // Const variable + * const EVENT_NAME = 'myEvent'; + * @Event({ eventName: EVENT_NAME }) + * + * // Nested object constant + * const EVENTS = { USER: { LOGIN: 'userLogin' } } as const; + * @Event({ eventName: EVENTS.USER.LOGIN }) + * ``` */ eventName?: string; /** @@ -141,6 +156,24 @@ export interface AttachInternalsDecorator { } export interface ListenDecorator { + /** + * @param eventName - The event name to listen for. Can be a string literal, + * const variable, or nested object constant. + * + * @example + * ```typescript + * // String literal + * @Listen('click') + * + * // Const variable + * const EVENT_NAME = 'customEvent'; + * @Listen(EVENT_NAME) + * + * // Nested object constant + * const EVENTS = { USER: { LOGIN: 'userLogin' } } as const; + * @Listen(EVENTS.USER.LOGIN) + * ``` + */ (eventName: string, opts?: ListenOptions): CustomMethodDecorator; } export interface ListenOptions { diff --git a/test/wdio/dynamic-imports/cmp.test.tsx b/test/wdio/dynamic-imports/cmp.test.tsx index 0c14a993c22..bf3e1b88dba 100644 --- a/test/wdio/dynamic-imports/cmp.test.tsx +++ b/test/wdio/dynamic-imports/cmp.test.tsx @@ -2,9 +2,38 @@ import { h } from '@stencil/core'; import { render } from '@wdio/browser-runner/stencil'; import { $, expect } from '@wdio/globals'; +// @ts-expect-error will be resolved by WDIO +import { defineCustomElement } from '/test-components/dynamic-import.js'; + import type { DynamicImport } from './dynamic-import.js'; -describe('tag-names', () => { +// Manually define the component since it's excluded from auto-loading +// to prevent pre-loading that would increment module state counters +defineCustomElement(); + +/** + * Dynamic Import Feature Tests + * + * This test suite validates Stencil's support for dynamic imports (import()) within components. + * Dynamic imports are crucial for: + * + * 1. **Code Splitting**: Breaking large bundles into smaller, lazily-loaded chunks + * 2. **Performance**: Loading code only when needed, reducing initial bundle size + * 3. **Runtime Module Loading**: Conditionally loading modules based on user actions or conditions + * 4. **Tree Shaking**: Better elimination of unused code when modules are loaded on-demand + * + * The test component chain demonstrates: + * - module1.js dynamically imports module3.js + * - module1.js statically imports module2.js + * - Each module maintains its own state counter + * - Multiple calls increment counters independently + * + * This pattern is commonly used in real applications for features like: + * - Loading heavy libraries only when specific features are accessed + * - Implementing plugin architectures with runtime module loading + * - Progressive enhancement where advanced features load on-demand + */ +describe('dynamic-imports', () => { beforeEach(() => { render({ components: [], @@ -12,12 +41,33 @@ describe('tag-names', () => { }); }); + /** + * Tests that dynamic imports work correctly with proper state management. + * + * Expected behavior: + * 1. First load (componentWillLoad): State counters start at 0, increment to 1 + * - module3 state: 0→1, module2 state: 0→1, module1 state: 0→1 + * - Result: "1 hello1 world1" + * + * 2. Second load (manual update): State counters increment from 1 to 2 + * - module3 state: 1→2, module2 state: 1→2, module1 state: 1→2 + * - Result: "2 hello2 world2" + * + * This verifies that: + * - Dynamic imports successfully load and execute modules + * - Module state persists between dynamic import calls (as expected in browsers) + * - Multiple invocations work correctly without module re-initialization + * - The import() promise resolves with the correct module exports + */ it('should load content from dynamic import', async () => { + // First load: componentWillLoad triggers, counters go from 0→1 await expect($('dynamic-import').$('div')).toHaveText('1 hello1 world1'); + // Manually trigger update to test dynamic import again const dynamicImport = document.querySelector('dynamic-import') as unknown as HTMLElement & DynamicImport; dynamicImport.update(); + // Second load: counters go from 1→2, demonstrating module state persistence await expect($('dynamic-import').$('div')).toHaveText('2 hello2 world2'); }); }); diff --git a/test/wdio/prerender-test/cmp.test.tsx b/test/wdio/prerender-test/cmp.test.tsx index a32885f38ed..0df7b51ae36 100644 --- a/test/wdio/prerender-test/cmp.test.tsx +++ b/test/wdio/prerender-test/cmp.test.tsx @@ -61,30 +61,72 @@ describe('prerender', () => { it('server componentWillLoad Order', async () => { const elm = await browser.waitUntil(() => iframe.querySelector('#server-componentWillLoad')); - expect(elm.innerText).toMatchInlineSnapshot(` - "CmpA server componentWillLoad - CmpD - a1-child server componentWillLoad - CmpD - a2-child server componentWillLoad - CmpD - a3-child server componentWillLoad - CmpD - a4-child server componentWillLoad - CmpB server componentWillLoad - CmpC server componentWillLoad - CmpD - c-child server componentWillLoad" - `); + + // Verify the element exists and has content + expect(elm).toBeTruthy(); + expect(elm.innerText).toContain('CmpA server componentWillLoad'); + + // Verify component hierarchy - parent loads before children + const loadOrder = elm.innerText + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + // CmpA should load first (root component) + expect(loadOrder[0]).toBe('CmpA server componentWillLoad'); + + // CmpA's children (CmpD instances) should load next + const cmpAChildren = loadOrder.slice(1, 5); + cmpAChildren.forEach((child, index) => { + expect(child).toBe(`CmpD - a${index + 1}-child server componentWillLoad`); + }); + + // CmpB should load after CmpA's children + expect(loadOrder[5]).toBe('CmpB server componentWillLoad'); + + // CmpC should load after CmpB + expect(loadOrder[6]).toBe('CmpC server componentWillLoad'); + + // CmpC's child should load last + expect(loadOrder[7]).toBe('CmpD - c-child server componentWillLoad'); + + // Verify total count matches expected structure + expect(loadOrder).toHaveLength(8); }); it('server componentDidLoad Order', async () => { const elm = await browser.waitUntil(() => iframe.querySelector('#server-componentDidLoad')); - expect(elm.innerText).toMatchInlineSnapshot(` - "CmpD - a1-child server componentDidLoad - CmpD - a2-child server componentDidLoad - CmpD - a3-child server componentDidLoad - CmpD - a4-child server componentDidLoad - CmpD - c-child server componentDidLoad - CmpC server componentDidLoad - CmpB server componentDidLoad - CmpA server componentDidLoad" - `); + + // Verify the element exists and has content + expect(elm).toBeTruthy(); + expect(elm.innerText).toContain('componentDidLoad'); + + // Verify component hierarchy - children load before parents (reverse of componentWillLoad) + const loadOrder = elm.innerText + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + // CmpA's children (CmpD instances) should load first + const cmpAChildren = loadOrder.slice(0, 4); + cmpAChildren.forEach((child, index) => { + expect(child).toBe(`CmpD - a${index + 1}-child server componentDidLoad`); + }); + + // CmpC's child should load next + expect(loadOrder[4]).toBe('CmpD - c-child server componentDidLoad'); + + // CmpC should load after its child + expect(loadOrder[5]).toBe('CmpC server componentDidLoad'); + + // CmpB should load after CmpC + expect(loadOrder[6]).toBe('CmpB server componentDidLoad'); + + // CmpA should load last (root component) + expect(loadOrder[7]).toBe('CmpA server componentDidLoad'); + + // Verify total count matches expected structure + expect(loadOrder).toHaveLength(8); }); it('correct scoped styles applied after scripts kick in', async () => { diff --git a/test/wdio/setup.ts b/test/wdio/setup.ts index cef731fcbf3..f3f001db4c9 100644 --- a/test/wdio/setup.ts +++ b/test/wdio/setup.ts @@ -19,7 +19,10 @@ const testRequiresManualSetup = window.__wdioSpec__.endsWith('page-list.test.ts') || window.__wdioSpec__.endsWith('event-re-register.test.tsx') || window.__wdioSpec__.endsWith('render.test.tsx') || - window.__wdioSpec__.endsWith('global-styles.test.tsx'); + window.__wdioSpec__.endsWith('global-styles.test.tsx') || + // Exclude dynamic-import tests to prevent auto-loading components that maintain module state + // Auto-loading during setup would increment state counters before tests run, causing test failures + window.__wdioSpec__.includes('dynamic-imports/cmp.test.tsx'); /** * setup all components defined in tests except for those where we want to manually setup diff --git a/test/wdio/shared-events/cmp.test.tsx b/test/wdio/shared-events/cmp.test.tsx new file mode 100644 index 00000000000..eb64ec80ae8 --- /dev/null +++ b/test/wdio/shared-events/cmp.test.tsx @@ -0,0 +1,74 @@ +import { h } from '@stencil/core'; +import { render } from '@wdio/browser-runner/stencil'; +import { $, expect } from '@wdio/globals'; + +describe('shared-events', () => { + beforeAll(async () => { + render({ + components: [], + template: () => ( +
+ +
+ +
+ ), + }); + }); + + // Clear events before each test to start fresh + beforeEach(async () => { + await $('#a-clear').click(); + await $('#e-clear').click(); + // Clear child components to prevent memory accumulation + const bClear = await $('#b-clear'); + if (await bClear.isExisting()) await bClear.click(); + const cClear = await $('#c-clear'); + if (await cClear.isExisting()) await cClear.click(); + const dClear = await $('#d-clear'); + if (await dClear.isExisting()) await dClear.click(); + }); + + // Clear events after each test to prevent memory leaks + afterEach(async () => { + await $('#a-clear').click(); + await $('#e-clear').click(); + }); + + describe('Memory-optimized event communication', () => { + it('should render components and handle basic A→B communication', async () => { + // Verify components rendered + await expect($('parent-a')).toBePresent(); + await expect($('unrelated-e')).toBePresent(); + + // Test A→B communication + await $('#a-to-b').click(); + await expect($('#sibling-b-events')).toHaveAttribute('data-event-count', '1'); + }); + + it('should handle shared event propagation across all components', async () => { + // Fire shared event from Parent A + await $('#a-shared').click(); + + // All components should receive the shared event + await expect($('#event-count')).toContain('Events received: 1'); + await expect($('#sibling-b-events')).toHaveAttribute('data-event-count', '1'); + await expect($('#sibling-c-events')).toHaveAttribute('data-event-count', '1'); + await expect($('#unrelated-e-events')).toHaveAttribute('data-event-count', '1'); + }); + + it('should handle cross-family communication and clear functionality', async () => { + // Test A→E and E→A communication + await $('#a-to-e').click(); + await $('#e-to-a').click(); + + // Both should receive events + await expect($('#event-count')).toContain('Events received: 1'); + await expect($('#unrelated-e-events')).toHaveAttribute('data-event-count', '1'); + + // Test clear functionality + await $('#a-clear').click(); + await expect($('#event-count')).toContain('Events received: 0'); + }); + }); +}); diff --git a/test/wdio/shared-events/event-constants.ts b/test/wdio/shared-events/event-constants.ts new file mode 100644 index 00000000000..a5569f9ae32 --- /dev/null +++ b/test/wdio/shared-events/event-constants.ts @@ -0,0 +1,45 @@ +/** + * Shared event names object for cross-component communication + */ +export const EVENT_NAMES = { + A_TO_B: 'aToB', + A_TO_C: 'aToC', + A_TO_D: 'aToD', + A_TO_E: 'aToE', + A_TO_BC: 'aToBc', + A_TO_BD: 'aToBd', + A_TO_BE: 'aToBe', + B_TO_A: 'bToA', + B_TO_C: 'bToC', + B_TO_D: 'bToD', + B_TO_E: 'bToE', + B_TO_AC: 'bToAc', + B_TO_AD: 'bToAd', + B_TO_AE: 'bToAe', + C_TO_A: 'cToA', + C_TO_B: 'cToB', + C_TO_D: 'cToD', + C_TO_E: 'cToE', + C_TO_AB: 'cToAb', + C_TO_AD: 'cToAd', + C_TO_AE: 'cToAe', + D_TO_A: 'dToA', + D_TO_B: 'dToB', + D_TO_C: 'dToC', + D_TO_E: 'dToE', + D_TO_AB: 'dToAb', + D_TO_AC: 'dToAc', + D_TO_AE: 'dToAe', + E_TO_A: 'eToA', + E_TO_B: 'eToB', + E_TO_C: 'eToC', + E_TO_D: 'eToD', + E_TO_AB: 'eToAb', + E_TO_AC: 'eToAc', + E_TO_AD: 'eToAd', +} as const; + +/** + * Single event constant for testing import patterns + */ +export const SHARED_EVENT = 'sharedCustomEvent'; diff --git a/test/wdio/shared-events/nested-child-d.tsx b/test/wdio/shared-events/nested-child-d.tsx new file mode 100644 index 00000000000..4ffa1f90256 --- /dev/null +++ b/test/wdio/shared-events/nested-child-d.tsx @@ -0,0 +1,181 @@ +import { Component, Element, Event, EventEmitter, h, Listen, State } from '@stencil/core'; + +// Import from TypeScript file using .js extension (Node16/NodeNext module resolution) +// The source file is event-constants.ts, but we use .js to reference the compiled output +import { EVENT_NAMES, SHARED_EVENT } from './event-constants.js'; + +@Component({ + tag: 'nested-child-d', + scoped: true, +}) +export class NestedChildD { + @Element() el!: HTMLElement; + + // Store events in memory only (not state) to prevent re-renders + private receivedEvents: string[] = []; + // Keep eventCount as state so it displays in DOM + @State() eventCount = 0; + + /** + * Events emitted by Nested Child D + */ + @Event({ eventName: EVENT_NAMES.D_TO_A }) toParentA!: EventEmitter; + @Event({ eventName: EVENT_NAMES.D_TO_B }) toSiblingB!: EventEmitter; + @Event({ eventName: EVENT_NAMES.D_TO_C }) toSiblingC!: EventEmitter; + @Event({ eventName: EVENT_NAMES.D_TO_E }) toExternal!: EventEmitter; + @Event({ eventName: EVENT_NAMES.D_TO_AB }) toParentAndB!: EventEmitter; + @Event({ eventName: EVENT_NAMES.D_TO_AC }) toParentAndC!: EventEmitter; + + disconnectedCallback() { + this.receivedEvents.length = 0; + this.eventCount = 0; + } + + /** + * Listen for events from other components + */ + + /** + * Listen for events from Parent A + * @param event - The event from Parent A + */ + @Listen(EVENT_NAMES.A_TO_D) + onFromParentA(event: CustomEvent) { + this.addReceivedEvent(`A→D: ${event.detail}`); + } + + /** + * Listen for events from Sibling B + * @param event - The event from Sibling B + */ + @Listen(EVENT_NAMES.B_TO_D) + onFromSiblingB(event: CustomEvent) { + this.addReceivedEvent(`B→D: ${event.detail}`); + } + + /** + * Listen for events from Sibling C + * @param event - The event from Sibling C + */ + @Listen(EVENT_NAMES.C_TO_D) + onFromSiblingC(event: CustomEvent) { + this.addReceivedEvent(`C→D: ${event.detail}`); + } + + /** + * Listen for events from External E + * @param event - The event from External E + */ + @Listen(EVENT_NAMES.E_TO_D) + onFromExternal(event: CustomEvent) { + this.addReceivedEvent(`E→D: ${event.detail}`); + } + + /** + * Listen for shared event + * @param event - The event from the shared event + */ + @Listen(SHARED_EVENT) + onSharedEvent(event: CustomEvent) { + this.addReceivedEvent(`SharedEvent: ${event.detail}`); + } + + private addReceivedEvent(message: string) { + this.receivedEvents.push(message); + this.eventCount++; + } + + /** + * D fires events to different components + */ + private fireToParentA = () => { + this.toParentA.emit('D→A: Message from Nested Child D to Parent A'); + }; + + private fireToSiblingB = () => { + this.toSiblingB.emit('D→B: Message from Nested Child D to Sibling B'); + }; + + private fireToSiblingC = () => { + this.toSiblingC.emit('D→C: Message from Nested Child D to Sibling C'); + }; + + private fireToExternal = () => { + this.toExternal.emit('D→E: Message from Nested Child D to External E'); + }; + + private fireToParentAndB = () => { + this.toParentAndB.emit('D→AB: Message from Nested Child D to Parent A and Sibling B'); + }; + + private fireToParentAndC = () => { + this.toParentAndC.emit('D→AC: Message from Nested Child D to Parent A and Sibling C'); + }; + + /** + * D fires shared event + */ + private fireSharedEvent = () => { + const event = new CustomEvent(SHARED_EVENT, { + detail: 'D→SharedEvent: Nested Child D using shared event', + bubbles: true, + }); + this.el.dispatchEvent(event); + }; + + private clearEvents = () => { + this.receivedEvents.length = 0; + this.eventCount = 0; + }; + + // Public method for tests to access received events + getReceivedEvents(): string[] { + return [...this.receivedEvents]; + } + + getEventCount(): number { + return this.eventCount; + } + + render() { + return ( +
+
Nested Child D Component
+ +
+ + + + + + + + +
+ +
+
Nested Child D - Events in Memory
+
+ Events stored in memory (not rendered to save performance) +
+
+
+ ); + } +} diff --git a/test/wdio/shared-events/parent-a.tsx b/test/wdio/shared-events/parent-a.tsx new file mode 100644 index 00000000000..ac25e9bbdc9 --- /dev/null +++ b/test/wdio/shared-events/parent-a.tsx @@ -0,0 +1,179 @@ +import { Component, Element, Event, EventEmitter, h, Listen, State } from '@stencil/core'; + +// Import from TypeScript file using .js extension (Node16/NodeNext module resolution) +// The source file is event-constants.ts, but we use .js to reference the compiled output +import { EVENT_NAMES, SHARED_EVENT } from './event-constants.js'; + +@Component({ + tag: 'parent-a', + scoped: true, +}) +export class ParentA { + @Element() el!: HTMLElement; + + // Store events in memory only (not state) to prevent re-renders + private receivedEvents: string[] = []; + // Keep eventCount as state so it displays in DOM + @State() eventCount = 0; + + /** + * Events emitted by Parent A + */ + @Event({ eventName: EVENT_NAMES.A_TO_BC }) toBothSiblings!: EventEmitter; + @Event({ eventName: EVENT_NAMES.A_TO_B }) toSiblingB!: EventEmitter; + @Event({ eventName: EVENT_NAMES.A_TO_C }) toSiblingC!: EventEmitter; + @Event({ eventName: EVENT_NAMES.A_TO_D }) toNestedChild!: EventEmitter; + @Event({ eventName: EVENT_NAMES.A_TO_E }) toExternal!: EventEmitter; + + disconnectedCallback() { + this.receivedEvents.length = 0; + this.eventCount = 0; + } + + /** + * Listen for events from other components + */ + + /** + * Listen for events from Sibling B + * @param event - The event from Sibling B + */ + @Listen(EVENT_NAMES.B_TO_A) + onFromSiblingB(event: CustomEvent) { + this.addReceivedEvent(`B→A: ${event.detail}`); + } + + /** + * Listen for events from Sibling C + * @param event - The event from Sibling C + */ + @Listen(EVENT_NAMES.C_TO_A) + onFromSiblingC(event: CustomEvent) { + this.addReceivedEvent(`C→A: ${event.detail}`); + } + + /** + * Listen for events from Nested Child D + * @param event - The event from Nested Child D + */ + @Listen(EVENT_NAMES.D_TO_A) + onFromNestedChild(event: CustomEvent) { + this.addReceivedEvent(`D→A: ${event.detail}`); + } + + /** + * Listen for events from External E + * @param event - The event from External E + */ + @Listen(EVENT_NAMES.E_TO_A) + onFromExternal(event: CustomEvent) { + this.addReceivedEvent(`E→A: ${event.detail}`); + } + + /** + * Listen for the shared event constant + * @param event - The event from the shared event + */ + @Listen(SHARED_EVENT) + onSharedEvent(event: CustomEvent) { + this.addReceivedEvent(`SharedEvent: ${event.detail}`); + } + + private addReceivedEvent(message: string) { + this.receivedEvents.push(message); + this.eventCount++; + } + + /** + * A fires events to different components + */ + private fireToBothSiblings = () => { + this.toBothSiblings.emit('A→BC: Message from Parent A to both siblings'); + }; + + private fireToSiblingB = () => { + this.toSiblingB.emit('A→B: Message from Parent A to Sibling B'); + }; + + private fireToSiblingC = () => { + this.toSiblingC.emit('A→C: Message from Parent A to Sibling C'); + }; + + private fireToNestedChild = () => { + this.toNestedChild.emit('A→D: Message from Parent A to Nested Child D'); + }; + + private fireToExternal = () => { + this.toExternal.emit('A→E: Message from Parent A to External E'); + }; + + /** + * A fires shared event + */ + private fireSharedEvent = () => { + const event = new CustomEvent(SHARED_EVENT, { + detail: 'A→SharedEvent: Parent A using shared event', + bubbles: true, + }); + this.el.dispatchEvent(event); + }; + + private clearEvents = () => { + this.receivedEvents.length = 0; + this.eventCount = 0; + }; + + // Public method for tests to access received events + getReceivedEvents(): string[] { + return [...this.receivedEvents]; + } + + getEventCount(): number { + return this.eventCount; + } + + render() { + return ( +
+

Parent Component A

+ +
+ + + + + + + +
+ +
+

Parent Component A - Events in Memory

+
+ Events stored in memory (not rendered to save performance) +
+
Events received: {this.eventCount}
+
+ +
+ + +
+
+ ); + } +} diff --git a/test/wdio/shared-events/sibling-b.tsx b/test/wdio/shared-events/sibling-b.tsx new file mode 100644 index 00000000000..8f2731729d2 --- /dev/null +++ b/test/wdio/shared-events/sibling-b.tsx @@ -0,0 +1,182 @@ +import { Component, Element, Event, EventEmitter, h, Listen, State } from '@stencil/core'; + +// Import from TypeScript file using .js extension (Node16/NodeNext module resolution) +// The source file is event-constants.ts, but we use .js to reference the compiled output +import { EVENT_NAMES, SHARED_EVENT } from './event-constants.js'; + +@Component({ + tag: 'sibling-b', + scoped: true, +}) +export class SiblingB { + @Element() el!: HTMLElement; + + // Store events in memory only (not state) to prevent re-renders + private receivedEvents: string[] = []; + // Keep eventCount as state so it displays in DOM + @State() eventCount = 0; + + /** + * Events emitted by Sibling B + */ + @Event({ eventName: EVENT_NAMES.B_TO_A }) toParentA!: EventEmitter; + @Event({ eventName: EVENT_NAMES.B_TO_C }) toSiblingC!: EventEmitter; + @Event({ eventName: EVENT_NAMES.B_TO_D }) toNestedChild!: EventEmitter; + @Event({ eventName: EVENT_NAMES.B_TO_E }) toExternal!: EventEmitter; + @Event({ eventName: EVENT_NAMES.B_TO_AC }) toParentAndSibling!: EventEmitter; + + disconnectedCallback() { + this.receivedEvents.length = 0; + this.eventCount = 0; + } + + /** + * Listen for events from other components + */ + + /** + * Listen for events from Parent A + * @param event - The event from Parent A + */ + @Listen(EVENT_NAMES.A_TO_B) + onFromParentA(event: CustomEvent) { + this.addReceivedEvent(`A→B: ${event.detail}`); + } + + /** + * Listen for events from Parent A + * @param event - The event from Parent A + */ + @Listen(EVENT_NAMES.A_TO_BC) + onFromParentToBoth(event: CustomEvent) { + this.addReceivedEvent(`A→BC: ${event.detail}`); + } + + /** + * Listen for events from Sibling C + * @param event - The event from Sibling C + */ + @Listen(EVENT_NAMES.C_TO_B) + onFromSiblingC(event: CustomEvent) { + this.addReceivedEvent(`C→B: ${event.detail}`); + } + + /** + * Listen for events from Nested Child D + * @param event - The event from Nested Child D + */ + @Listen(EVENT_NAMES.D_TO_B) + onFromNestedChild(event: CustomEvent) { + this.addReceivedEvent(`D→B: ${event.detail}`); + } + + /** + * Listen for events from External E + * @param event - The event from External E + */ + @Listen(EVENT_NAMES.E_TO_B) + onFromExternal(event: CustomEvent) { + this.addReceivedEvent(`E→B: ${event.detail}`); + } + + /** + * Listen for shared event + * @param event - The event from the shared event + */ + @Listen(SHARED_EVENT) + onSharedEvent(event: CustomEvent) { + this.addReceivedEvent(`SharedEvent: ${event.detail}`); + } + + private addReceivedEvent(message: string) { + this.receivedEvents.push(message); + this.eventCount++; + } + + /** + * B fires events to different components + */ + private fireToParentA = () => { + this.toParentA.emit('B→A: Message from Sibling B to Parent A'); + }; + + private fireToSiblingC = () => { + this.toSiblingC.emit('B→C: Message from Sibling B to Sibling C'); + }; + + private fireToNestedChild = () => { + this.toNestedChild.emit('B→D: Message from Sibling B to Nested Child D'); + }; + + private fireToExternal = () => { + this.toExternal.emit('B→E: Message from Sibling B to External E'); + }; + + private fireToParentAndSibling = () => { + this.toParentAndSibling.emit('B→AC: Message from Sibling B to Parent A and Sibling C'); + }; + + /** + * B fires shared event + */ + private fireSharedEvent = () => { + const event = new CustomEvent(SHARED_EVENT, { + detail: 'B→SharedEvent: Sibling B using shared event', + bubbles: true, + }); + this.el.dispatchEvent(event); + }; + + private clearEvents = () => { + this.receivedEvents.length = 0; + this.eventCount = 0; + }; + + // Public method for tests to access received events + getReceivedEvents(): string[] { + return [...this.receivedEvents]; + } + + getEventCount(): number { + return this.eventCount; + } + + render() { + return ( +
+

Sibling B Component

+ +
+ + + + + + + +
+ +
+
Sibling B - Events in Memory
+
+ Events stored in memory (not rendered to save performance) +
+
+
+ ); + } +} diff --git a/test/wdio/shared-events/sibling-c.tsx b/test/wdio/shared-events/sibling-c.tsx new file mode 100644 index 00000000000..9604c89f6cc --- /dev/null +++ b/test/wdio/shared-events/sibling-c.tsx @@ -0,0 +1,186 @@ +import { Component, Element, Event, EventEmitter, h, Listen, State } from '@stencil/core'; + +// Import from TypeScript file using .js extension (Node16/NodeNext module resolution) +// The source file is event-constants.ts, but we use .js to reference the compiled output +import { EVENT_NAMES, SHARED_EVENT } from './event-constants.js'; + +@Component({ + tag: 'sibling-c', + scoped: true, +}) +export class SiblingC { + @Element() el!: HTMLElement; + + // Store events in memory only (not state) to prevent re-renders + private receivedEvents: string[] = []; + // Keep eventCount as state so it displays in DOM + @State() eventCount = 0; + + /** + * Events emitted by Sibling C + */ + @Event({ eventName: EVENT_NAMES.C_TO_A }) toParentA!: EventEmitter; + @Event({ eventName: EVENT_NAMES.C_TO_B }) toSiblingB!: EventEmitter; + @Event({ eventName: EVENT_NAMES.C_TO_D }) toNestedChild!: EventEmitter; + @Event({ eventName: EVENT_NAMES.C_TO_E }) toExternal!: EventEmitter; + @Event({ eventName: EVENT_NAMES.C_TO_AB }) toParentAndSibling!: EventEmitter; + + disconnectedCallback() { + this.receivedEvents.length = 0; + this.eventCount = 0; + } + + /** + * Listen for events from other components + */ + + /** + * Listen for events from Parent A + * @param event - The event from Parent A + */ + @Listen(EVENT_NAMES.A_TO_C) + onFromParentA(event: CustomEvent) { + this.addReceivedEvent(`A→C: ${event.detail}`); + } + + /** + * Listen for events from Parent A + * @param event - The event from Parent A + */ + @Listen(EVENT_NAMES.A_TO_BC) + onFromParentToBoth(event: CustomEvent) { + this.addReceivedEvent(`A→BC: ${event.detail}`); + } + + /** + * Listen for events from Sibling B + * @param event - The event from Sibling B + */ + @Listen(EVENT_NAMES.B_TO_C) + onFromSiblingB(event: CustomEvent) { + this.addReceivedEvent(`B→C: ${event.detail}`); + } + + /** + * Listen for events from Nested Child D + * @param event - The event from Nested Child D + */ + @Listen(EVENT_NAMES.D_TO_C) + onFromNestedChild(event: CustomEvent) { + this.addReceivedEvent(`D→C: ${event.detail}`); + } + + /** + * Listen for events from External E + * @param event - The event from External E + */ + @Listen(EVENT_NAMES.E_TO_C) + onFromExternal(event: CustomEvent) { + this.addReceivedEvent(`E→C: ${event.detail}`); + } + + /** + * Listen for shared event + * @param event - The event from the shared event + */ + @Listen(SHARED_EVENT) + onSharedEvent(event: CustomEvent) { + this.addReceivedEvent(`SharedEvent: ${event.detail}`); + } + + private addReceivedEvent(message: string) { + this.receivedEvents.push(message); + this.eventCount++; + } + + /** + * C fires events to different components + */ + private fireToParentA = () => { + this.toParentA.emit('C→A: Message from Sibling C to Parent A'); + }; + + private fireToSiblingB = () => { + this.toSiblingB.emit('C→B: Message from Sibling C to Sibling B'); + }; + + private fireToNestedChild = () => { + this.toNestedChild.emit('C→D: Message from Sibling C to Nested Child D'); + }; + + private fireToExternal = () => { + this.toExternal.emit('C→E: Message from Sibling C to External E'); + }; + + private fireToParentAndSibling = () => { + this.toParentAndSibling.emit('C→AB: Message from Sibling C to Parent A and Sibling B'); + }; + + /** + * C fires shared event + */ + private fireSharedEvent = () => { + const event = new CustomEvent(SHARED_EVENT, { + detail: 'C→SharedEvent: Sibling C using shared event', + bubbles: true, + }); + this.el.dispatchEvent(event); + }; + + private clearEvents = () => { + this.receivedEvents.length = 0; + this.eventCount = 0; + }; + + // Public method for tests to access received events + getReceivedEvents(): string[] { + return [...this.receivedEvents]; + } + + getEventCount(): number { + return this.eventCount; + } + + render() { + return ( +
+

Sibling C Component

+ +
+ + + + + + + +
+ +
+
Sibling C - Events in Memory
+
+ Events stored in memory (not rendered to save performance) +
+
+ +
+ +
+
+ ); + } +} diff --git a/test/wdio/shared-events/unrelated-e.tsx b/test/wdio/shared-events/unrelated-e.tsx new file mode 100644 index 00000000000..e6ca0c35529 --- /dev/null +++ b/test/wdio/shared-events/unrelated-e.tsx @@ -0,0 +1,190 @@ +import { Component, Element, Event, EventEmitter, h, Listen, State } from '@stencil/core'; + +// Import from TypeScript file using .js extension (Node16/NodeNext module resolution) +// The source file is event-constants.ts, but we use .js to reference the compiled output +import { EVENT_NAMES, SHARED_EVENT } from './event-constants.js'; + +@Component({ + tag: 'unrelated-e', + scoped: true, +}) +export class UnrelatedE { + @Element() el!: HTMLElement; + + // Store events in memory only (not state) to prevent re-renders + private receivedEvents: string[] = []; + // Keep eventCount as state so it displays in DOM + @State() eventCount = 0; + + /** + * Events emitted by External E + */ + @Event({ eventName: EVENT_NAMES.E_TO_A }) toParentA!: EventEmitter; + @Event({ eventName: EVENT_NAMES.E_TO_B }) toSiblingB!: EventEmitter; + @Event({ eventName: EVENT_NAMES.E_TO_C }) toSiblingC!: EventEmitter; + @Event({ eventName: EVENT_NAMES.E_TO_D }) toNestedChild!: EventEmitter; + @Event({ eventName: EVENT_NAMES.E_TO_AB }) toParentAndB!: EventEmitter; + @Event({ eventName: EVENT_NAMES.E_TO_AC }) toParentAndC!: EventEmitter; + @Event({ eventName: EVENT_NAMES.E_TO_AD }) toParentAndD!: EventEmitter; + + disconnectedCallback() { + this.receivedEvents.length = 0; + this.eventCount = 0; + } + + /** + * Listen for events from family components + */ + + /** + * Listen for events from Parent A + * @param event - The event from Parent A + */ + @Listen(EVENT_NAMES.A_TO_E) + onFromParentA(event: CustomEvent) { + this.addReceivedEvent(`A→E: ${event.detail}`); + } + + /** + * Listen for events from Sibling B + * @param event - The event from Sibling B + */ + @Listen(EVENT_NAMES.B_TO_E) + onFromSiblingB(event: CustomEvent) { + this.addReceivedEvent(`B→E: ${event.detail}`); + } + + /** + * Listen for events from Sibling C + * @param event - The event from Sibling C + */ + @Listen(EVENT_NAMES.C_TO_E) + onFromSiblingC(event: CustomEvent) { + this.addReceivedEvent(`C→E: ${event.detail}`); + } + + /** + * Listen for events from Nested Child D + * @param event - The event from Nested Child D + */ + @Listen(EVENT_NAMES.D_TO_E) + onFromNestedChild(event: CustomEvent) { + this.addReceivedEvent(`D→E: ${event.detail}`); + } + + /** + * Listen for shared event + * @param event - The event from the shared event + */ + @Listen(SHARED_EVENT) + onSharedEvent(event: CustomEvent) { + this.addReceivedEvent(`SharedEvent: ${event.detail}`); + } + + private addReceivedEvent(message: string) { + this.receivedEvents.push(message); + this.eventCount++; + } + + /** + * E fires events to different family components + */ + private fireToParentA = () => { + this.toParentA.emit('E→A: Message from External E to Parent A'); + }; + + private fireToSiblingB = () => { + this.toSiblingB.emit('E→B: Message from External E to Sibling B'); + }; + + private fireToSiblingC = () => { + this.toSiblingC.emit('E→C: Message from External E to Sibling C'); + }; + + private fireToNestedChild = () => { + this.toNestedChild.emit('E→D: Message from External E to Nested Child D'); + }; + + private fireToParentAndB = () => { + this.toParentAndB.emit('E→AB: Message from External E to Parent A and Sibling B'); + }; + + private fireToParentAndC = () => { + this.toParentAndC.emit('E→AC: Message from External E to Parent A and Sibling C'); + }; + + private fireToParentAndD = () => { + this.toParentAndD.emit('E→AD: Message from External E to Parent A and Nested Child D'); + }; + + /** + * E fires shared event + */ + private fireSharedEvent = () => { + const event = new CustomEvent(SHARED_EVENT, { + detail: 'E→SharedEvent: External E using shared event', + bubbles: true, + }); + this.el.dispatchEvent(event); + }; + + private clearEvents = () => { + this.receivedEvents.length = 0; + this.eventCount = 0; + }; + + // Public method for tests to access received events + getReceivedEvents(): string[] { + return [...this.receivedEvents]; + } + + getEventCount(): number { + return this.eventCount; + } + + render() { + return ( +
+

Unrelated Component E (External)

+

This component is outside the family tree

+ +
+ + + + + + + + + +
+ +
+

External Component E - Events in Memory

+
+ Events stored in memory (not rendered to save performance) +
+
+
+ ); + } +} diff --git a/test/wdio/wdio.conf.ts b/test/wdio/wdio.conf.ts index 0d25965d457..9b375e7cef3 100644 --- a/test/wdio/wdio.conf.ts +++ b/test/wdio/wdio.conf.ts @@ -1,8 +1,9 @@ /// import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const __dirname = path.dirname(new URL(import.meta.url).pathname); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const isCI = Boolean(process.env.CI); /**