diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8518eca --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + # GitHub Actions - updates uses: statements in workflows + - package-ecosystem: "github-actions" + directory: "/" # Where your .github/workflows/ folder is + schedule: + interval: "weekly" + + # NPM + - package-ecosystem: "npm" + directory: "/system/exceptions" # adjust if needed + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdbd8c4..33f7eab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: ${{ runner.OS }}-commandbox-cache-${{ hashFiles( 'box.json' ) }}-${{ hashFiles( 'test-harness/box.json' ) }} - name: Setup CommandBox - uses: elpete/setup-commandbox@v1.0.0 + uses: elpete/setup-commandbox@v1.0.1 - name: Setup env.VERSION run: | @@ -138,7 +138,7 @@ jobs: uses: actions/checkout@v4 - name: Setup CommandBox - uses: elpete/setup-commandbox@v1.0.0 + uses: elpete/setup-commandbox@v1.0.1 - name: Generate Docs run: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8bd386d..2370a63 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -12,7 +12,7 @@ jobs: ############################################# tests: name: Tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: MODULE_ID: cbwire strategy: @@ -43,7 +43,7 @@ jobs: #printf "DB_BUNDLENAME=com.mysql.cj\n" >> test-harness/.env - name: Setup CommandBox - uses: elpete/setup-commandbox@v1.0.0 + uses: elpete/setup-commandbox@v1.0.1 - name: Install Main Dependencies working-directory: ./ @@ -92,7 +92,7 @@ jobs: format: name: Format - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v2 diff --git a/models/CBWIREController.cfc b/models/CBWIREController.cfc index 78841cc..b573e66 100644 --- a/models/CBWIREController.cfc +++ b/models/CBWIREController.cfc @@ -1,4 +1,4 @@ -component singleton { +component accessors="true" singleton { // Injected WireBox instance so that we can dynamically create instances of components. property name="wirebox" inject="wirebox"; @@ -11,13 +11,16 @@ component singleton { // Inject module settings property name="moduleSettings" inject="coldbox:modulesettings:cbwire"; - + // Inject module service property name="moduleService" inject="coldbox:moduleService"; // Inject SingleFileComponentBuilder property name="singleFileComponentBuilder" inject="SingleFileComponentBuilder@cbwire"; + // Inject ChecksumService + property name="checksumService" inject="ChecksumService@cbwire"; + function init() { // Initialize the array to store single file components variables._singleFileComponents = []; @@ -43,8 +46,8 @@ component singleton { ._withKey( arguments.key ); // If the component is lazy loaded, we need to generate an x-intersect snapshot of the component - return arguments.lazy ? - local.instance._generateXIntersectLazyLoadSnapshot( params=arguments.params ) : + return arguments.lazy ? + local.instance._generateXIntersectLazyLoadSnapshot( params=arguments.params ) : local.instance._render(); } @@ -53,7 +56,7 @@ component singleton { * * @incomingRequest The JSON struct payload of the incoming request. * @event The event object. - * + * * @return A struct representing the response with updated component details or an error message. */ function handleRequest( incomingRequest, event ) { @@ -73,6 +76,7 @@ component singleton { } // Perform additional deserialization of the component snapshots local.payload.components = local.payload.components.map( function( _comp ) { + checksumService.validateChecksum( arguments._comp.snapshot ); arguments._comp.snapshot = deserializeJSON( arguments._comp.snapshot ); return arguments._comp; } ); @@ -121,15 +125,15 @@ component singleton { /** * Uploads all files from the request to the specified destination * after verifying the signed URL. - * + * * @incomingRequest The JSON struct payload of the incoming request. * @event The event object. - * + * * @return A struct representing the response with updated component details or an error message. */ function handleFileUpload( incomingRequest, event ) { // Determine our storage path for temporary files - local.storagePath = getCanonicalPath( variables.moduleSettings.moduleRootPath & "/models/tmp" ); + local.storagePath = getCanonicalPath( variables.moduleSettings.storagePath ); // Ensure the storage path exists if( !directoryExists( local.storagePath ) ){ @@ -158,10 +162,10 @@ component singleton { /** * Handles the preview of a file by reading the file metadata and sending it back to the client. - * + * * @incomingRequest The JSON struct payload of the incoming request. * @event The event object. - * + * * @return file contents */ function handleFilePreview( incomingRequest, event ){ @@ -170,10 +174,10 @@ component singleton { return event.noRender(); } - local.metaPath = getCanonicalPath( variables.moduleSettings.moduleRootPath & "models/tmp/#local.uuid#.json" ); + local.metaPath = getCanonicalPath( variables.moduleSettings.storagePath & "/#local.uuid#.json" ); local.metaJSON = deserializeJSON( fileRead( local.metaPath ) ); - local.contents = fileReadBinary( getCanonicalPath( variables.moduleSettings.moduleRootPath & "models/tmp/#local.metaJSON.serverFile#" ) ); + local.contents = fileReadBinary( getCanonicalPath( variables.moduleSettings.storagePath & "/#local.metaJSON.serverFile#" ) ); event .sendFile( file = local.contents, @@ -185,67 +189,148 @@ component singleton { } /** - * Dynamically creates an instance of a CBWIRE component based on the provided name. - * Assumes components are located within a specific namespace or directory structure. + * Retrieves the full dot notation path for a component based on its name. + * If the name contains a module reference, it resolves the path accordingly. * - * @componentName The name of the component to instantiate, possibly including a namespace. - * @params Optional parameters to pass to the component constructor. - * @key Optional key to use when retrieving the component from WireBox. - * - * @return The instantiated component object. - * @throws ApplicationException If the component cannot be found or instantiated. + * @name The name of the component to resolve. + * + * @return The full dot notation path for the component. */ - function createInstance( name ) { - // Determine if the component name traverses a valid namespace or directory structure - local.fullComponentPath = arguments.name; - - if ( !local.fullComponentPath contains "wires." ) { - local.fullComponentPath = "wires." & local.fullComponentPath; + function getComponentDSL( name ) { + local.componentDSL = arguments.name; + + if ( !local.componentDSL contains "wires." ) { + // Get the default wires location from our setttings + if ( moduleSettings.keyExists( "wiresLocation" ) ) { + local.componentDSL = moduleSettings.wiresLocation & "." & local.componentDSL; + } else { + // Fallback + local.componentDSL = "wires." & local.componentDSL; + } } - - if ( find( "@", local.fullComponentPath ) ) { + + if ( find( "@", local.componentDSL ) ) { // This is a module reference, find in our module - var params = listToArray( local.fullComponentPath, "@" ); - if ( params.len() != 2 ) { - throw( type="ModuleNotFound", message = "CBWIRE cannot locate the module or component using '" & local.fullComponentPath & "'." ); + local.params = listToArray( local.componentDSL, "@" ); + if ( local.params.len() != 2 ) { + throw( type="ModuleNotFound", message = "CBWIRE cannot locate the module or component using '" & local.componentDSL & "'." ); } - // modify local.fullComponentPath to full path for module - local.fullComponentPath = getModuleComponentPath( params[ 1 ], params[ 2 ] ); + // modify local.componentDSL to full path for module + local.componentDSL = getModuleComponentPath( params[ 1 ], params[ 2 ] ); } - try { - // Check if we've already flagged this component as a single file component - // This is to improve performance by not attempting to create the component again - if ( variables._singleFileComponents.contains( arguments.name ) ) { - throw( type="Injector.InstanceNotFoundException", message="Component '#arguments.name#' is a single file component." ); - } - // Attempt to create an instance of the component - local.componentInstance = variables.wirebox.getInstance(local.fullComponentPath) - ._withPath( arguments.name ); - return local.componentInstance; - } catch( Injector.InstanceNotFoundException e ) { - local.singleFileComponent = variables.singleFileComponentBuilder - .setInitialRender( true ) - .build( fullComponentPath, arguments.name, getCurrentRequestModule() ); - if ( isNull( local.singleFileComponent ) ) { - writeDump( local ); - abort; - rethrow; - } - variables._singleFileComponents.append( arguments.name ); + return local.componentDSL; + } - return local.singleFileComponent; - } catch (Any e) { - writeDump( e ); - abort; - // Log error or handle it as needed - throw("ApplicationException", "Unable to instantiate component '#arguments.name#'. Detail: #e.message#"); + /** + * Converts a component DSL from dot notation to slash notation. + * + * @componentDSL String | The component DSL to convert. + * + * @return String | The converted DSL in slash notation. + */ + function convertDSLToSlashNotation( componentDSL ) { + return replace( arguments.componentDSL, ".", "/", "all" ); + } + + /** + * Returns true if the component DSL is a module DSL. + * + * @componentDSL String | The component DSL to check. + * + * @return boolean + */ + function isModuleDSL( componentDSL ) { + return find( "@", arguments.componentDSL ) > 0; + } + + function getDSLFilePathWithoutExtension( componentDSL ) { + + if ( isModuleDSL( componentDSL ) ) { + return getModuleDSLFilePathWithoutExtension( componentDSL ); } + + local.dslSlashNotation = convertDSLToSlashNotation( arguments.componentDSL ); + + return expandPath( "/" & local.dslSlashNotation); } /** + * Returns true if the component is a single file component. + * Also provides a performance optimization by checking if the component is already flagged as a single file component. + * + * @componentDSL String | The component DSL to check. + * + * @return boolean + */ + function isSingleFileComponent( componentDSL ) { + + if ( variables._singleFileComponents.contains( componentDSL ) ) { + return true; + } + + local.dslFilePathWithoutExtension = getDSLFilePathWithoutExtension( componentDSL ); + + if ( !fileExists( local.dslFilePathWithoutExtension & ".bx" ) && !fileExists( local.dslFilePathWithoutExtension & ".cfc" ) ) { + if ( fileExists( local.dslFilePathWithoutExtension & ".bxm" ) || fileExists( local.dslFilePathWithoutExtension & ".cfm" ) ) { + variables._singleFileComponents.append( componentDSL ); + return true; + } + } + + return false; + } + + /** + * Creates a single file component instance based on the provided DSL and name. + * This method uses the SingleFileComponentBuilder to build the component. + * + * @componentDSL String | The DSL of the component to create. + * @name String | The name of the component to create. + * + * @return The instantiated single file component object. + */ + function createSingleFileComponent( componentDSL, name ) { + return variables.singleFileComponentBuilder.setInitialRender( true ).build( arguments.componentDSL, arguments.name, getCurrentRequestModule() ); + } + + /** + * Creates a regular component instance based on the provided DSL and name. + * This method uses WireBox to get an instance of the component. + * + * @componentDSL String | The DSL of the component to create. + * @name String | The name of the component to create. + * + * @return The instantiated regular component object. + */ + function createRegularComponent( componentDSL, name ) { + return variables.wirebox.getInstance( arguments.componentDSL )._withPath( arguments.name ); + } + + /** + * Creates an instance of a CBWIRE component based on the provided name or DSL. + * + * @name String | The name of the component to instantiate. + * + * @return The instantiated component object. + * + * @throws ApplicationException If the component cannot be found or instantiated. + */ + function createInstance( name ) { + local.componentDSL = getComponentDSL( arguments.name ); + + if ( isSingleFileComponent( local.componentDSL ) ) { + return createSingleFileComponent( local.componentDSL, arguments.name ); + } else { + return createRegularComponent( local.componentDSL, arguments.name ); + } + + throw("ApplicationException", "Unable to instantiate component '#arguments.name#'. Detail: #e.message#"); + } + + /** * Returns the path to the modules folder. - * + * * @module string | The name of the module. * * @return string @@ -286,8 +371,8 @@ component singleton { * Returns the path to the wires folder within a module path. * * @module string | The name of the module. - * - * @return string + * + * @return string */ function getModuleWiresPath( module ) { local.moduleRegistry = moduleService.getModuleRegistry(); @@ -296,7 +381,7 @@ component singleton { /** * Returns the ColdBox RequestContext object. - * + * * @return The ColdBox RequestContext object. */ function getEvent(){ @@ -305,7 +390,7 @@ component singleton { /** * Returns any request assets defined by components during the request. - * + * * @return struct */ function getRequestAssets() { @@ -316,7 +401,7 @@ component singleton { /** * Returns the ColdBox ConfigSettings object. - * + * * @return struct */ function getConfigSettings(){ @@ -325,7 +410,7 @@ component singleton { /** * Returns an array of preprocessor instances. - * + * * @return An array of preprocessor instances. */ function getPreprocessors(){ @@ -333,7 +418,7 @@ component singleton { if( structKeyExists( variables, "preprocessors" ) ){ return variables.preprocessors; } - // List of preprocesssors here. Had to hard code instead of using + // List of preprocesssors here. Had to hard code instead of using // directoryList because of filesystem differences in various OSes local.files = [ "TemplatePreprocessor.cfc", @@ -351,18 +436,18 @@ component singleton { /** * Returns CSS styling needed by Livewire. - * + * * @return string */ function getStyles( cache=true ) { if (structKeyExists(variables, "styles") && arguments.cache ) { return variables.styles; } - + savecontent variable="local.html" { include "styles.cfm"; } - + variables.styles = local.html; return variables.styles; } @@ -372,10 +457,10 @@ component singleton { * We don't cache the results like we do with * styles because we need to generate a unique * CSRF token for each request. - * + * * @return string */ - function getScripts() { + function getScripts() { savecontent variable="local.html" { include "scripts.cfm"; } @@ -384,7 +469,7 @@ component singleton { /** * Returns HTML to persist the state of anything inside the call. - * + * * @return string */ function persist( name ) { @@ -393,7 +478,7 @@ component singleton { /** * Ends the persistence of the state of anything inside the call. - * + * * @return string */ function endPersist() { @@ -402,10 +487,10 @@ component singleton { /** * Generates a secure signature for the upload URL. - * + * * @baseURL string | The base URL for the upload request. * @expires string | The expiration time for the request. - * + * * @return string */ function generateSignature(baseUrl, expires) { @@ -419,7 +504,7 @@ component singleton { /** * Generates a CSRF token for the current request. - * + * * @return string */ function generateCSRFToken() { @@ -429,7 +514,7 @@ component singleton { /** * Returns the base URL for incoming requests. - * + * * @return string */ function getBaseURL() { @@ -451,13 +536,13 @@ component singleton { local.expires = dateDiff( "s", createDate( 1970, 1, 1 ), local.expires ) + 3600; // Adding 3600 seconds (1 hour) // Generate a secure signature. You'll need to define the `generateSignature` method local.signature = generateSignature( local.baseURL, local.expires ); - // Construct the upload URL with the query parameters - return local.baseURL & "/cbwire/upload?expires=" & local.expires & "&signature=" & urlEncodedFormat( local.signature ); + // Construct the upload URL with the query parameters using the configured upload endpoint + return local.baseURL & getUploadEndpoint() & "?expires=" & local.expires & "&signature=" & urlEncodedFormat( local.signature ); } /** * Verifies signed upload URL. - * + * * @return boolean */ function verifySignedUploadURL( expires, signature ) { @@ -523,11 +608,22 @@ component singleton { /** * Returns the URI endpoint for updating CBWIRE components. - * + * * @return string */ function getUpdateEndpoint() { - var settings = variables.moduleSettings; + var settings = variables.moduleSettings; return settings.keyExists( "updateEndpoint") && settings.updateEndpoint.len() ? settings.updateEndpoint : "/cbwire/update"; } -} \ No newline at end of file + + /** + * Returns the URI endpoint for uploading files, derived from the updateEndpoint. + * + * @return string + */ + function getUploadEndpoint() { + var updateEndpoint = getUpdateEndpoint(); + // Replace "/update" with "/upload" in the endpoint + return updateEndpoint.replace("/update", "/upload"); + } +} diff --git a/test-harness/tests/specs/CBWIRESpec.cfc b/test-harness/tests/specs/CBWIRESpec.cfc index 0e0e4c5..120f47a 100644 --- a/test-harness/tests/specs/CBWIRESpec.cfc +++ b/test-harness/tests/specs/CBWIRESpec.cfc @@ -44,7 +44,7 @@ component extends="coldbox.system.testing.BaseTestCase" { it( "should use default display bar color", function() { var CBWIREController = getInstance( "CBWIREController@cbwire" ); var html = CBWIREController.getStyles( cache=false ); - expect( html ).toInclude( "--livewire-progress-bar-color: ##2299dd;" ); + expect( html ).toInclude( "livewire-progress-bar-color: ##2299dd;" ); } ); it( "should be able to change the display bar color", function() { @@ -82,6 +82,42 @@ component extends="coldbox.system.testing.BaseTestCase" { expect( CBWIREController.getUpdateEndpoint() ).toBe( "/index.cfm/cbwire/update" ); } ); + it( "should have default uploadEndpoint", function() { + var CBWIREController = getInstance( "CBWIREController@cbwire" ); + var settings = getInstance( "coldbox:modulesettings:cbwire" ); + // Ensure no custom updateEndpoint is set + settings.delete( "updateEndpoint" ); + expect( CBWIREController.getUploadEndpoint() ).toBe( "/cbwire/upload" ); + } ); + + it( "should derive uploadEndpoint from updateEndpoint", function() { + var CBWIREController = getInstance( "CBWIREController@cbwire" ); + var settings = getInstance( "coldbox:modulesettings:cbwire" ); + settings.updateEndpoint = "/index.cfm/cbwire/update"; + expect( CBWIREController.getUploadEndpoint() ).toBe( "/index.cfm/cbwire/upload" ); + } ); + + it( "should derive uploadEndpoint from custom updateEndpoint", function() { + var CBWIREController = getInstance( "CBWIREController@cbwire" ); + var settings = getInstance( "coldbox:modulesettings:cbwire" ); + settings.updateEndpoint = "/index.bxm/cbwire/update"; + expect( CBWIREController.getUploadEndpoint() ).toBe( "/index.bxm/cbwire/upload" ); + } ); + + it( "should handle updateEndpoint without /update suffix", function() { + var CBWIREController = getInstance( "CBWIREController@cbwire" ); + var settings = getInstance( "coldbox:modulesettings:cbwire" ); + settings.updateEndpoint = "/api/cbwire"; + expect( CBWIREController.getUploadEndpoint() ).toBe( "/api/cbwire" ); + } ); + + it( "should handle complex updateEndpoint paths", function() { + var CBWIREController = getInstance( "CBWIREController@cbwire" ); + var settings = getInstance( "coldbox:modulesettings:cbwire" ); + settings.updateEndpoint = "/myapp/index.cfm/api/cbwire/update"; + expect( CBWIREController.getUploadEndpoint() ).toBe( "/myapp/index.cfm/api/cbwire/upload" ); + } ); + it( "should have component request assets added in head", function() { var event = this.get( "tests.requestassets" ); var html = event.getRenderedContent(); @@ -110,6 +146,16 @@ component extends="coldbox.system.testing.BaseTestCase" { expect( result ).toBeString(); } ); + it( title="should render a boxlang component with a separate boxlang template", body=function() { + var result = CBWIREController.wire( "test.should_render_a_boxlang_component" ); + expect( result ).toInclude( "

