diff --git a/AGENTS.md b/AGENTS.md index 6cb6f7588..a7f356c91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,11 @@ ColdBox is an HMVC (Hierarchical Model-View-Controller) framework designed for t - Bodyless component calls, e.g. `bx:component;` - `continue;` and `break;` statements for Adobe ColdFusion/Lucee compatibility. +### Annotations And Optional Values +- In BoxLang (`.bx`) classes and examples, write annotations as BoxLang annotations above the declaration, not as inline attributes: `@appMapping( "/root" )` and `@baseURL( "http://127.0.0.1:8080" )` on the lines before `class extends="coldbox.system.testing.BaseTestCase" {`. Keep `extends` and `implements` inline. CFML (`.cfc`) components keep inline attributes. +- Do not start a docblock line with `@` in an example, because BoxLang reads it as documentation metadata. +- Prefer the elvis operator `?:` and safe navigation `?.` over `structKeyExists()` and `isNull()` checks when they say the same thing. + ## JavaScript Coding Standards ### Spacing and Formatting diff --git a/changelog.md b/changelog.md index 651c5e17c..59b71a339 100644 --- a/changelog.md +++ b/changelog.md @@ -9,26 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- `COLDBOX-1456` `Bootstrap.onSessionStart()` ran the session start handler on a controller that was still loading during a reinit. It now skips the event until the controller is initiated. -- WireBox released a circular partner to other threads before the outer object finished wiring. A singleton, engine scope or CacheBox object built inside another build left the wiring set as soon as its own wiring ended, while holding a reference to the outer object. Keys now stay marked until the outermost build completes, tracked on the injector across all scopes. -- `COLDBOX-1455` WireBox singleton, engine (application, session, server) and CacheBox scopes handed objects to other threads before their dependencies were wired. A single wiring lock now makes other threads wait, while the wiring thread still resolves circular dependencies. - -### Changed - -- WireBox `CFScopes` scope renamed to `EngineScopes` - ### Added +- Browser testing for ColdBox applications (BoxLang), built on TestBox browser support and bx-playwright: annotate any `BaseTestCase` with `@browser`, `@browserProfile` or `@baseURL` and it gets TestBox's `browse()`, `this.playwright()`, `browserAvailable()` and browser matchers, while it still loads your application like any integration test. `BaseTestCase` adds the ColdBox helpers `routeURL()`, `visitRoute()` and `assertRouteIs()` for named routes, including module routes. Logged-in tests use bx-playwright saved sessions. Needs a TestBox release with annotation-driven browser support (Ortus-Solutions/TestBox#222). - `group()` now accepts a `meta` struct option that every route inside the group inherits. Nested groups merge outer-first and a route's own `meta()` values win on conflict. This lets a single group declare route metadata, for example the permissions consumed by security middleware. - `Router.registerMiddleware()` registers a closure, lambda, object instance or WireBox ID under a name, singly or as a struct of `name : target` pairs, so it can be referenced from `middleware()`, `group( { middleware : [ name ] } )` and `withoutMiddleware( name )`. Registering a name that already exists, including a `middlewareGroup()` name, throws `Router.DuplicateMiddleware` unless `force = true`. (COLDBOX-1460) ### Fixed +- `event.route( "name@module" )` built module route links without a slash between the module entry point and the route pattern +- Adobe ColdFusion: a request context decorator copied the `this` reference of the original context, so its inherited methods ran against the original context and missed the decorator's own state and mocks +- `COLDBOX-1456` `Bootstrap.onSessionStart()` ran the session start handler on a controller that was still loading during a reinit. It now skips the event until the controller is initiated. +- WireBox released a circular partner to other threads before the outer object finished wiring. A singleton, engine scope or CacheBox object built inside another build left the wiring set as soon as its own wiring ended, while holding a reference to the outer object. Keys now stay marked until the outermost build completes, tracked on the injector across all scopes. +- `COLDBOX-1455` WireBox singleton, engine (application, session, server) and CacheBox scopes handed objects to other threads before their dependencies were wired. A single wiring lock now makes other threads wait, while the wiring thread still resolves circular dependencies. - `BaseTestCase.execute()` (and the `get()`, `post()`, etc. helpers built on it) now runs route-scoped middleware registered with `Router.middleware()`, in the same order as the Bootstrap, so integration tests exercise the same request lifecycle as a real request. - Passing a component instance as route middleware (for example `middleware( myObject )`) no longer fails on engines where `isStruct()` is true for components. (COLDBOX-1460) +### Changed + +- WireBox `CFScopes` scope renamed to `EngineScopes` + ## [8.2.0] - 2026-09-23 - diff --git a/system/testing/BaseTestCase.cfc b/system/testing/BaseTestCase.cfc index 88f01786f..8f0f98480 100755 --- a/system/testing/BaseTestCase.cfc +++ b/system/testing/BaseTestCase.cfc @@ -895,6 +895,229 @@ component extends="testbox.system.compat.framework.TestCase" accessors="true" { return variables.env; } + /********************************************* BROWSER TESTING ROUTE HELPERS *********************************************/ + + /** + * The path of a named route, without scheme and host, built by ColdBox's own event.route(), so it carries + * the routing app mapping and, for module routes (`name@module` or `module:name`), the module entry point. + * Use it with TestBox browser specs: annotate the test with `@browser`, `@browserProfile` or `@baseURL`. + * + *
+	 * routeURL( "users.show", { id : 5 } )   // /users/5/
+	 * routeURL( "home@blog" )                // /blog/home/
+	 * 
+ * + * @name The route name, `name@module` or `module:name` for module routes + * @params The route placeholder values, for example { id : 5 } + * + * @return The route path, with the query string when the link has one + * + * @throws InvalidArgumentException When the named route does not exist + */ + string function routeURL( required string name, struct params = {} ){ + return routeLinkPath( getRequestContext().route( arguments.name, arguments.params ) ) + } + + /** + * Visit a named route with a bx-playwright page: page.visit( routeURL( name, params ) ). Relative routes + * resolve against the browser spec `baseURL`. + * + * @page The bx-playwright page, from browse() + * @name The route name, `name@module` or `module:name` for module routes + * @params The route placeholder values, for example { id : 5 } + * + * @return The page + */ + function visitRoute( + required page, + required string name, + struct params = {} + ){ + arguments.page.visit( routeURL( arguments.name, arguments.params ) ) + return arguments.page + } + + /** + * Assert that a bx-playwright page is on a named route. With params, the page path must be the path of + * routeURL( name, params ). Without params, the page path must match the route pattern, so any value of + * its placeholders passes. Like ColdBox routing, the match ignores case and the trailing slash, and the + * query string and hash are ignored. It waits for the page URL with bx-playwright's waitForUrl(), up to the + * bx-playwright assertion timeout (the `timeouts.assertion` setting). + * + *
+	 * assertRouteIs( page, "users.show" )               // any user
+	 * assertRouteIs( page, "users.show", { id : 5 } )   // user 5
+	 * 
+ * + * @page The bx-playwright page, from browse() + * @name The route name, `name@module` or `module:name` for module routes + * @params The route placeholder values, empty to match any value of the placeholders + * + * @return The page + * + * @throws TestBox.AssertionFailed When the page path does not match the route before the assertion timeout + */ + function assertRouteIs( + required page, + required string name, + struct params = {} + ){ + var expected = "route [#arguments.name#]" + var pathRegex = "" + if ( arguments.params.isEmpty() ) { + pathRegex = routePathRegex( arguments.name ) + } else { + expected = "route [#arguments.name#] with params #serializeJSON( arguments.params )#" + pathRegex = quoteRouteRegex( + reReplace( + routeLinkPath( routeURL( arguments.name, arguments.params ), false ), + "/+$", + "" + ) + ) + } + var urlRegex = "^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]*" & pathRegex & "/?(\?.*)?(##.*)?$" + var timeout = arguments.page.getConfig().timeouts.assertion ?: 5000 + try { + arguments.page.waitForUrl( arguments.page.regex( urlRegex, "i" ), timeout ) + } catch ( any e ) { + if ( !listFindNoCase( "Playwright.Timeout,Playwright.AssertionFailed", e.type ) ) { + throw( object = e ) + } + throw( + type = "TestBox.AssertionFailed", + message = "Expected the page to be on #expected#, but the path is [#routeLinkPath( arguments.page.url() )#]", + detail = "The page URL must match the regex [#urlRegex#]. #e.message#" + ) + } + return arguments.page + } + + /** + * The path of a URL, without scheme and host. + * + * @link An absolute or relative URL + * @withQuery Keep the query string + * + * @return The raw path, plus the raw query string when there is one and withQuery is true + */ + private string function routeLinkPath( required string link, boolean withQuery = true ){ + var uri = createObject( "java", "java.net.URI" ).create( arguments.link ) + var path = uri.getRawPath() ?: "" + var query = uri.getRawQuery() ?: "" + if ( !len( path ) ) { + path = "/" + } + return arguments.withQuery && len( query ) ? path & "?" & query : path + } + + /** + * The regex a page path must match to be on a named route, any value of its placeholders included: the + * routing path of the application, the module entry point and the route's own regex, which ColdBox + * builds from the route pattern and its constraints. Every route registered with the name counts, so a + * route with optional placeholders matches with and without them. Not anchored, and without the trailing slash. + * + * @name The route name, `name@module` or `module:name` for module routes + * + * @return The path regex + * + * @throws InvalidArgumentException When the named route does not exist + */ + private string function routePathRegex( required string name ){ + var router = getController().getWireBox().getInstance( "router@coldbox" ) + var routes = router.getRoutes() + var routeName = arguments.name + if ( find( "@", arguments.name ) ) { + routes = router.getModuleRoutes( getToken( arguments.name, 2, "@" ) ) + routeName = getToken( arguments.name, 1, "@" ) + } else if ( find( ":", arguments.name ) ) { + routes = router.getModuleRoutes( getToken( arguments.name, 1, ":" ) ) + routeName = getToken( arguments.name, 2, ":" ) + } + // A route with optional placeholders, such as /posts/:id?, is registered as several routes with the same + // name (/posts/:id, then /posts): the page may be on any of them + var variants = [] + var matched = false + for ( var route in routes ) { + if ( route.name == routeName ) { + matched = true + var variant = reReplace( route.regexPattern ?: "", "^/+|/+$", "", "all" ) + if ( !variants.findNoCase( variant ) ) { + variants.append( variant ) + } + } + } + if ( !matched ) { + throw( + type = "InvalidArgumentException", + message = "The named route '#arguments.name#' does not exist" + ) + } + var regex = quoteRouteRegex( + reReplace( + routeLinkPath( getRequestContext().getSESBaseURL(), false ), + "/+$", + "" + ) + ) + var entryPoint = reReplace( + routeModuleEntryPoint( arguments.name ), + "^/+|/+$", + "", + "all" + ) + if ( len( entryPoint ) ) { + regex &= "/" & quoteRouteRegex( entryPoint ) + } + var paths = variants.filter( function( variant ){ + return len( variant ) + } ) + if ( paths.len() ) { + var alternatives = "/(?:" & paths.toList( "|" ) & ")" + // An empty variant (the route is the root of the app or module) matches without a path + regex &= paths.len() < variants.len() ? "(?:" & alternatives & ")?" : alternatives + } + return regex + } + + /** + * The inherited entry point of the module of a route name (`name@module` or `module:name`). + * + * @name The route name + * + * @return The module entry point, or an empty string for application routes + */ + private string function routeModuleEntryPoint( required string name ){ + var module = "" + if ( find( "@", arguments.name ) ) { + module = getToken( arguments.name, 2, "@" ) + } + if ( find( ":", arguments.name ) ) { + module = getToken( arguments.name, 1, ":" ) + } + if ( !len( module ) ) { + return "" + } + var modules = getController().getSetting( "modules" ) + return modules.keyExists( module ) ? modules[ module ].inheritedEntryPoint : "" + } + + /** + * Escape regex special characters. Playwright runs URL regexes in the browser, so Java's \Q...\E quoting cannot be used. + * + * @text The literal text + * + * @return The text with regex special characters escaped + */ + private string function quoteRouteRegex( required string text ){ + return reReplace( + arguments.text, + "([.*+?^$\{\}()|\[\]\\/])", + "\\\1", + "all" + ) + } + /** * Separate a route into two parts: the base route, and a query string collection * diff --git a/system/web/context/RequestContext.cfc b/system/web/context/RequestContext.cfc index 009909e80..a55026acb 100644 --- a/system/web/context/RequestContext.cfc +++ b/system/web/context/RequestContext.cfc @@ -277,9 +277,10 @@ component serializable="false" accessors="true" { * Get a representation of this instance */ struct function getMemento(){ - // Return only non-function elements + // Return only non-function elements, without the `this` reference that Adobe keeps in the variables scope: + // a decorator restoring it would run its own methods with `this` pointing to the original context return variables.filter( function( key, value ){ - return ( !isCustomFunction( value ) ); + return ( !isCustomFunction( value ) && arguments.key != "this" ); } ); } @@ -1371,8 +1372,13 @@ component serializable="false" accessors="true" { // Did we find it? if ( !foundRoute.isEmpty() ) { + // Join the module entry point and the route pattern with a single slash + var routePath = foundRoute.pattern + if ( len( entryPoint ) ) { + routePath = reReplace( entryPoint, "/+$", "" ) & "/" & reReplace( routePath, "^/+", "" ) + } var args = { - to : entryPoint & foundRoute.pattern, + to : routePath, ssl : !isNull( arguments.ssl ) ? arguments.ssl : javacast( "null", "" ) }; diff --git a/test-harness/config/Router.cfc b/test-harness/config/Router.cfc index 4833f2842..4fef49f1a 100644 --- a/test-harness/config/Router.cfc +++ b/test-harness/config/Router.cfc @@ -168,6 +168,12 @@ component { route( "invalid-main-verbs" ).withVerbs( "post" ).to( "main.index" ); + // Browser testing routes: tests/specs/browser + route( "/users/:id" ).as( "users.show" ).to( "browserTesting.user" ) + route( "/browser-posts/:id?" ).as( "browserPosts" ).to( "browserTesting.user" ) + route( "/browser-testing/login" ).as( "browserTesting.login" ).to( "browserTesting.login" ) + route( "/browser-testing/whoami" ).as( "browserTesting.whoami" ).to( "browserTesting.whoami" ) + // Default Application Routing route( ":handler/:action?/:id-numeric?" ).end(); diff --git a/test-harness/handlers/browserTesting.cfc b/test-harness/handlers/browserTesting.cfc new file mode 100644 index 000000000..bed4e46f7 --- /dev/null +++ b/test-harness/handlers/browserTesting.cfc @@ -0,0 +1,35 @@ +/** + * Pages for the browser tests of tests/specs/browser + */ +component { + + /** + * Show a user: the users.show named route + */ + function user( event, rc, prc ){ + return "

User #encodeForHTML( rc.id )#

" + } + + /** + * A login form, like an application login page: GET shows the form, POST logs the user in and shows who is + * logged in (no relocate(): the harness runs with the test controller, which intercepts relocations) + */ + function login( event, rc, prc ){ + if ( event.getHTTPMethod() == "POST" ) { + session.browserTestingUser = rc.user ?: "" + return whoami( argumentCollection = arguments ) + } + return "
" + } + + /** + * Show who is logged in + */ + function whoami( event, rc, prc ){ + if ( len( session.browserTestingUser ?: "" ) ) { + return "

Logged in as #encodeForHTML( session.browserTestingUser )#

" + } + return "

Guest

" + } + +} diff --git a/tests/resources/browser/FakePage.bx b/tests/resources/browser/FakePage.bx new file mode 100644 index 000000000..4204f0d10 --- /dev/null +++ b/tests/resources/browser/FakePage.bx @@ -0,0 +1,103 @@ +/** + * A stand-in for a bx-playwright page, so the BaseTestCase route helpers can be tested without a browser. + * It records visits and answers url(), regex(), getConfig() and waitForUrl() like bx-playwright, without waiting. + */ +class { + + /** + * Constructor + * + * @currentURL The URL the page is on + * + * @return The fake page + */ + function init( string currentURL = "about:blank" ) { + variables.currentURL = arguments.currentURL + variables.visits = [] + return this + } + + /** + * Record a visit and move the page to it + * + * @target The visited URL or path + * + * @return The page + */ + function visit( required string target ) { + variables.visits.append( arguments.target ) + variables.currentURL = arguments.target + return this + } + + /** + * The visits of the page, in order + * + * @return The visited URLs or paths + */ + array function getVisits() { + return variables.visits + } + + /** + * The current URL of the page + * + * @return The current URL + */ + string function url() { + return variables.currentURL + } + + /** + * Build a java.util.regex.Pattern like bx-playwright's regex() + * + * @pattern The regular expression + * @flags i for case insensitive + * + * @return The compiled pattern + */ + function regex( required string pattern, string flags = "" ) { + var Pattern = createObject( "java", "java.util.regex.Pattern" ) + return Pattern.compile( arguments.pattern, arguments.flags.findNoCase( "i" ) ? Pattern.CASE_INSENSITIVE : 0 ) + } + + /** + * The resolved bx-playwright configuration: the default timeouts + * + * @return The configuration struct + */ + struct function getConfig() { + return { timeouts : { action : 30000, navigation : 30000, assertion : 5000 } } + } + + /** + * Wait until the URL matches a pattern, timing out at once like bx-playwright's waitForUrl() when it does not + * + * @expected The java.util.regex.Pattern the URL must match + * @timeout The timeout in milliseconds, recorded only + * + * @return The page + * + * @throws Playwright.Timeout When the URL does not match + */ + function waitForUrl( required any expected, numeric timeout = 0 ) { + variables.lastTimeout = arguments.timeout + if ( !arguments.expected.matcher( variables.currentURL ).find() ) { + throw( + type = "Playwright.Timeout", + message = "Timeout #arguments.timeout#ms exceeded waiting for the URL #arguments.expected.pattern()#: received #variables.currentURL#" + ) + } + return this + } + + /** + * The timeout of the last waitForUrl() call + * + * @return The timeout in milliseconds + */ + numeric function getLastTimeout() { + return variables.lastTimeout ?: -1 + } + +} diff --git a/tests/runner.cfm b/tests/runner.cfm index 4a0872fc9..b7cb222fc 100644 --- a/tests/runner.cfm +++ b/tests/runner.cfm @@ -23,6 +23,11 @@ +// Browser specs are BoxLang classes: skip them on the other engines +if( !structKeyExists( server, "boxlang" ) ){ + url.directoryExcludes = listAppend( url.directoryExcludes, "/browser" ); +} + // Directory Filter: return true use, false do not process. function directoryFilter( required bundlePath ){ var excludeList = listToArray( url.directoryExcludes ); diff --git a/tests/specs/browser/BrowserTestingSpec.bx b/tests/specs/browser/BrowserTestingSpec.bx new file mode 100644 index 000000000..e985079f6 --- /dev/null +++ b/tests/specs/browser/BrowserTestingSpec.bx @@ -0,0 +1,197 @@ +/** + * Browser testing with coldbox.system.testing.BaseTestCase: the ColdBox route helpers, and browser tests against + * the test harness. The `@browser` annotation turns on TestBox browser support (`@baseURL` alone would too). + * + * The browser specs need bx-playwright and run against the test harness served by the test server on + * http://127.0.0.1:8599. They are skipped when bx-playwright is not installed. BoxLang only: tests/runner.cfm + * excludes this folder on other engines. + */ +@appMapping( "/cbTestHarness" ) +@browser +@baseURL( "http://127.0.0.1:8599" ) +class extends="coldbox.system.testing.BaseTestCase" { + + /*********************************** BDD SUITES ***********************************/ + + function run() { + describe( "Browser testing routes", () => { + beforeEach( ( currentSpec ) => { + variables.basePath = createObject( "java", "java.net.URI" ) + .create( getRequestContext().getSESBaseURL() ) + .getRawPath() + .reReplace( "/+$", "" ) + variables.host = "http://127.0.0.1:8599" + } ) + + it( "builds route paths without scheme and host", () => { + expect( routeURL( "users.show", { id : 5 } ) ).toBe( basePath & "/users/5/" ) + expect( routeURL( "testRoute" ) ).toBe( basePath & "/testroute/" ) + expect( routeURL( "users.show", { id : 5 } ) ).notToInclude( "://" ) + } ) + + it( "builds module route paths with the module entry point", () => { + expect( routeURL( "photos.edit@resourcesTest", { id : 3 } ) ).toBe( basePath & "/resourcesTest/photos/3/edit/" ) + expect( routeURL( "resourcesTest:photos.new" ) ).toBe( basePath & "/resourcesTest/photos/new/" ) + } ) + + it( "throws for unknown routes", () => { + expect( () => routeURL( "does.not.exist" ) ).toThrow( "InvalidArgumentException" ) + } ) + + it( "asserts the page is on a route, whatever its placeholders", () => { + var page = new tests.resources.browser.FakePage( host & basePath & "/users/5/" ) + expect( assertRouteIs( page, "users.show" ) ).toBe( page ) + expect( page.getLastTimeout() ).toBe( 5000 ) + assertRouteIs( new tests.resources.browser.FakePage( host & basePath & "/users/abc" ), "users.show" ) + } ) + + it( "ignores the trailing slash, case, query string and hash like ColdBox routing", () => { + for ( var path in [ "/users/5", "/users/5/", "/USERS/5", "/users/5?tab=posts", "/users/5/##bio" ] ) { + assertRouteIs( new tests.resources.browser.FakePage( host & basePath & path ), "users.show" ) + } + } ) + + it( "asserts the page is on a route with given params", () => { + var page = new tests.resources.browser.FakePage( host & basePath & "/users/5/" ) + assertRouteIs( page, "users.show", { id : 5 } ) + expect( () => assertRouteIs( page, "users.show", { id : 6 } ) ).toThrow( "TestBox.AssertionFailed" ) + } ) + + it( "fails with the expected route and the actual path", () => { + var page = new tests.resources.browser.FakePage( host & basePath & "/contactus/" ) + var failure = "" + try { + assertRouteIs( page, "users.show" ) + } catch ( any e ) { + failure = e.type & ": " & e.message + } + expect( failure ) + .toInclude( "TestBox.AssertionFailed: " ) + .toInclude( "route [users.show]" ) + .toInclude( "the path is [#basePath#/contactus/]" ) + + failure = "" + try { + assertRouteIs( page, "users.show", { id : 5 } ) + } catch ( any e ) { + failure = e.type & ": " & e.message + } + expect( failure ).toInclude( "TestBox.AssertionFailed: " ).toInclude( "route [users.show] with params" ) + } ) + + it( "does not match a longer path or another route", () => { + var longer = new tests.resources.browser.FakePage( host & basePath & "/users/5/posts" ) + var user = new tests.resources.browser.FakePage( host & basePath & "/users/5/" ) + expect( () => assertRouteIs( longer, "users.show" ) ).toThrow( "TestBox.AssertionFailed" ) + expect( () => assertRouteIs( user, "testRoute" ) ).toThrow( "TestBox.AssertionFailed" ) + } ) + + it( "asserts module routes", () => { + var page = new tests.resources.browser.FakePage( host & basePath & "/resourcesTest/photos/9/edit" ) + assertRouteIs( page, "photos.edit@resourcesTest" ) + assertRouteIs( page, "photos.edit@resourcesTest", { id : 9 } ) + } ) + + it( + title = "explains why browser testing is not available", + body = () => { + if ( browserAvailable() ) { + expect( getBrowserSupport().getUnavailableReason() ).toBe( "" ) + } else { + expect( getBrowserSupport().getUnavailableReason() ).notToBeEmpty() + } + }, + skip = !hasTestBoxBrowserSupport() + ) + + it( "asserts routes with optional placeholders, with and without them", () => { + assertRouteIs( new tests.resources.browser.FakePage( host & basePath & "/browser-posts/12" ), "browserPosts" ) + assertRouteIs( new tests.resources.browser.FakePage( host & basePath & "/browser-posts/" ), "browserPosts" ) + var other = new tests.resources.browser.FakePage( host & basePath & "/users/5/" ) + expect( () => assertRouteIs( other, "browserPosts" ) ).toThrow( "TestBox.AssertionFailed" ) + } ) + + it( "visits named routes without a browser", () => { + var page = new tests.resources.browser.FakePage() + expect( visitRoute( page, "users.show", { id : 7 } ) ).toBe( page ) + expect( page.getVisits() ).toBe( [ basePath & "/users/7/" ] ) + } ) + } ) + + describe( + title = "Browser testing in a browser", + body = () => { + beforeEach( ( currentSpec ) => { + // The test server serves the harness through its front controller + variables.originalSESBaseURL = getRequestContext().getSESBaseURL() + getRequestContext().setSESBaseURL( "http://127.0.0.1:8599/test-harness/index.cfm" ) + } ) + + afterEach( ( currentSpec ) => { + getRequestContext().setSESBaseURL( variables.originalSESBaseURL ) + } ) + + it( "visits and asserts named routes", () => { + browse( ( page ) => { + reinitHarness( page ) + visitRoute( page, "users.show", { id : 5 } ) + expect( page ).toSee( "User 5" ) + assertRouteIs( page, "users.show" ) + assertRouteIs( page, "users.show", { id : 5 } ) + expect( () => assertRouteIs( page, "users.show", { id : 6 } ) ).toThrow( "TestBox.AssertionFailed" ) + } ) + } ) + + it( "reuses a saved session created through the login page", () => { + this.playwright().session( + "coldbox-harness-user", + ( page ) => { + reinitHarness( page ) + visitRoute( page, "browserTesting.login" ) + page.fill( "User", "42" ).click( "Sign in" ) + expect( page ).toSee( "Logged in as 42" ) + }, + { refresh : true } + ) + browse( ( page ) => { + visitRoute( page, "browserTesting.whoami" ) + expect( page ).toSee( "Logged in as 42" ) + }, { session : "coldbox-harness-user" } ) + } ) + + it( "keeps every page of a browse() call in its own session", () => { + browse( ( user, guest ) => { + reinitHarness( user ) + visitRoute( user, "browserTesting.login" ) + user.fill( "User", "1" ).click( "Sign in" ) + visitRoute( guest, "browserTesting.whoami" ) + expect( user ).toSee( "Logged in as 1" ) + expect( guest ).toSee( "Guest" ) + } ) + } ) + }, + skip = !hasTestBoxBrowserSupport() || !browserAvailable() + ) + } + + /** + * Did TestBox attach browser support to this spec? TestBox releases without annotation-driven browser + * support do not, so the browser specs skip there and the route helper specs still run. + */ + private boolean function hasTestBoxBrowserSupport() { + return structKeyExists( variables, "browserAvailable" ) + } + + /** + * Reinitialize the harness through 127.0.0.1 once, so it detects its testing environment + * + * @page The page to reinitialize with + */ + private function reinitHarness( required page ) { + if ( isNull( variables.harnessReinitialized ) ) { + arguments.page.visit( "/test-harness/index.cfm?fwreinit=1" ) + variables.harnessReinitialized = true + } + } + +} diff --git a/tests/specs/web/context/RequestContextDecoratorTest.cfc b/tests/specs/web/context/RequestContextDecoratorTest.cfc index ab383bef3..bd11ba682 100755 --- a/tests/specs/web/context/RequestContextDecoratorTest.cfc +++ b/tests/specs/web/context/RequestContextDecoratorTest.cfc @@ -36,6 +36,35 @@ component extends="tests.resources.BaseIntegrationTest" { makePublic( mockDecorator, "getController" ); expect( mockController ).toBe( mockDecorator.getController() ); } ); + + it( "does not take the this scope of the original context", function(){ + expect( mockContext.getMemento() ).notToHaveKey( "this" ); + } ); + + it( "runs its inherited methods against its own mocks", function(){ + var context = new coldbox.system.web.context.RequestContext( + properties = { + defaultLayout : "Main.cfm", + defaultView : "", + folderLayouts : {}, + viewLayouts : {}, + eventName : "event", + sesBaseURL : "http://localhost/index.cfm", + registeredLayouts : {}, + modules : {} + }, + controller = mockController + ); + var decorator = prepareMock( + new coldbox.system.web.context.RequestContextDecorator( context, mockController ) + ); + decorator + .$( "getHTTPHeader" ) + .$args( "x-forwarded-proto", "http" ) + .$results( "https" ); + // isSSL() calls getHTTPHeader() unscoped, which must reach the mock of the decorator + expect( decorator.isSSL() ).toBeTrue(); + } ); } ); } diff --git a/tests/specs/web/context/RequestContextTest.cfc b/tests/specs/web/context/RequestContextTest.cfc index bbbcc6b56..cf11747d7 100755 --- a/tests/specs/web/context/RequestContextTest.cfc +++ b/tests/specs/web/context/RequestContextTest.cfc @@ -128,6 +128,20 @@ component extends="coldbox.system.testing.BaseModelTest" { expect( r ).toBe( "http://jfetmac/applications/coldbox/test-harness/index.cfm/mymodule/home/" ); } + function testModuleRoutesWithEntryPointWithoutTrailingSlash(){ + // Mocks: ModuleService stores inherited entry points without a trailing slash + var mockRouter = createStub().$( "findRouteByName", { name : "login", pattern : "login/:id/" } ) + mockController.getWireBox().$( "getInstance", mockRouter ) + + var event = getRequestContext().$property( + "modules", + "variables", + { myModule : { inheritedEntryPoint : "mymodule" } } + ) + var r = event.route( "login@mymodule", { id : 3 } ) + expect( r ).toBe( "http://jfetmac/applications/coldbox/test-harness/index.cfm/mymodule/login/3/" ) + } + function testInvalidRoute(){ // Mocks var mockSES = createStub().$( "getRoutes", [] );