diff --git a/AGENTS.md b/AGENTS.md index 6cb6f7588..819865974 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.BrowserTestCase" {`. 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 dc17dffb7..0b6dd5ed8 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `coldbox.system.testing.BrowserTestCase` (BoxLang): browser tests for ColdBox applications built on TestBox browser support and bx-playwright. It loads your application like any integration test and adds `browse()`, `this.playwright()`, `browserAvailable()`, `browserUnavailableReason()`, the `browserProfile` and `baseURL` annotations, the TestBox browser matchers, and the ColdBox helpers `routeURL()`, `visitRoute()` and `assertRouteIs()`. Logged-in tests use bx-playwright saved sessions + +### 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 + ## [8.2.0] - 2026-09-23 - diff --git a/system/testing/BrowserTestCase.bx b/system/testing/BrowserTestCase.bx new file mode 100644 index 000000000..086a9f100 --- /dev/null +++ b/system/testing/BrowserTestCase.bx @@ -0,0 +1,353 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Base test case for ColdBox browser tests, built on TestBox browser support and the bx-playwright module. + * BoxLang only. + * + * It loads the ColdBox application like any ColdBox integration test, so the browser helpers know your + * routes, and drives a real browser against your running application: + * + *
+ * class extends="coldbox.system.testing.BrowserTestCase" {
+ *     function run() {
+ *         describe( "Users", () => {
+ *             it( "shows a user", () => {
+ *                 browse( ( page ) => {
+ *                     visitRoute( page, "users.show", { id : 5 } )
+ *                     assertRouteIs( page, "users.show" )
+ *                     expect( page ).toSee( "User 5" )
+ *                 } )
+ *             } )
+ *         } )
+ *     }
+ * }
+ * 
+ * + * Class annotations, written as BoxLang annotations above `class`, besides the BaseTestCase ones + * (`@appMapping( "/root" )`, `@webMapping`, `@configMapping`, ...): + * - `@browserProfile( "ci" )`: bx-playwright profiles for the bundle browser, a list such as `ci,mobile` + * - `@baseURL( "http://127.0.0.1:8080" )`: the URL of your running application, relative visits resolve against it + * + * Everything browser related is delegated to testbox.system.browser.BrowserSupport, exactly like TestBox's + * BrowserSpec: the bundle shares one browser started on first use, every browse() call gets fresh, isolated + * pages, and the browser closes after the bundle through the `closeBrowser()` method, which carries the + * `afterAll` annotation. The browser matchers of testbox.system.browser.BrowserMatchers are registered for + * every spec of the bundle. `this.playwright()` returns the bundle manager; an unqualified `playwright()` + * still calls the bx-playwright BIF. + * + * For logged-in tests, use a bx-playwright saved session: log in through your login page once with + * `this.playwright().session( "admin", ( page ) => ... )`, then `browse( ( page ) => ..., { session : "admin" } )`. + * + * When bx-playwright is not installed, or the TestBox install has no browser support (testbox.system.browser), + * browse() and visitRoute() skip the running spec. + * Browser specs are not thread safe: do not use `asyncAll` in suites that browse. + */ +class extends="coldbox.system.testing.BaseTestCase" { + + // Does the TestBox install have browser support (testbox.system.browser)? Older TestBox releases do not: + // the route helpers still work, and the browser helpers skip the running spec. + variables.$testBoxBrowserSupport = fileExists( expandPath( "/testbox/system/browser/BrowserSupport.bx" ) ) + + // Browser matchers for every spec of the bundle: expect( page ).toSee( "Welcome" ) + if ( variables.$testBoxBrowserSupport ) { + addMatchers( new testbox.system.browser.BrowserMatchers() ) + } + + /** + * -------------------------------------------------------------------------- + * Browser + * -------------------------------------------------------------------------- + */ + + /** + * Run a callback with fresh browser pages: one page per declared callback argument, each in its own + * browser context. When the callback throws, the kept screenshots, trace and videos are attached to the + * spec and the exception is rethrown. Skips the spec when browser testing is not available. + * + * @callback The function to run, receiving one page per declared argument + * @options Context options for bx-playwright newContext(), for example { viewport : { width : 390, height : 844 } } + * + * @return The callback result, or null when it returns nothing + */ + function browse( required function callback, struct options = {} ) { + ensureTestBoxBrowserSupport() + return getBrowserSupport().browse( argumentCollection = arguments ) + } + + /** + * The bx-playwright manager of this bundle, created on first use with the `browserProfile` and `baseURL` + * annotations. Skips the spec when browser testing is not available. + * + * Call it as `this.playwright()`: BoxLang resolves an unqualified `playwright()` call to the bx-playwright + * BIF, even inside this class, which returns a new manager that the bundle does not close. + * + * @return The bx-playwright manager (models.Playwright@playwright) + */ + function playwright() { + ensureTestBoxBrowserSupport() + return getBrowserSupport().getManager() + } + + /** + * Can browser specs run here? True on BoxLang with the bx-playwright module installed and a TestBox + * release with browser support. + * Handy for skip constraints: it( title = "...", body = () => {}, skip = !browserAvailable() ) + * + * @return True when browser testing is available + */ + boolean function browserAvailable() { + return variables.$testBoxBrowserSupport && getBrowserSupport().isAvailable() + } + + /** + * Why browser specs cannot run here, or an empty string when they can. + * + * @return The reason browser testing is not available + */ + string function browserUnavailableReason() { + if ( !variables.$testBoxBrowserSupport ) { + return "Browser specs need TestBox browser support (testbox.system.browser.BrowserSupport), which this TestBox install does not have: update TestBox" + } + return getBrowserSupport().isAvailable() ? "" : getBrowserSupport().getUnavailableReason() + } + + /** + * The browser support of this bundle, which owns the bundle manager. + * + * @return The testbox.system.browser.BrowserSupport of this bundle + */ + function getBrowserSupport() { + if ( !variables.$testBoxBrowserSupport ) { + throw( + type = "BrowserTestCase.BrowserSupportUnavailable", + message = browserUnavailableReason() + ) + } + if ( isNull( variables.$browserSupport ) ) { + variables.$browserSupport = new testbox.system.browser.BrowserSupport( this ) + } + return variables.$browserSupport + } + + /** + * Close the bundle browser after all the specs ran. It carries the `afterAll` annotation, so TestBox runs it + * after your own afterAll() without a super call. + * + * @return This test case + */ + @afterAll + function closeBrowser() { + if ( !isNull( variables.$browserSupport ) ) { + variables.$browserSupport.close() + } + return this + } + + /** + * -------------------------------------------------------------------------- + * Routes + * -------------------------------------------------------------------------- + */ + + /** + * 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. + * + *
+	 * 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 toPath( getRequestContext().route( arguments.name, arguments.params ) ) + } + + /** + * Visit a named route: page.visit( routeURL( name, params ) ). Relative routes resolve against the + * `baseURL` annotation. Pages come from browse(), which skips the spec when browser testing is not available. + * + * @page The bx-playwright page + * @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 the 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 + * @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 #jsonSerialize( arguments.params )#" + pathRegex = quoteRegex( toPath( routeURL( arguments.name, arguments.params ), false ).reReplace( "/+$", "" ) ) + } + 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 ) ) { + rethrow + } + throw( + type = "TestBox.AssertionFailed", + message = "Expected the page to be on #expected#, but the path is [#toPath( arguments.page.url() )#]", + detail = "The page URL must match the regex [#urlRegex#]. #e.message#" + ) + } + return arguments.page + } + + /** + * -------------------------------------------------------------------------- + * Private helpers + * -------------------------------------------------------------------------- + */ + + /** + * Skip the running spec when the TestBox install has no browser support. + */ + private void function ensureTestBoxBrowserSupport() { + if ( !variables.$testBoxBrowserSupport ) { + skip( browserUnavailableReason() ) + } + } + + /** + * 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 toPath( 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 = ( route.regexPattern ?: "" ).reReplace( "^/+|/+$", "", "all" ) + if ( !variants.findNoCase( variant ) ) { + variants.append( variant ) + } + } + } + if ( !matched ) { + throw( type = "InvalidArgumentException", message = "The named route '#arguments.name#' does not exist" ) + } + var regex = quoteRegex( toPath( getRequestContext().getSESBaseURL(), false ).reReplace( "/+$", "" ) ) + var entryPoint = moduleEntryPoint( arguments.name ).reReplace( "^/+|/+$", "", "all" ) + if ( len( entryPoint ) ) { + regex &= "/" & quoteRegex( entryPoint ) + } + var paths = variants.filter( ( variant ) => 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 moduleEntryPoint( 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 quoteRegex( required string text ) { + return arguments.text.reReplace( "([.*+?^$\{\}()|\[\]\\/])", "\\\1", "all" ) + } + +} 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 022c22fe0..c9d62f038 100644 --- a/test-harness/config/Router.cfc +++ b/test-harness/config/Router.cfc @@ -126,6 +126,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..4d172c690 --- /dev/null +++ b/tests/resources/browser/FakePage.bx @@ -0,0 +1,103 @@ +/** + * A stand-in for a bx-playwright page, so BrowserTestCase 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/BrowserTestCaseSpec.bx b/tests/specs/browser/BrowserTestCaseSpec.bx new file mode 100644 index 000000000..3fcd321c5 --- /dev/null +++ b/tests/specs/browser/BrowserTestCaseSpec.bx @@ -0,0 +1,183 @@ +/** + * coldbox.system.testing.BrowserTestCase: route helpers, and browser tests against the test harness. + * + * 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 or the TestBox install has no + * browser support. BoxLang only: tests/runner.cfm excludes this folder on other engines. + */ +@appMapping( "/cbTestHarness" ) +@baseURL( "http://127.0.0.1:8599" ) +class extends="coldbox.system.testing.BrowserTestCase" { + + /*********************************** BDD SUITES ***********************************/ + + function run() { + describe( "BrowserTestCase 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( "explains why browser testing is not available", () => { + if ( browserAvailable() ) { + expect( browserUnavailableReason() ).toBe( "" ) + } else { + expect( browserUnavailableReason() ).notToBeEmpty() + } + } ) + + 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 = "BrowserTestCase 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 = !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", [] );