A Boxlang Component

" ); + }, skip=!isBoxLang() ); + + it( title="should render a single-file boxlang component", body=function() { + var result = CBWIREController.wire( "test.should_render_a_singlefile_boxlang_component" ); + expect( result ).toInclude( "

A Single File Boxlang Component

" ); + }, skip=!isBoxLang() ); + it( "should raise error if markers are not found in single-file component", function() { expect( function() { var result = CBWIREController.wire( "test.should_raise_error_for_single_file_component" ); @@ -142,7 +188,7 @@ component extends="coldbox.system.testing.BaseTestCase" { it( "should support rendering wires with x-data and arrow functions", function() { var result = CBWIREController.wire( "test.should_support_rendering_wires_with_xdata_and_arrow_functions" ); - expect( reFindNoCase( "
Implicitly rendered

" ); } ); + it("should not auto set passed in params if onMount is defined", function() { + var result = CBWIREController.wire( name="test.should_not_auto_set_passed_in_params_if_onMount_is_defined", params={ name="Jane Doe" } ); + expect( result ).toInclude( "Hello John Doe" ); + } ); + it( "should support passing params into a onRender method", function() { var result = CBWIREController.wire( "test.should_support_passing_params_into_a_onRender_method" ); expect( result ).toInclude( "

Passed in: 5

" ); @@ -199,6 +250,29 @@ component extends="coldbox.system.testing.BaseTestCase" { expect( result ).toInclude( "

UUID2: #firstUUID#

" ); } ); + it( "should correctly mount with _lazyMount() using a valid snapshot", function() { + // Create a mock snapshot + var mountParams = { "foo": "bar", "baz": 123 }; + var snapshotStruct = { + "data": { + "forMount": [ mountParams ] + } + }; + var encodedSnapshot = toBase64( serializeJson( snapshotStruct ) ); + // Setup test component and event + testComponent._withEvent( getRequestContext() ); + // Spy on onMount to capture params + var calledParams = {}; + + // Call _lazyMount + testComponent._lazyMount( encodedSnapshot ); + // If we got here that means there's no error NOT calling onMount + testComponent.$( "onMount" ); + + testComponent._lazyMount( encodedSnapshot ); + + } ); + it( "should accept false flag for computed properties to prevent caching", function() { var result = CBWIREController.wire( "test.should_accept_false_flag_for_computed_properties_to_prevent_caching" ); var firstUUID = reFindNoCase( "UUID: ([A-Za-z0-9-]+)", result, 1, true ).match[ 2 ]; @@ -224,9 +298,6 @@ component extends="coldbox.system.testing.BaseTestCase" { xit( "should support deep nesting with correct count of children", function() { var result = CBWIREController.wire( "test.should_support_deep_nesting" ); var parent = parseRendering( result, 1 ); - writeDump( result ); - writeDump( parent ); - abort; var child1 = parseRendering( result, 2 ); var child2 = parseRendering( result, 3 ); expect( parent.snapshot.memo.children.count() ).toBe( 2 ); @@ -236,17 +307,17 @@ component extends="coldbox.system.testing.BaseTestCase" { it( "shouldn't isolate by default", function() { var result = CBWIREController.wire( "test.shouldnt_isolate_by_default" ); - expect( result ).toInclude( ""isolate":false" ); + expect( result ).toInclude( ""isolate"&##x3a;false" ); } ); it( "should isolate when using isolate=true", function() { var result = CBWIREController.wire( "test.should_isolate_when_using_isolate_true" ); - expect( result ).toInclude( ""isolate":true" ); + expect( result ).toInclude( ""isolate"&##x3a;true" ); } ); it( "should isolate when using lazyLoad=true", function() { var result = CBWIREController.wire( "test.should_isolate_when_using_lazyLoad_true" ); - expect( result ).toInclude( ""isolate":true" ); + expect( result ).toInclude( ""isolate"&##x3a;true" ); } ); it( "should support hasErrors(), hasError( prop ), and getError( prop ) for validation", function() { @@ -453,7 +524,7 @@ component extends="coldbox.system.testing.BaseTestCase" { it( "should include listeners within wire:effects on initial render", function() { var result = testComponent._render( testComponent.template( "wires.TestComponent" ) ); - expect( result ).toInclude( "wire:effects=""{"listeners":["someEvent"]}""" ); + expect( result ).toInclude( "wire:effects=""&##x7b;"listeners"&##x3a;&##x5b;"someEvent"&##x5d;&##x7d;""" ); } ); it( "should support single file components", function() { @@ -470,6 +541,18 @@ component extends="coldbox.system.testing.BaseTestCase" { } } ); + it( "should correctly encode snapshot data containing HTML with quotes", function() { + local.htmlWithQuotes = '

Some text with "quotes" inside & special chars >.

'; + local.componentName = "test.html_with_quotes"; // Use the new component + + var renderedHtml = CBWIREController.wire( + name = local.componentName, + params = { content = local.htmlWithQuotes } + ); + + expect( renderedHtml ).toInclude( 'Some&##x20;text&##x20;with&##x20;&##x5c;"quotes' ); + }); + }); describe("Incoming Requests", function() { @@ -507,7 +590,7 @@ component extends="coldbox.system.testing.BaseTestCase" { it( "should trim string values if enabled on component", () => { var settings = getInstance( "coldbox:modulesettings:cbwire" ); - settings.trimStringValues = false; + settings.trimStringValues = true; var payload = incomingRequest( memo = { "name": "test.should_trim_string_values_if_enabled_on_component", @@ -1264,6 +1347,42 @@ component extends="coldbox.system.testing.BaseTestCase" { expect( reFindNoCase( "/cbwire/upload\?expires=[0-9]+&signature=[A-Za-z0-9]+$", response.components[1].effects.dispatches[1].params.url ) ).toBeGT( 0 ); } ); + it( "should _startUpload() and honor custom updateEndpoint in upload URL", function() { + var settings = getInstance( "coldbox:modulesettings:cbwire" ); + // Set custom updateEndpoint + settings.updateEndpoint = "/index.bxm/cbwire/update"; + + var payload = incomingRequest( + memo = { + "name": "TestComponent", + "id": "Z1Ruz1tGMPXSfw7osBW2", + "children": [] + }, + data = {}, + calls = [ + { + "path": "", + "method": "_startUpload", + "params": [ + "testFile", + [ "name": "image.png", "size": 118672, "type": "image/png" ], + false + ] + } + ], + updates = {} + ); + var response = cbwireController.handleRequest( payload, event ); + expect( response.components[1].effects.dispatches[1].name ).toBe( "upload:generatedSignedUrl" ); + expect( response.components[1].effects.dispatches[1].self ).toBeTrue(); + expect( response.components[1].effects.dispatches[1].params.name ).toBe( "testFile" ); + expect( response.components[1].effects.dispatches[1].params.url ).toInclude( "/index.bxm/cbwire/upload" ); + expect( reFindNoCase( "/index\.bxm/cbwire/upload\?expires=[0-9]+&signature=[A-Za-z0-9]+$", response.components[1].effects.dispatches[1].params.url ) ).toBeGT( 0 ); + + // Clean up - restore default + settings.delete( "updateEndpoint" ); + } ); + xit( "should _finishUpload()", function() { var payload = incomingRequest( memo = { @@ -1306,7 +1425,7 @@ component extends="coldbox.system.testing.BaseTestCase" { key="", lazy=true ); - expect( lazyHtml ).toInclude( ""isolate":true" ); + expect( lazyHtml ).toInclude( ""isolate"&##x3a;true" ); } ); it( "should lazy load a component using the original outer element", function() { @@ -1372,6 +1491,77 @@ component extends="coldbox.system.testing.BaseTestCase" { } ); }); + describe("CBWIREController getComponentDSL", function() { + + beforeEach(function(currentSpec) { + setup(); + cbwireController = getInstance("CBWIREController@cbwire"); + prepareMock(cbwireController); + }); + + it("should return component name with wires prefix when name doesn't contain wires", function() { + var result = cbwireController.getComponentDSL("TestComponent"); + expect(result).toBe("wires.TestComponent"); + }); + + it("should use wiresLocation setting when provided and name doesn't contain wires", function() { + var settings = getInstance("coldbox:modulesettings:cbwire"); + settings.wiresLocation = "customWires"; + var result = cbwireController.getComponentDSL("TestComponent"); + expect(result).toBe("customWires.TestComponent"); + settings.wiresLocation = ""; + }); + + it("should fallback to wires prefix when wiresLocation is not set", function() { + var settings = getInstance("coldbox:modulesettings:cbwire"); + if (settings.keyExists("wiresLocation")) { + structDelete(settings, "wiresLocation"); + } + var result = cbwireController.getComponentDSL("TestComponent"); + expect(result).toBe("wires.TestComponent"); + }); + + it("should return component name as-is when it already contains wires", function() { + var result = cbwireController.getComponentDSL("wires.TestComponent"); + expect(result).toBe("wires.TestComponent"); + }); + + it("should handle module reference with @ symbol", function() { + //cbwireController.$("getModuleComponentPath", "modules.testModule.wires.TestComponent"); + var result = cbwireController.getComponentDSL("TestComponent@testingmodule"); + expect(result).toBe("modules_app.testingmodule.wires.TestComponent"); + }); + + it("should throw ModuleNotFound exception when module reference has invalid format", function() { + expect(function() { + cbwireController.getComponentDSL("TestComponent@module@extra"); + }).toThrow(type="ModuleNotFound"); + }); + + it("should throw ModuleNotFound exception when module reference has only one part", function() { + expect(function() { + cbwireController.getComponentDSL("TestComponent@"); + }).toThrow(type="ModuleNotFound"); + }); + + it("should throw ModuleNotFound exception when module reference has just @ symbol", function() { + expect(function() { + cbwireController.getComponentDSL("@"); + }).toThrow(type="ModuleNotFound"); + }); + + it("should handle nested component paths with wires prefix", function() { + var result = cbwireController.getComponentDSL("nested.TestComponent"); + expect(result).toBe("wires.nested.TestComponent"); + }); + + it("should handle component with existing wires in middle of path", function() { + var result = cbwireController.getComponentDSL("some.wires.path.TestComponent"); + expect(result).toBe("some.wires.path.TestComponent"); + }); + + }); + describe("CBWIREController", function() { beforeEach(function(currentSpec) { @@ -1380,6 +1570,7 @@ component extends="coldbox.system.testing.BaseTestCase" { setup(); cbwireController = getInstance("CBWIREController@cbwire"); prepareMock( cbwireController ); + }); it( "should return an object", function() { @@ -1457,15 +1648,21 @@ component extends="coldbox.system.testing.BaseTestCase" { } ).toThrow( type="ModuleNotFound" ); } ); - it( "can render component from nested module using default wires location", function() { + it( "can render component from module using default wires location", function() { var result = cbwireController.wire( "NestedModuleDefaultComponent@testingmodule" ); expect( result ).toContain( "Nested module component using default wires location" ); } ); + it( "can render component from module using nested folder", function() { + var result = cbwireController.wire( "wires.nestedComponent.NestedFolderComponent@testingmodule" ); + expect( result ).toContain( "Nested folder component" ); + } ); + it( "can load components from an external modules folder", function() { var result = cbwireController.wire( "should_load_external_modules@ExternalModule" ); expect( result ).toInclude( "External Module Loaded" ); } ); + }); describe( "Preprocessors", function() { @@ -1612,7 +1809,8 @@ component extends="coldbox.system.testing.BaseTestCase" { "calls": arguments.calls, "snapshot": { "data": arguments.data, - "memo": arguments.memo + "memo": arguments.memo, + "checksum": "" }, "updates": arguments.updates } @@ -1621,7 +1819,7 @@ component extends="coldbox.system.testing.BaseTestCase" { }; response.content.components = response.content.components.map( function( _comp ) { - _comp.snapshot = serializeJson( _comp.snapshot ); + _comp.snapshot = getInstance( "ChecksumService@cbwire" ).calculateChecksum( _comp.snapshot ); return _comp; } ); @@ -1649,29 +1847,96 @@ component extends="coldbox.system.testing.BaseTestCase" { } /** - * Parse the snapshot from a rendered HTML component + * Parse the snapshot from a rendered HTML component by decoding + * ALL HTML entities before attempting JSON deserialization. * - * @return struct + * @html string | The rendered HTML containing the component. + * @index numeric | The index of the component if multiple match (usually 1). + * + * @return struct The deserialized snapshot struct. + * + * @throws Error if parsing or deserialization fails. */ - private function parseSnapshot( html, index = 1 ) { - local.match = reMatchNoCase( "wire:snapshot=""([^""]+)", html )[ index ]; - local.regexMatches = reFindNoCase( "wire:snapshot=""([^""]+)", local.match, 1, true ); - local.snapshot = local.regexMatches.match[ 2 ]; - local.snapshot = replaceNoCase( local.snapshot, """, """", "all" ); - return deserializeJSON( local.snapshot ); + private function parseSnapshot( required string html, numeric index = 1 ) { + // Use single quotes for regex literals to avoid excessive escaping + local.match = reMatchNoCase( 'wire:snapshot="([^"]+)"', arguments.html ); + if ( !arrayLen( local.match ) >= arguments.index ) { + throw( message="Snapshot attribute not found at index #arguments.index# in provided HTML.", detail=arguments.html ); + } + local.snapshotAttributeMatch = local.match[ arguments.index ]; + + // Extract the encoded value + local.regexMatches = reFindNoCase( 'wire:snapshot="([^"]+)"', local.snapshotAttributeMatch, 1, true ); + if ( !arrayLen( local.regexMatches.match ) == 2 ) { + throw( message="Could not extract snapshot value using regex from attribute match.", detail=local.snapshotAttributeMatch ); + } + local.snapshotEncoded = local.regexMatches.match[ 2 ]; + + // Decode ALL HTML entities (handles ", ", ", <, &, etc.) + local.snapshotDecoded = canonicalize( local.snapshotEncoded, true, true ); // Key change! + + try { + return deserializeJSON( local.snapshotDecoded ); + } catch ( any e ) { + // Provide more context on failure for easier debugging + var errorMsg = "Failed to deserialize snapshot JSON after decoding HTML entities."; + errorMsg &= " Decoded JSON string was: [#encodeForHtml(local.snapshotDecoded)#]."; // Encode for safe display + errorMsg &= " Original Error: #e.message# #e.detail#"; + throw( message=errorMsg, detail=local.snapshotDecoded ); + } } /** - * Parse the effects from a rendered HTML component + * Parse the effects from a rendered HTML component by decoding + * ALL HTML entities before attempting JSON deserialization. * - * @return struct + * @html The rendered HTML containing the component. + * @index The index of the component if multiple match (usually 1). + * + * @return any The deserialized effects (usually struct or array). + * + * @throws Error if parsing or deserialization fails. + */ + private function parseEffects( required string html, numeric index = 1 ) { + // Use single quotes for regex literals + local.match = reMatchNoCase( 'wire:effects="([^"]+)"', arguments.html ); + if ( !arrayLen( local.match ) >= arguments.index ) { + throw( message="Effects attribute not found at index #arguments.index# in provided HTML.", detail=arguments.html ); + } + local.effectsAttributeMatch = local.match[ arguments.index ]; + + // Extract the encoded value + local.regexMatches = reFindNoCase( 'wire:effects="([^"]+)"', local.effectsAttributeMatch, 1, true ); + if ( !arrayLen( local.regexMatches.match ) == 2 ) { + throw( message="Could not extract effects value using regex from attribute match.", detail=local.effectsAttributeMatch ); + } + local.effectsEncoded = local.regexMatches.match[ 2 ]; + + // Decode ALL HTML entities + local.effectsDecoded = canonicalize( local.effectsEncoded, true, true ); // Key change! + + try { + if ( isJSON( local.effectsDecoded ) ) { + return deserializeJSON( local.effectsDecoded ); + } else { + return {}; + } + } catch ( any e ) { + // Provide more context on failure + var errorMsg = "Failed to deserialize effects JSON after decoding HTML entities."; + errorMsg &= " Decoded JSON string was: [#encodeForHtml(local.effectsDecoded)#]."; // Encode for safe display + errorMsg &= " Original Error: #e.message# #e.detail#"; + throw( message=errorMsg, detail=local.effectsDecoded ); + } + } + + /** + * Check if the current environment is a BoxLang environment + * + * @return boolean */ - private function parseEffects( html, index = 1 ) { - local.match = reMatchNoCase( "wire:effects=""([^""]+)", html )[ index ]; - local.regexMatches = reFindNoCase( "wire:effects=""([^""]+)", local.match, 1, true ); - local.effects = local.regexMatches.match[ 2 ]; - local.effects = replaceNoCase( local.effects, """, """", "all" ); - return deserializeJSON( local.effects ); + private function isBoxLang() { + return server.keyExists( "boxlang" ); } }