From f05a4e2af8aac4b4d3cbca94d4350077b6d7e4fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:58:12 +0000 Subject: [PATCH 1/8] Fix module named routes built without a slash after the entry point event.route( "name@module" ) joined the module inherited entry point and the route pattern without a separator, so "login@BrowserTesting" built "__browser-testinglogin/3/". ModuleService stores inherited entry points without a trailing slash, so the link now joins them with a single slash. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi --- system/web/context/RequestContext.cfc | 7 ++++++- tests/specs/web/context/RequestContextTest.cfc | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/system/web/context/RequestContext.cfc b/system/web/context/RequestContext.cfc index 009909e80..2477e7c0b 100644 --- a/system/web/context/RequestContext.cfc +++ b/system/web/context/RequestContext.cfc @@ -1371,8 +1371,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/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", [] ); From 2ad0c9d25668c1f5faf6525f5a399f90373dbd08 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:58:12 +0000 Subject: [PATCH 2/8] Add the BrowserTesting core module with test-only login and logout endpoints GET /__browser-testing/login/:id and GET /__browser-testing/logout call the login and logout closures of moduleSettings.browserTesting. Every request gets a plain 404 unless the environment is testing, the module is enabled, a token is configured and matches the X-Browser-Testing-Token header or the token variable (compared in constant time), and the closure is set. The test harness enables it, and runs in its testing environment on 127.0.0.1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi --- .../modules/BrowserTesting/ModuleConfig.cfc | 85 +++++++++ .../modules/BrowserTesting/handlers/Auth.cfc | 147 ++++++++++++++++ test-harness/config/Coldbox.cfc | 17 +- .../integration/BrowserTestingModuleSpec.cfc | 163 ++++++++++++++++++ 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 system/modules/BrowserTesting/ModuleConfig.cfc create mode 100644 system/modules/BrowserTesting/handlers/Auth.cfc create mode 100644 tests/specs/integration/BrowserTestingModuleSpec.cfc diff --git a/system/modules/BrowserTesting/ModuleConfig.cfc b/system/modules/BrowserTesting/ModuleConfig.cfc new file mode 100644 index 000000000..54c90e8da --- /dev/null +++ b/system/modules/BrowserTesting/ModuleConfig.cfc @@ -0,0 +1,85 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Browser Testing core module: test-only login and logout endpoints that browser tests + * (coldbox.system.testing.BrowserTestCase) call through loginAs() and logout(). + * + * Routes, under the `__browser-testing` entry point: + * - GET /__browser-testing/login/:id : calls the `login` closure with ( id, event, rc, prc ) + * - GET /__browser-testing/logout : calls the `logout` closure with ( event, rc, prc ) + * + * Settings, overridden by the application in config/ColdBox.cfc: + * + *
+ * moduleSettings = {
+ *     browserTesting : {
+ *         enabled : true,
+ *         token   : getSystemSetting( "BROWSER_TESTING_TOKEN", "" ),
+ *         login   : ( id, event, rc, prc ) => auth().login( userService.get( id ) ),
+ *         logout  : ( event, rc, prc ) => auth().logout()
+ *     }
+ * }
+ * 
+ * + * Security model: the endpoints log anyone in as any user, so they are locked down by default and + * every request must pass ALL of these checks, else it gets a plain `404 Not Found`, exactly like a + * missing page, and no closure runs: + * - The application environment (the `environment` setting) is `testing` + * - The `enabled` setting is `true` (it defaults to `false`) + * - The `token` setting is not empty, and the request sends the same token in the + * `X-Browser-Testing-Token` header or the `token` URL/FORM variable. Tokens are compared in constant time + * - The closure of the endpoint (`login` or `logout`) is set + * - The request uses GET + * + * The checks run in the handler actions on every request, so they also cover the module convention + * route and `event=` executions. Use a random token per environment, kept out of source control + * (an environment variable), and never enable the module in an environment that real users can reach. + * The entry point starts with two underscores so it does not clash with application routes. + */ +component { + + // Module Properties + this.title = "Browser Testing" + this.description = "Test-only login and logout endpoints for ColdBox browser tests" + // Model Namespace and module settings key: moduleSettings.browserTesting + this.modelNamespace = "browserTesting" + // Route entry point + this.entryPoint = "__browser-testing" + // No models to map + this.autoMapModels = false + + /** + * Configure the module settings and routes + */ + function configure(){ + // module settings - stored in modules.name.settings + variables.settings = { + // Browser testing endpoints are off unless the application turns them on + enabled : false, + // The shared secret every request must send, an empty token disables the endpoints + token : "", + // The login closure: ( id, event, rc, prc ) => {} + login : "", + // The logout closure: ( event, rc, prc ) => {} + logout : "" + } + + // Module routes + variables.routes = [ + { + pattern : "/login/:id", + handler : "Auth", + action : { GET : "login" }, + name : "login" + }, + { + pattern : "/logout", + handler : "Auth", + action : { GET : "logout" }, + name : "logout" + } + ] + } + +} diff --git a/system/modules/BrowserTesting/handlers/Auth.cfc b/system/modules/BrowserTesting/handlers/Auth.cfc new file mode 100644 index 000000000..1745a37c9 --- /dev/null +++ b/system/modules/BrowserTesting/handlers/Auth.cfc @@ -0,0 +1,147 @@ +/** + * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + * Test-only login and logout endpoints for browser tests. + * Every action answers a plain `404 Not Found` unless the request passes all the checks of the + * security model documented in the module's ModuleConfig.cfc. + */ +component extends="coldbox.system.EventHandler" { + + // Only GET requests reach the actions + this.allowedMethods = { login : "GET", logout : "GET" } + + /** + * Log in a user for a browser test by calling the `login` closure of the module settings + * with ( id, event, rc, prc ). Answers `OK` on success, else a plain `404 Not Found`. + * + * @event The request context + * @rc The request collection, `id` is the user identifier from the route + * @prc The private request collection + */ + function login( event, rc, prc ){ + var settings = getModuleSettings( "BrowserTesting" ) + if ( isAllowed( arguments.event, settings, "login" ) ) { + var loginClosure = settings.login + loginClosure( + arguments.rc.id ?: "", + arguments.event, + arguments.rc, + arguments.prc + ) + respond( arguments.event, 200, "OK" ) + } else { + respond( arguments.event, 404, "Not Found" ) + } + } + + /** + * Log out the current user of a browser test by calling the `logout` closure of the module settings + * with ( event, rc, prc ). Answers `OK` on success, else a plain `404 Not Found`. + * + * @event The request context + * @rc The request collection + * @prc The private request collection + */ + function logout( event, rc, prc ){ + var settings = getModuleSettings( "BrowserTesting" ) + if ( isAllowed( arguments.event, settings, "logout" ) ) { + var logoutClosure = settings.logout + logoutClosure( arguments.event, arguments.rc, arguments.prc ) + respond( arguments.event, 200, "OK" ) + } else { + respond( arguments.event, 404, "Not Found" ) + } + } + + /** + * Does the request pass every check of the security model? The environment is `testing`, the module + * is enabled, the token is set and matches the request token, and the closure of the endpoint is set. + * + * @event The request context + * @settings The module settings + * @closureKey The setting that holds the closure of the endpoint: login or logout + * + * @return True when the endpoint may run + */ + private boolean function isAllowed( + required event, + required struct settings, + required string closureKey + ){ + // Testing environment only + if ( getSetting( "environment", "production" ) != "testing" ) { + return false + } + // Explicitly enabled + var enabled = arguments.settings.enabled ?: false + if ( !isBoolean( enabled ) || !enabled ) { + return false + } + // A token must be configured + var token = arguments.settings.token ?: "" + if ( !isSimpleValue( token ) || !len( token ) ) { + return false + } + // The endpoint closure must be set + var target = arguments.settings[ arguments.closureKey ] ?: "" + if ( !isClosure( target ) && !isCustomFunction( target ) ) { + return false + } + return tokensMatch( token, getRequestToken( arguments.event ) ) + } + + /** + * The token the request sent: the `X-Browser-Testing-Token` header, else the `token` request variable. + * + * @event The request context + * + * @return The request token, or an empty string when the request sent none + */ + private string function getRequestToken( required event ){ + var token = arguments.event.getHTTPHeader( "X-Browser-Testing-Token", "" ) + if ( !len( token ) ) { + token = arguments.event.getValue( "token", "" ) + } + return isSimpleValue( token ) ? token : "" + } + + /** + * Compare two tokens in constant time: their SHA-256 hashes are compared with MessageDigest.isEqual(), + * so the comparison time does not depend on the token contents or length. + * + * @expected The configured token + * @actual The request token + * + * @return True when both tokens are equal and the request token is not empty + */ + private boolean function tokensMatch( required string expected, required string actual ){ + if ( !len( arguments.actual ) ) { + return false + } + return createObject( "java", "java.security.MessageDigest" ).isEqual( + charsetDecode( hash( arguments.expected, "SHA-256" ), "utf-8" ), + charsetDecode( hash( arguments.actual, "SHA-256" ), "utf-8" ) + ) + } + + /** + * Render a plain text response. + * + * @event The request context + * @statusCode The HTTP status code + * @text The response text + */ + private void function respond( + required event, + required numeric statusCode, + required string text + ){ + arguments.event.renderData( + type = "plain", + data = arguments.text, + statusCode = arguments.statusCode + ) + } + +} diff --git a/test-harness/config/Coldbox.cfc b/test-harness/config/Coldbox.cfc index 43a939837..c4b6decde 100644 --- a/test-harness/config/Coldbox.cfc +++ b/test-harness/config/Coldbox.cfc @@ -54,7 +54,22 @@ // environment settings, create a detectEnvironment() method to detect it yourself. // create a function with the name of the environment so it can be executed if that environment is detected // the value of the environment is a list of regex patterns to match the CGI.SERVER_NAME. - variables.environments = { development : "^cf.,^localhost,^127" }; + // The browser tests of tests/specs/browser reach the harness on 127.0.0.1, its testing environment + variables.environments = { development : "^cf.,^localhost", testing : "^127\.0\.0\.1" }; + + // Browser testing endpoints: they only answer in the testing environment + variables.moduleSettings = { + browserTesting : { + enabled : true, + token : "coldbox-test-harness-browser-token", + login : function( id, event, rc, prc ){ + session.browserTestingUser = arguments.id + }, + logout : function( event, rc, prc ){ + structDelete( session, "browserTestingUser" ) + } + } + } // Module Directives variables.modules = { diff --git a/tests/specs/integration/BrowserTestingModuleSpec.cfc b/tests/specs/integration/BrowserTestingModuleSpec.cfc new file mode 100644 index 000000000..165e4e812 --- /dev/null +++ b/tests/specs/integration/BrowserTestingModuleSpec.cfc @@ -0,0 +1,163 @@ +/** + * The BrowserTesting core module: test-only login and logout endpoints and their security checks + */ +component extends="tests.resources.BaseIntegrationTest" { + + /*********************************** BDD SUITES ***********************************/ + + function run(){ + describe( "BrowserTesting core module", () => { + beforeEach( ( currentSpec ) => { + setup() + variables.settings = getController().getModuleSettings( "BrowserTesting" ) + variables.originalSettings = structCopy( variables.settings ) + variables.originalEnvironment = getController().getSetting( "environment" ) + // A fully enabled module whose closures record their calls + request.browserTestingCalls = [] + variables.settings.enabled = true + variables.settings.token = "unit-test-token" + variables.settings.login = function( id, event, rc, prc ){ + request.browserTestingCalls.append( { action : "login", id : arguments.id } ) + } + variables.settings.logout = function( event, rc, prc ){ + request.browserTestingCalls.append( { action : "logout" } ) + } + getController().setSetting( "environment", "testing" ) + } ) + + afterEach( ( currentSpec ) => { + structClear( variables.settings ) + structAppend( + variables.settings, + variables.originalSettings, + true + ) + getController().setSetting( "environment", variables.originalEnvironment ) + } ) + + it( "is registered as a core module with the __browser-testing entry point", () => { + var config = getController().getSetting( "modules" ) + expect( config ).toHaveKey( "BrowserTesting" ) + expect( config.BrowserTesting.entryPoint ).toBe( "__browser-testing" ) + expect( config.BrowserTesting.path ).toInclude( "system" ) + } ) + + it( "is disabled by default with an empty token and no closures", () => { + var config = prepareMock( new coldbox.system.modules.BrowserTesting.ModuleConfig() ) + config.configure() + var defaults = config.$getProperty( "settings" ) + expect( defaults.enabled ).toBeFalse() + expect( defaults.token ).toBe( "" ) + expect( defaults.login ).toBe( "" ) + expect( defaults.logout ).toBe( "" ) + } ) + + it( "takes the application overrides from moduleSettings.browserTesting", () => { + expect( variables.originalSettings.enabled ).toBeTrue() + expect( variables.originalSettings.token ).toBe( "coldbox-test-harness-browser-token" ) + var login = variables.originalSettings.login + expect( isClosure( login ) || isCustomFunction( login ) ).toBeTrue() + } ) + + it( "logs in through the login closure with the token in the request", () => { + var event = get( route = "/__browser-testing/login/42", params = { token : "unit-test-token" } ) + expect( event.getStatusCode() ).toBe( 200 ) + expect( event.getRenderedContent() ).toBe( "OK" ) + expect( request.browserTestingCalls ).toHaveLength( 1 ) + expect( request.browserTestingCalls[ 1 ].action ).toBe( "login" ) + expect( request.browserTestingCalls[ 1 ].id ).toBe( 42 ) + } ) + + it( "logs in with the token in the X-Browser-Testing-Token header", () => { + var event = getRequestContext() + prepareMock( event ) + .$( "getHTTPHeader" ) + .$args( "X-Browser-Testing-Token", "" ) + .$results( "unit-test-token" ) + event = get( route = "/__browser-testing/login/7" ) + expect( event.getStatusCode() ).toBe( 200 ) + expect( request.browserTestingCalls ).toHaveLength( 1 ) + expect( request.browserTestingCalls[ 1 ].id ).toBe( 7 ) + } ) + + it( "logs out through the logout closure", () => { + var event = get( route = "/__browser-testing/logout", params = { token : "unit-test-token" } ) + expect( event.getStatusCode() ).toBe( 200 ) + expect( request.browserTestingCalls ).toHaveLength( 1 ) + expect( request.browserTestingCalls[ 1 ].action ).toBe( "logout" ) + } ) + + it( "answers 404 outside the testing environment", () => { + getController().setSetting( "environment", "development" ) + expectNotFound( "/__browser-testing/login/1", { token : "unit-test-token" } ) + getController().setSetting( "environment", "production" ) + expectNotFound( "/__browser-testing/logout", { token : "unit-test-token" } ) + } ) + + it( "answers 404 when disabled", () => { + variables.settings.enabled = false + expectNotFound( "/__browser-testing/login/1", { token : "unit-test-token" } ) + variables.settings.enabled = "yes please" + expectNotFound( "/__browser-testing/login/1", { token : "unit-test-token" } ) + } ) + + it( "answers 404 when no token is configured, even for an empty request token", () => { + variables.settings.token = "" + expectNotFound( "/__browser-testing/login/1", { token : "" } ) + expectNotFound( "/__browser-testing/login/1", {} ) + } ) + + it( "answers 404 for a missing or wrong request token", () => { + expectNotFound( "/__browser-testing/login/1", {} ) + expectNotFound( "/__browser-testing/login/1", { token : "wrong-token" } ) + expectNotFound( "/__browser-testing/login/1", { token : "unit-test-toke" } ) + expectNotFound( "/__browser-testing/logout", { token : "UNIT-TEST-TOKEN" } ) + } ) + + it( "answers 404 when the closure of the endpoint is not set", () => { + variables.settings.login = "" + expectNotFound( "/__browser-testing/login/1", { token : "unit-test-token" } ) + variables.settings.logout = "not a closure" + expectNotFound( "/__browser-testing/logout", { token : "unit-test-token" } ) + } ) + + it( "guards the convention route of the module too", () => { + variables.settings.enabled = false + expectNotFound( "/__browser-testing/auth/login", { token : "unit-test-token", id : 1 } ) + } ) + + it( "only accepts GET requests", () => { + var event = this.post( + route = "/__browser-testing/login/1", + params = { token : "unit-test-token" } + ) + expect( event.getRenderedContent() ).notToBe( "OK" ) + expect( request.browserTestingCalls ).toBeEmpty() + } ) + + it( "builds its named routes under the entry point", () => { + var link = getRequestContext().route( "login@BrowserTesting", { id : 3 } ) + expect( link ).toInclude( "/__browser-testing/login/3" ) + expect( getRequestContext().route( "logout@BrowserTesting" ) ).toInclude( "/__browser-testing/logout" ) + } ) + } ) + } + + /** + * Execute a GET request and expect the plain 404 of the module, without any closure call + * + * @route The route to execute + * @params The request parameters + */ + private function expectNotFound( required string route, struct params = {} ){ + var event = get( route = arguments.route, params = arguments.params ) + expect( event.getStatusCode() ).toBe( + 404, + "Expected a 404 for #arguments.route# #serializeJSON( arguments.params )#" + ) + expect( event.getRenderedContent() ).toBe( "Not Found" ) + expect( request.browserTestingCalls ).toBeEmpty() + setup() + } + +} From 1607f9548ffa6bd91850e2ab2ff64b9200c5f377 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:58:12 +0000 Subject: [PATCH 3/8] Add BrowserTestCase for browser tests of ColdBox applications coldbox.system.testing.BrowserTestCase (BoxLang) extends BaseTestCase and delegates to TestBox's BrowserSupport like BrowserSpec: browse(), this.playwright(), browserAvailable(), the browserProfile and baseURL annotations, the browser matchers and the afterAll closeBrowser() hook. It adds routeURL(), visitRoute() and assertRouteIs() for named routes, and loginAs() and logout() through the BrowserTesting core module. The specs live in tests/specs/browser, which tests/runner.cfm skips on engines other than BoxLang; the browser specs skip without bx-playwright. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi --- system/testing/BrowserTestCase.bx | 402 +++++++++++++++++++++ test-harness/config/Router.cfc | 4 + test-harness/handlers/browserTesting.cfc | 23 ++ tests/resources/browser/FakePage.bx | 103 ++++++ tests/runner.cfm | 5 + tests/specs/browser/BrowserTestCaseSpec.bx | 183 ++++++++++ 6 files changed, 720 insertions(+) create mode 100644 system/testing/BrowserTestCase.bx create mode 100644 test-harness/handlers/browserTesting.cfc create mode 100644 tests/resources/browser/FakePage.bx create mode 100644 tests/specs/browser/BrowserTestCaseSpec.bx diff --git a/system/testing/BrowserTestCase.bx b/system/testing/BrowserTestCase.bx new file mode 100644 index 000000000..b194ca06e --- /dev/null +++ b/system/testing/BrowserTestCase.bx @@ -0,0 +1,402 @@ +/** + * 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" appMapping="/root" baseURL="http://127.0.0.1:8080" {
+ *     function run() {
+ *         describe( "Users", () => {
+ *             it( "shows a user", () => {
+ *                 browse( ( page ) => {
+ *                     loginAs( page, 1 )
+ *                     visitRoute( page, "users.show", { id : 5 } )
+ *                     assertRouteIs( page, "users.show" )
+ *                     expect( page ).toSee( "User 5" )
+ *                 } )
+ *             } )
+ *         } )
+ *     }
+ * }
+ * 
+ * + * Class annotations, besides the BaseTestCase ones (appMapping, webMapping, configMapping, ...): + * - `browserProfile`: bx-playwright profiles for the bundle browser, a list such as `ci,mobile` + * - `baseURL`: 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. + * + * loginAs() and logout() call the test-only endpoints of the BrowserTesting core module, which must be + * enabled in the `testing` environment of the application with a token and login/logout closures: + * see coldbox/system/modules/BrowserTesting/ModuleConfig.cfc. + * + * When bx-playwright is not installed, browse(), visitRoute(), loginAs() and logout() skip the running spec. + * Browser specs are not thread safe: do not use `asyncAll` in suites that browse. + */ +class extends="coldbox.system.testing.BaseTestCase" { + + // Browser matchers for every spec of the bundle: expect( page ).toSee( "Welcome" ) + 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 bx-playwright 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 = {} ) { + 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 bx-playwright 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() { + return getBrowserSupport().getManager() + } + + /** + * Can browser specs run here? True on BoxLang with the bx-playwright module installed. + * Handy for skip constraints: it( title = "...", body = () => {}, skip = !browserAvailable() ) + * + * @return True when browser testing is available + */ + boolean function browserAvailable() { + return getBrowserSupport().isAvailable() + } + + /** + * The browser support of this bundle, which owns the bundle manager. + * + * @return The testbox.system.browser.BrowserSupport of this bundle + */ + function getBrowserSupport() { + 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. Skips the spec when bx-playwright 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 = {} ) { + ensureBrowser() + 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 + } + + /** + * -------------------------------------------------------------------------- + * Authentication + * -------------------------------------------------------------------------- + */ + + /** + * Log a user in for the page: calls the `login` closure of the BrowserTesting core module through its + * test-only endpoint (GET /__browser-testing/login/:id), with the module token of the loaded application. + * The request shares the cookies of the page, so the page is logged in for its next visits. + * Skips the spec when bx-playwright is not available. + * + * @page The bx-playwright page + * @id The user identifier passed to the login closure + * + * @return The page + * + * @throws BrowserTestCase.BrowserTestingUnavailable When the endpoint does not answer with success + */ + function loginAs( required page, required any id ) { + ensureBrowser() + return callBrowserTesting( arguments.page, "login@BrowserTesting", { id : arguments.id }, "loginAs()" ) + } + + /** + * Log the user of the page out: calls the `logout` closure of the BrowserTesting core module through its + * test-only endpoint (GET /__browser-testing/logout), with the module token of the loaded application. + * Skips the spec when bx-playwright is not available. + * + * @page The bx-playwright page + * + * @return The page + * + * @throws BrowserTestCase.BrowserTestingUnavailable When the endpoint does not answer with success + */ + function logout( required page ) { + ensureBrowser() + return callBrowserTesting( arguments.page, "logout@BrowserTesting", {}, "logout()" ) + } + + /** + * -------------------------------------------------------------------------- + * Private helpers + * -------------------------------------------------------------------------- + */ + + /** + * Skip the running spec when browser testing is not available. + */ + private void function ensureBrowser() { + if ( !browserAvailable() ) { + skip( getBrowserSupport().getUnavailableReason() ) + } + } + + /** + * Call an endpoint of the BrowserTesting core module with the request API of the page context, which shares + * the page cookies, sending the module token in the `X-Browser-Testing-Token` header. + * + * @page The bx-playwright page + * @route The module route name + * @params The route placeholder values + * @action The helper name, for error messages + * + * @return The page + * + * @throws BrowserTestCase.BrowserTestingUnavailable When the module is not loaded, has no token, or the endpoint fails + */ + private function callBrowserTesting( + required page, + required string route, + required struct params, + required string action + ) { + var setup = "Enable the BrowserTesting module in the testing environment of your application: " + setup &= "moduleSettings.browserTesting = { enabled : true, token : '', login : ( id, event, rc, prc ) => {}, logout : ( event, rc, prc ) => {} }, " + setup &= "and run the application with the environment setting set to 'testing'." + var token = "" + var path = "" + try { + token = getController().getModuleSettings( "BrowserTesting", "token", "" ) + path = routeURL( arguments.route, arguments.params ) + } catch ( any e ) { + throw( + type = "BrowserTestCase.BrowserTestingUnavailable", + message = "#arguments.action# needs the BrowserTesting core module, which is not loaded in the test application: #e.message#", + detail = setup + ) + } + if ( !isSimpleValue( token ) || !len( token ) ) { + throw( + type = "BrowserTestCase.BrowserTestingUnavailable", + message = "#arguments.action# needs the token of the BrowserTesting module, but moduleSettings.browserTesting.token is empty in the test application.", + detail = setup + ) + } + var response = arguments.page + .context() + .request() + .get( path, { headers : { "X-Browser-Testing-Token" : token } } ) + var status = response.status() + if ( status < 200 || status >= 300 ) { + var reason = "The endpoint answered HTTP #status#: #left( response.text(), 500 )#" + if ( status == 404 ) { + reason = "The endpoint answered 404 Not Found: the BrowserTesting module of the application under test is disabled, its token differs from the token of the test application, the closure is not set, or the environment is not 'testing'." + } + throw( + type = "BrowserTestCase.BrowserTestingUnavailable", + message = "#arguments.action# failed calling [#path#]. #reason#", + detail = setup + ) + } + 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 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. 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 route = getController().getWireBox().getInstance( "router@coldbox" ).findRouteByName( arguments.name ) + if ( route.isEmpty() ) { + 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" ) + var routeRegex = ( route.regexPattern ?: "" ).reReplace( "^/+|/+$", "", "all" ) + if ( len( entryPoint ) ) { + regex &= "/" & quoteRegex( entryPoint ) + } + if ( len( routeRegex ) ) { + regex &= "/" & routeRegex + } + 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/test-harness/config/Router.cfc b/test-harness/config/Router.cfc index 022c22fe0..1735e2515 100644 --- a/test-harness/config/Router.cfc +++ b/test-harness/config/Router.cfc @@ -126,6 +126,10 @@ 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-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..3b1a3d781 --- /dev/null +++ b/test-harness/handlers/browserTesting.cfc @@ -0,0 +1,23 @@ +/** + * 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 )#

" + } + + /** + * Show who is logged in through the BrowserTesting module login closure + */ + function whoami( event, rc, prc ){ + if ( structKeyExists( 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..1263a5430 --- /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, where the harness runs in its testing environment. They are skipped when + * bx-playwright is not installed. BoxLang only: tests/runner.cfm excludes this folder on other engines. + */ +class extends="coldbox.system.testing.BrowserTestCase" appMapping="/cbTestHarness" baseURL="http://127.0.0.1:8599" { + + /*********************************** 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( "login@BrowserTesting", { id : 3 } ) ).toBe( basePath & "/__browser-testing/login/3/" ) + expect( routeURL( "BrowserTesting:logout" ) ).toBe( basePath & "/__browser-testing/logout/" ) + } ) + + 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 & "/__browser-testing/login/9" ) + assertRouteIs( page, "login@BrowserTesting" ) + assertRouteIs( page, "login@BrowserTesting", { id : 9 } ) + } ) + + it( + title = "visits named routes", + body = () => { + var page = new tests.resources.browser.FakePage() + expect( visitRoute( page, "users.show", { id : 7 } ) ).toBe( page ) + expect( page.getVisits() ).toBe( [ basePath & "/users/7/" ] ) + }, + skip = !browserAvailable() + ) + } ) + + 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( "logs in and out through the BrowserTesting module", () => { + browse( ( page ) => { + reinitHarness( page ) + visitRoute( page, "browserTesting.whoami" ) + expect( page ).toSee( "Guest" ) + + expect( loginAs( page, 42 ) ).toBe( page ) + visitRoute( page, "browserTesting.whoami" ) + expect( page ).toSee( "Logged in as 42" ) + + expect( logout( page ) ).toBe( page ) + visitRoute( page, "browserTesting.whoami" ) + expect( page ).toSee( "Guest" ) + } ) + } ) + + it( "keeps every page of a browse() call in its own session", () => { + browse( ( admin, guest ) => { + reinitHarness( admin ) + loginAs( admin, 1 ) + visitRoute( admin, "browserTesting.whoami" ) + visitRoute( guest, "browserTesting.whoami" ) + expect( admin ).toSee( "Logged in as 1" ) + expect( guest ).toSee( "Guest" ) + } ) + } ) + + it( "explains a login the application refuses", () => { + var settings = getController().getModuleSettings( "BrowserTesting" ) + var token = settings.token + settings.token = "not-the-application-token" + try { + browse( ( page ) => { + reinitHarness( page ) + expect( () => loginAs( page, 1 ) ).toThrow( "BrowserTestCase.BrowserTestingUnavailable", "404 Not Found" ) + } ) + } finally { + settings.token = token + } + } ) + }, + 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 + } + } + +} From fd039a3543bc1fdf1e99bd0e881c40abbda47f94 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:58:19 +0000 Subject: [PATCH 4/8] Document browser testing support in the changelog Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi --- changelog.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/changelog.md b/changelog.md index dc17dffb7..e6c741c68 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()`, the `browserProfile` and `baseURL` annotations, the TestBox browser matchers, and the ColdBox helpers `routeURL()`, `visitRoute()`, `assertRouteIs()`, `loginAs()` and `logout()` +- `BrowserTesting` core module: test-only `GET /__browser-testing/login/:id` and `GET /__browser-testing/logout` endpoints that call the `login` and `logout` closures of `moduleSettings.browserTesting`. They answer `404 Not Found` unless the environment is `testing`, the module is `enabled`, a `token` is configured and sent with the request, and the closure is set + +### Fixed + +- `event.route( "name@module" )` built module route links without a slash between the module entry point and the route pattern + ## [8.2.0] - 2026-09-23 - From 32e827bbc07b0381f59834cacf691db88da4f100 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 12:41:02 +0000 Subject: [PATCH 5/8] Fix CI: skip browser helpers without TestBox browser support, keep decorator mocks on Adobe BrowserTestCase registered testbox.system.browser.BrowserMatchers in its pseudo constructor, so every runner.cfm request on BoxLang failed with a 500 on released TestBox builds that do not ship testbox.system.browser yet. It now detects TestBox browser support, registers the matchers only when present, and skips browser specs with a clear reason otherwise. The route helpers still run. RequestContext.getMemento() returned the `this` reference that Adobe keeps in the variables scope. RequestContextDecorator restored it, so on Adobe the decorator's inherited methods ran with `this` pointing to the original context and mocked methods failed with "Element _MOCKRESULTS is undefined in THIS" (BrowserTestingModuleSpec header token spec). The memento now leaves it out, with regression specs in RequestContextDecoratorTest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi --- changelog.md | 3 +- system/testing/BrowserTestCase.bx | 57 +++++++++++++++---- system/web/context/RequestContext.cfc | 5 +- tests/specs/browser/BrowserTestCaseSpec.bx | 10 +++- .../context/RequestContextDecoratorTest.cfc | 29 ++++++++++ 5 files changed, 90 insertions(+), 14 deletions(-) diff --git a/changelog.md b/changelog.md index e6c741c68..ac867c451 100644 --- a/changelog.md +++ b/changelog.md @@ -11,12 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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()`, the `browserProfile` and `baseURL` annotations, the TestBox browser matchers, and the ColdBox helpers `routeURL()`, `visitRoute()`, `assertRouteIs()`, `loginAs()` and `logout()` +- `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()`, `assertRouteIs()`, `loginAs()` and `logout()` - `BrowserTesting` core module: test-only `GET /__browser-testing/login/:id` and `GET /__browser-testing/logout` endpoints that call the `login` and `logout` closures of `moduleSettings.browserTesting`. They answer `404 Not Found` unless the environment is `testing`, the module is `enabled`, a `token` is configured and sent with the request, and the closure is set ### 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 index b194ca06e..807653196 100644 --- a/system/testing/BrowserTestCase.bx +++ b/system/testing/BrowserTestCase.bx @@ -40,13 +40,20 @@ * enabled in the `testing` environment of the application with a token and login/logout closures: * see coldbox/system/modules/BrowserTesting/ModuleConfig.cfc. * - * When bx-playwright is not installed, browse(), visitRoute(), loginAs() and logout() skip the running spec. + * When bx-playwright is not installed, or the TestBox install has no browser support (testbox.system.browser), + * browse(), visitRoute(), loginAs() and logout() 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" ) - addMatchers( new testbox.system.browser.BrowserMatchers() ) + if ( variables.$testBoxBrowserSupport ) { + addMatchers( new testbox.system.browser.BrowserMatchers() ) + } /** * -------------------------------------------------------------------------- @@ -57,7 +64,7 @@ class extends="coldbox.system.testing.BaseTestCase" { /** * 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 bx-playwright is not available. + * 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 } } @@ -65,12 +72,13 @@ class extends="coldbox.system.testing.BaseTestCase" { * @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 bx-playwright is not available. + * 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. @@ -78,17 +86,31 @@ class extends="coldbox.system.testing.BaseTestCase" { * @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. + * 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 getBrowserSupport().isAvailable() + 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() } /** @@ -97,6 +119,12 @@ class extends="coldbox.system.testing.BaseTestCase" { * @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 ) } @@ -145,7 +173,7 @@ class extends="coldbox.system.testing.BaseTestCase" { /** * Visit a named route: page.visit( routeURL( name, params ) ). Relative routes resolve against the - * `baseURL` annotation. Skips the spec when bx-playwright is not available. + * `baseURL` annotation. 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 @@ -215,7 +243,7 @@ class extends="coldbox.system.testing.BaseTestCase" { * Log a user in for the page: calls the `login` closure of the BrowserTesting core module through its * test-only endpoint (GET /__browser-testing/login/:id), with the module token of the loaded application. * The request shares the cookies of the page, so the page is logged in for its next visits. - * Skips the spec when bx-playwright is not available. + * Skips the spec when browser testing is not available. * * @page The bx-playwright page * @id The user identifier passed to the login closure @@ -232,7 +260,7 @@ class extends="coldbox.system.testing.BaseTestCase" { /** * Log the user of the page out: calls the `logout` closure of the BrowserTesting core module through its * test-only endpoint (GET /__browser-testing/logout), with the module token of the loaded application. - * Skips the spec when bx-playwright is not available. + * Skips the spec when browser testing is not available. * * @page The bx-playwright page * @@ -256,7 +284,16 @@ class extends="coldbox.system.testing.BaseTestCase" { */ private void function ensureBrowser() { if ( !browserAvailable() ) { - skip( getBrowserSupport().getUnavailableReason() ) + skip( browserUnavailableReason() ) + } + } + + /** + * Skip the running spec when the TestBox install has no browser support. + */ + private void function ensureTestBoxBrowserSupport() { + if ( !variables.$testBoxBrowserSupport ) { + skip( browserUnavailableReason() ) } } diff --git a/system/web/context/RequestContext.cfc b/system/web/context/RequestContext.cfc index 2477e7c0b..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" ); } ); } diff --git a/tests/specs/browser/BrowserTestCaseSpec.bx b/tests/specs/browser/BrowserTestCaseSpec.bx index 1263a5430..fa7228163 100644 --- a/tests/specs/browser/BrowserTestCaseSpec.bx +++ b/tests/specs/browser/BrowserTestCaseSpec.bx @@ -3,7 +3,7 @@ * * The browser specs need bx-playwright and run against the test harness served by the test server on * http://127.0.0.1:8599, where the harness runs in its testing environment. They are skipped when - * bx-playwright is not installed. BoxLang only: tests/runner.cfm excludes this folder on other engines. + * bx-playwright is not installed or the TestBox install has no browser support. BoxLang only: tests/runner.cfm excludes this folder on other engines. */ class extends="coldbox.system.testing.BrowserTestCase" appMapping="/cbTestHarness" baseURL="http://127.0.0.1:8599" { @@ -88,6 +88,14 @@ class extends="coldbox.system.testing.BrowserTestCase" appMapping="/cbTestHarnes assertRouteIs( page, "login@BrowserTesting", { id : 9 } ) } ) + it( "explains why browser testing is not available", () => { + if ( browserAvailable() ) { + expect( browserUnavailableReason() ).toBe( "" ) + } else { + expect( browserUnavailableReason() ).notToBeEmpty() + } + } ) + it( title = "visits named routes", body = () => { 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(); + } ); } ); } From f28b073b5fa16524d796920bc3141d65a6617d77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:08:10 +0000 Subject: [PATCH 6/8] Use BoxLang annotations in BoxLang examples Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi --- AGENTS.md | 5 +++++ system/testing/BrowserTestCase.bx | 9 +++++---- tests/specs/browser/BrowserTestCaseSpec.bx | 4 +++- 3 files changed, 13 insertions(+), 5 deletions(-) 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/system/testing/BrowserTestCase.bx b/system/testing/BrowserTestCase.bx index 807653196..99f65c78c 100644 --- a/system/testing/BrowserTestCase.bx +++ b/system/testing/BrowserTestCase.bx @@ -9,7 +9,7 @@ * routes, and drives a real browser against your running application: * *
- * class extends="coldbox.system.testing.BrowserTestCase" appMapping="/root" baseURL="http://127.0.0.1:8080" {
+ * class extends="coldbox.system.testing.BrowserTestCase" {
  *     function run() {
  *         describe( "Users", () => {
  *             it( "shows a user", () => {
@@ -25,9 +25,10 @@
  * }
  * 
* - * Class annotations, besides the BaseTestCase ones (appMapping, webMapping, configMapping, ...): - * - `browserProfile`: bx-playwright profiles for the bundle browser, a list such as `ci,mobile` - * - `baseURL`: the URL of your running application, relative visits resolve against it + * 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 diff --git a/tests/specs/browser/BrowserTestCaseSpec.bx b/tests/specs/browser/BrowserTestCaseSpec.bx index fa7228163..5ab0fcbd1 100644 --- a/tests/specs/browser/BrowserTestCaseSpec.bx +++ b/tests/specs/browser/BrowserTestCaseSpec.bx @@ -5,7 +5,9 @@ * http://127.0.0.1:8599, where the harness runs in its testing environment. 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. */ -class extends="coldbox.system.testing.BrowserTestCase" appMapping="/cbTestHarness" baseURL="http://127.0.0.1:8599" { +@appMapping( "/cbTestHarness" ) +@baseURL( "http://127.0.0.1:8599" ) +class extends="coldbox.system.testing.BrowserTestCase" { /*********************************** BDD SUITES ***********************************/ From eed8785849fde1321d78ccc6bc038ec524d02b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:29:28 +0000 Subject: [PATCH 7/8] Remove the BrowserTesting module and loginAs()/logout() The test-only login endpoints were a backdoor. Logged-in browser tests now use bx-playwright saved sessions: log in through the login page once with this.playwright().session(), then browse( ..., { session : "name" } ). The test harness gets a plain login form for the saved session specs, and goes back to its development environment on 127.0.0.1. Module route specs use the resourcesTest module. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi --- changelog.md | 3 +- .../modules/BrowserTesting/ModuleConfig.cfc | 85 --------- .../modules/BrowserTesting/handlers/Auth.cfc | 147 ---------------- system/testing/BrowserTestCase.bx | 108 +----------- test-harness/config/Coldbox.cfc | 17 +- test-harness/config/Router.cfc | 1 + test-harness/handlers/browserTesting.cfc | 15 +- tests/specs/browser/BrowserTestCaseSpec.bx | 61 +++---- .../integration/BrowserTestingModuleSpec.cfc | 163 ------------------ 9 files changed, 43 insertions(+), 557 deletions(-) delete mode 100644 system/modules/BrowserTesting/ModuleConfig.cfc delete mode 100644 system/modules/BrowserTesting/handlers/Auth.cfc delete mode 100644 tests/specs/integration/BrowserTestingModuleSpec.cfc diff --git a/changelog.md b/changelog.md index ac867c451..0b6dd5ed8 100644 --- a/changelog.md +++ b/changelog.md @@ -11,8 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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()`, `assertRouteIs()`, `loginAs()` and `logout()` -- `BrowserTesting` core module: test-only `GET /__browser-testing/login/:id` and `GET /__browser-testing/logout` endpoints that call the `login` and `logout` closures of `moduleSettings.browserTesting`. They answer `404 Not Found` unless the environment is `testing`, the module is `enabled`, a `token` is configured and sent with the request, and the closure is set +- `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 diff --git a/system/modules/BrowserTesting/ModuleConfig.cfc b/system/modules/BrowserTesting/ModuleConfig.cfc deleted file mode 100644 index 54c90e8da..000000000 --- a/system/modules/BrowserTesting/ModuleConfig.cfc +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp - * www.ortussolutions.com - * --- - * Browser Testing core module: test-only login and logout endpoints that browser tests - * (coldbox.system.testing.BrowserTestCase) call through loginAs() and logout(). - * - * Routes, under the `__browser-testing` entry point: - * - GET /__browser-testing/login/:id : calls the `login` closure with ( id, event, rc, prc ) - * - GET /__browser-testing/logout : calls the `logout` closure with ( event, rc, prc ) - * - * Settings, overridden by the application in config/ColdBox.cfc: - * - *
- * moduleSettings = {
- *     browserTesting : {
- *         enabled : true,
- *         token   : getSystemSetting( "BROWSER_TESTING_TOKEN", "" ),
- *         login   : ( id, event, rc, prc ) => auth().login( userService.get( id ) ),
- *         logout  : ( event, rc, prc ) => auth().logout()
- *     }
- * }
- * 
- * - * Security model: the endpoints log anyone in as any user, so they are locked down by default and - * every request must pass ALL of these checks, else it gets a plain `404 Not Found`, exactly like a - * missing page, and no closure runs: - * - The application environment (the `environment` setting) is `testing` - * - The `enabled` setting is `true` (it defaults to `false`) - * - The `token` setting is not empty, and the request sends the same token in the - * `X-Browser-Testing-Token` header or the `token` URL/FORM variable. Tokens are compared in constant time - * - The closure of the endpoint (`login` or `logout`) is set - * - The request uses GET - * - * The checks run in the handler actions on every request, so they also cover the module convention - * route and `event=` executions. Use a random token per environment, kept out of source control - * (an environment variable), and never enable the module in an environment that real users can reach. - * The entry point starts with two underscores so it does not clash with application routes. - */ -component { - - // Module Properties - this.title = "Browser Testing" - this.description = "Test-only login and logout endpoints for ColdBox browser tests" - // Model Namespace and module settings key: moduleSettings.browserTesting - this.modelNamespace = "browserTesting" - // Route entry point - this.entryPoint = "__browser-testing" - // No models to map - this.autoMapModels = false - - /** - * Configure the module settings and routes - */ - function configure(){ - // module settings - stored in modules.name.settings - variables.settings = { - // Browser testing endpoints are off unless the application turns them on - enabled : false, - // The shared secret every request must send, an empty token disables the endpoints - token : "", - // The login closure: ( id, event, rc, prc ) => {} - login : "", - // The logout closure: ( event, rc, prc ) => {} - logout : "" - } - - // Module routes - variables.routes = [ - { - pattern : "/login/:id", - handler : "Auth", - action : { GET : "login" }, - name : "login" - }, - { - pattern : "/logout", - handler : "Auth", - action : { GET : "logout" }, - name : "logout" - } - ] - } - -} diff --git a/system/modules/BrowserTesting/handlers/Auth.cfc b/system/modules/BrowserTesting/handlers/Auth.cfc deleted file mode 100644 index 1745a37c9..000000000 --- a/system/modules/BrowserTesting/handlers/Auth.cfc +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp - * www.ortussolutions.com - * --- - * Test-only login and logout endpoints for browser tests. - * Every action answers a plain `404 Not Found` unless the request passes all the checks of the - * security model documented in the module's ModuleConfig.cfc. - */ -component extends="coldbox.system.EventHandler" { - - // Only GET requests reach the actions - this.allowedMethods = { login : "GET", logout : "GET" } - - /** - * Log in a user for a browser test by calling the `login` closure of the module settings - * with ( id, event, rc, prc ). Answers `OK` on success, else a plain `404 Not Found`. - * - * @event The request context - * @rc The request collection, `id` is the user identifier from the route - * @prc The private request collection - */ - function login( event, rc, prc ){ - var settings = getModuleSettings( "BrowserTesting" ) - if ( isAllowed( arguments.event, settings, "login" ) ) { - var loginClosure = settings.login - loginClosure( - arguments.rc.id ?: "", - arguments.event, - arguments.rc, - arguments.prc - ) - respond( arguments.event, 200, "OK" ) - } else { - respond( arguments.event, 404, "Not Found" ) - } - } - - /** - * Log out the current user of a browser test by calling the `logout` closure of the module settings - * with ( event, rc, prc ). Answers `OK` on success, else a plain `404 Not Found`. - * - * @event The request context - * @rc The request collection - * @prc The private request collection - */ - function logout( event, rc, prc ){ - var settings = getModuleSettings( "BrowserTesting" ) - if ( isAllowed( arguments.event, settings, "logout" ) ) { - var logoutClosure = settings.logout - logoutClosure( arguments.event, arguments.rc, arguments.prc ) - respond( arguments.event, 200, "OK" ) - } else { - respond( arguments.event, 404, "Not Found" ) - } - } - - /** - * Does the request pass every check of the security model? The environment is `testing`, the module - * is enabled, the token is set and matches the request token, and the closure of the endpoint is set. - * - * @event The request context - * @settings The module settings - * @closureKey The setting that holds the closure of the endpoint: login or logout - * - * @return True when the endpoint may run - */ - private boolean function isAllowed( - required event, - required struct settings, - required string closureKey - ){ - // Testing environment only - if ( getSetting( "environment", "production" ) != "testing" ) { - return false - } - // Explicitly enabled - var enabled = arguments.settings.enabled ?: false - if ( !isBoolean( enabled ) || !enabled ) { - return false - } - // A token must be configured - var token = arguments.settings.token ?: "" - if ( !isSimpleValue( token ) || !len( token ) ) { - return false - } - // The endpoint closure must be set - var target = arguments.settings[ arguments.closureKey ] ?: "" - if ( !isClosure( target ) && !isCustomFunction( target ) ) { - return false - } - return tokensMatch( token, getRequestToken( arguments.event ) ) - } - - /** - * The token the request sent: the `X-Browser-Testing-Token` header, else the `token` request variable. - * - * @event The request context - * - * @return The request token, or an empty string when the request sent none - */ - private string function getRequestToken( required event ){ - var token = arguments.event.getHTTPHeader( "X-Browser-Testing-Token", "" ) - if ( !len( token ) ) { - token = arguments.event.getValue( "token", "" ) - } - return isSimpleValue( token ) ? token : "" - } - - /** - * Compare two tokens in constant time: their SHA-256 hashes are compared with MessageDigest.isEqual(), - * so the comparison time does not depend on the token contents or length. - * - * @expected The configured token - * @actual The request token - * - * @return True when both tokens are equal and the request token is not empty - */ - private boolean function tokensMatch( required string expected, required string actual ){ - if ( !len( arguments.actual ) ) { - return false - } - return createObject( "java", "java.security.MessageDigest" ).isEqual( - charsetDecode( hash( arguments.expected, "SHA-256" ), "utf-8" ), - charsetDecode( hash( arguments.actual, "SHA-256" ), "utf-8" ) - ) - } - - /** - * Render a plain text response. - * - * @event The request context - * @statusCode The HTTP status code - * @text The response text - */ - private void function respond( - required event, - required numeric statusCode, - required string text - ){ - arguments.event.renderData( - type = "plain", - data = arguments.text, - statusCode = arguments.statusCode - ) - } - -} diff --git a/system/testing/BrowserTestCase.bx b/system/testing/BrowserTestCase.bx index 99f65c78c..d0e82fab8 100644 --- a/system/testing/BrowserTestCase.bx +++ b/system/testing/BrowserTestCase.bx @@ -14,7 +14,6 @@ * describe( "Users", () => { * it( "shows a user", () => { * browse( ( page ) => { - * loginAs( page, 1 ) * visitRoute( page, "users.show", { id : 5 } ) * assertRouteIs( page, "users.show" ) * expect( page ).toSee( "User 5" ) @@ -37,12 +36,11 @@ * every spec of the bundle. `this.playwright()` returns the bundle manager; an unqualified `playwright()` * still calls the bx-playwright BIF. * - * loginAs() and logout() call the test-only endpoints of the BrowserTesting core module, which must be - * enabled in the `testing` environment of the application with a token and login/logout closures: - * see coldbox/system/modules/BrowserTesting/ModuleConfig.cfc. + * 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(), visitRoute(), loginAs() and logout() skip the running spec. + * 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" { @@ -234,46 +232,6 @@ class extends="coldbox.system.testing.BaseTestCase" { return arguments.page } - /** - * -------------------------------------------------------------------------- - * Authentication - * -------------------------------------------------------------------------- - */ - - /** - * Log a user in for the page: calls the `login` closure of the BrowserTesting core module through its - * test-only endpoint (GET /__browser-testing/login/:id), with the module token of the loaded application. - * The request shares the cookies of the page, so the page is logged in for its next visits. - * Skips the spec when browser testing is not available. - * - * @page The bx-playwright page - * @id The user identifier passed to the login closure - * - * @return The page - * - * @throws BrowserTestCase.BrowserTestingUnavailable When the endpoint does not answer with success - */ - function loginAs( required page, required any id ) { - ensureBrowser() - return callBrowserTesting( arguments.page, "login@BrowserTesting", { id : arguments.id }, "loginAs()" ) - } - - /** - * Log the user of the page out: calls the `logout` closure of the BrowserTesting core module through its - * test-only endpoint (GET /__browser-testing/logout), with the module token of the loaded application. - * Skips the spec when browser testing is not available. - * - * @page The bx-playwright page - * - * @return The page - * - * @throws BrowserTestCase.BrowserTestingUnavailable When the endpoint does not answer with success - */ - function logout( required page ) { - ensureBrowser() - return callBrowserTesting( arguments.page, "logout@BrowserTesting", {}, "logout()" ) - } - /** * -------------------------------------------------------------------------- * Private helpers @@ -298,66 +256,6 @@ class extends="coldbox.system.testing.BaseTestCase" { } } - /** - * Call an endpoint of the BrowserTesting core module with the request API of the page context, which shares - * the page cookies, sending the module token in the `X-Browser-Testing-Token` header. - * - * @page The bx-playwright page - * @route The module route name - * @params The route placeholder values - * @action The helper name, for error messages - * - * @return The page - * - * @throws BrowserTestCase.BrowserTestingUnavailable When the module is not loaded, has no token, or the endpoint fails - */ - private function callBrowserTesting( - required page, - required string route, - required struct params, - required string action - ) { - var setup = "Enable the BrowserTesting module in the testing environment of your application: " - setup &= "moduleSettings.browserTesting = { enabled : true, token : '', login : ( id, event, rc, prc ) => {}, logout : ( event, rc, prc ) => {} }, " - setup &= "and run the application with the environment setting set to 'testing'." - var token = "" - var path = "" - try { - token = getController().getModuleSettings( "BrowserTesting", "token", "" ) - path = routeURL( arguments.route, arguments.params ) - } catch ( any e ) { - throw( - type = "BrowserTestCase.BrowserTestingUnavailable", - message = "#arguments.action# needs the BrowserTesting core module, which is not loaded in the test application: #e.message#", - detail = setup - ) - } - if ( !isSimpleValue( token ) || !len( token ) ) { - throw( - type = "BrowserTestCase.BrowserTestingUnavailable", - message = "#arguments.action# needs the token of the BrowserTesting module, but moduleSettings.browserTesting.token is empty in the test application.", - detail = setup - ) - } - var response = arguments.page - .context() - .request() - .get( path, { headers : { "X-Browser-Testing-Token" : token } } ) - var status = response.status() - if ( status < 200 || status >= 300 ) { - var reason = "The endpoint answered HTTP #status#: #left( response.text(), 500 )#" - if ( status == 404 ) { - reason = "The endpoint answered 404 Not Found: the BrowserTesting module of the application under test is disabled, its token differs from the token of the test application, the closure is not set, or the environment is not 'testing'." - } - throw( - type = "BrowserTestCase.BrowserTestingUnavailable", - message = "#arguments.action# failed calling [#path#]. #reason#", - detail = setup - ) - } - return arguments.page - } - /** * The path of a URL, without scheme and host. * diff --git a/test-harness/config/Coldbox.cfc b/test-harness/config/Coldbox.cfc index c4b6decde..43a939837 100644 --- a/test-harness/config/Coldbox.cfc +++ b/test-harness/config/Coldbox.cfc @@ -54,22 +54,7 @@ // environment settings, create a detectEnvironment() method to detect it yourself. // create a function with the name of the environment so it can be executed if that environment is detected // the value of the environment is a list of regex patterns to match the CGI.SERVER_NAME. - // The browser tests of tests/specs/browser reach the harness on 127.0.0.1, its testing environment - variables.environments = { development : "^cf.,^localhost", testing : "^127\.0\.0\.1" }; - - // Browser testing endpoints: they only answer in the testing environment - variables.moduleSettings = { - browserTesting : { - enabled : true, - token : "coldbox-test-harness-browser-token", - login : function( id, event, rc, prc ){ - session.browserTestingUser = arguments.id - }, - logout : function( event, rc, prc ){ - structDelete( session, "browserTestingUser" ) - } - } - } + variables.environments = { development : "^cf.,^localhost,^127" }; // Module Directives variables.modules = { diff --git a/test-harness/config/Router.cfc b/test-harness/config/Router.cfc index 1735e2515..e45b5f098 100644 --- a/test-harness/config/Router.cfc +++ b/test-harness/config/Router.cfc @@ -128,6 +128,7 @@ component { // Browser testing routes: tests/specs/browser route( "/users/:id" ).as( "users.show" ).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 diff --git a/test-harness/handlers/browserTesting.cfc b/test-harness/handlers/browserTesting.cfc index 3b1a3d781..a0a0b3b05 100644 --- a/test-harness/handlers/browserTesting.cfc +++ b/test-harness/handlers/browserTesting.cfc @@ -11,10 +11,21 @@ component { } /** - * Show who is logged in through the BrowserTesting module login closure + * A login form, like an application login page: GET shows the form, POST logs the user in + */ + function login( event, rc, prc ){ + if ( event.getHTTPMethod() == "POST" ) { + session.browserTestingUser = rc.user ?: "" + relocate( "browserTesting.whoami" ) + } + return "
" + } + + /** + * Show who is logged in */ function whoami( event, rc, prc ){ - if ( structKeyExists( session, "browserTestingUser" ) ) { + if ( len( session.browserTestingUser ?: "" ) ) { return "

Logged in as #encodeForHTML( session.browserTestingUser )#

" } return "

Guest

" diff --git a/tests/specs/browser/BrowserTestCaseSpec.bx b/tests/specs/browser/BrowserTestCaseSpec.bx index 5ab0fcbd1..67a86d75d 100644 --- a/tests/specs/browser/BrowserTestCaseSpec.bx +++ b/tests/specs/browser/BrowserTestCaseSpec.bx @@ -2,8 +2,8 @@ * 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, where the harness runs in its testing environment. 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. + * 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" ) @@ -28,8 +28,8 @@ class extends="coldbox.system.testing.BrowserTestCase" { } ) it( "builds module route paths with the module entry point", () => { - expect( routeURL( "login@BrowserTesting", { id : 3 } ) ).toBe( basePath & "/__browser-testing/login/3/" ) - expect( routeURL( "BrowserTesting:logout" ) ).toBe( basePath & "/__browser-testing/logout/" ) + 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", () => { @@ -85,9 +85,9 @@ class extends="coldbox.system.testing.BrowserTestCase" { } ) it( "asserts module routes", () => { - var page = new tests.resources.browser.FakePage( host & basePath & "/__browser-testing/login/9" ) - assertRouteIs( page, "login@BrowserTesting" ) - assertRouteIs( page, "login@BrowserTesting", { id : 9 } ) + 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", () => { @@ -133,46 +133,33 @@ class extends="coldbox.system.testing.BrowserTestCase" { } ) } ) - it( "logs in and out through the BrowserTesting module", () => { + 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 ) => { - reinitHarness( page ) - visitRoute( page, "browserTesting.whoami" ) - expect( page ).toSee( "Guest" ) - - expect( loginAs( page, 42 ) ).toBe( page ) visitRoute( page, "browserTesting.whoami" ) expect( page ).toSee( "Logged in as 42" ) - - expect( logout( page ) ).toBe( page ) - visitRoute( page, "browserTesting.whoami" ) - expect( page ).toSee( "Guest" ) - } ) + }, { session : "coldbox-harness-user" } ) } ) it( "keeps every page of a browse() call in its own session", () => { - browse( ( admin, guest ) => { - reinitHarness( admin ) - loginAs( admin, 1 ) - visitRoute( admin, "browserTesting.whoami" ) + browse( ( user, guest ) => { + reinitHarness( user ) + visitRoute( user, "browserTesting.login" ) + user.fill( "User", "1" ).click( "Sign in" ) visitRoute( guest, "browserTesting.whoami" ) - expect( admin ).toSee( "Logged in as 1" ) + expect( user ).toSee( "Logged in as 1" ) expect( guest ).toSee( "Guest" ) } ) } ) - - it( "explains a login the application refuses", () => { - var settings = getController().getModuleSettings( "BrowserTesting" ) - var token = settings.token - settings.token = "not-the-application-token" - try { - browse( ( page ) => { - reinitHarness( page ) - expect( () => loginAs( page, 1 ) ).toThrow( "BrowserTestCase.BrowserTestingUnavailable", "404 Not Found" ) - } ) - } finally { - settings.token = token - } - } ) }, skip = !browserAvailable() ) diff --git a/tests/specs/integration/BrowserTestingModuleSpec.cfc b/tests/specs/integration/BrowserTestingModuleSpec.cfc deleted file mode 100644 index 165e4e812..000000000 --- a/tests/specs/integration/BrowserTestingModuleSpec.cfc +++ /dev/null @@ -1,163 +0,0 @@ -/** - * The BrowserTesting core module: test-only login and logout endpoints and their security checks - */ -component extends="tests.resources.BaseIntegrationTest" { - - /*********************************** BDD SUITES ***********************************/ - - function run(){ - describe( "BrowserTesting core module", () => { - beforeEach( ( currentSpec ) => { - setup() - variables.settings = getController().getModuleSettings( "BrowserTesting" ) - variables.originalSettings = structCopy( variables.settings ) - variables.originalEnvironment = getController().getSetting( "environment" ) - // A fully enabled module whose closures record their calls - request.browserTestingCalls = [] - variables.settings.enabled = true - variables.settings.token = "unit-test-token" - variables.settings.login = function( id, event, rc, prc ){ - request.browserTestingCalls.append( { action : "login", id : arguments.id } ) - } - variables.settings.logout = function( event, rc, prc ){ - request.browserTestingCalls.append( { action : "logout" } ) - } - getController().setSetting( "environment", "testing" ) - } ) - - afterEach( ( currentSpec ) => { - structClear( variables.settings ) - structAppend( - variables.settings, - variables.originalSettings, - true - ) - getController().setSetting( "environment", variables.originalEnvironment ) - } ) - - it( "is registered as a core module with the __browser-testing entry point", () => { - var config = getController().getSetting( "modules" ) - expect( config ).toHaveKey( "BrowserTesting" ) - expect( config.BrowserTesting.entryPoint ).toBe( "__browser-testing" ) - expect( config.BrowserTesting.path ).toInclude( "system" ) - } ) - - it( "is disabled by default with an empty token and no closures", () => { - var config = prepareMock( new coldbox.system.modules.BrowserTesting.ModuleConfig() ) - config.configure() - var defaults = config.$getProperty( "settings" ) - expect( defaults.enabled ).toBeFalse() - expect( defaults.token ).toBe( "" ) - expect( defaults.login ).toBe( "" ) - expect( defaults.logout ).toBe( "" ) - } ) - - it( "takes the application overrides from moduleSettings.browserTesting", () => { - expect( variables.originalSettings.enabled ).toBeTrue() - expect( variables.originalSettings.token ).toBe( "coldbox-test-harness-browser-token" ) - var login = variables.originalSettings.login - expect( isClosure( login ) || isCustomFunction( login ) ).toBeTrue() - } ) - - it( "logs in through the login closure with the token in the request", () => { - var event = get( route = "/__browser-testing/login/42", params = { token : "unit-test-token" } ) - expect( event.getStatusCode() ).toBe( 200 ) - expect( event.getRenderedContent() ).toBe( "OK" ) - expect( request.browserTestingCalls ).toHaveLength( 1 ) - expect( request.browserTestingCalls[ 1 ].action ).toBe( "login" ) - expect( request.browserTestingCalls[ 1 ].id ).toBe( 42 ) - } ) - - it( "logs in with the token in the X-Browser-Testing-Token header", () => { - var event = getRequestContext() - prepareMock( event ) - .$( "getHTTPHeader" ) - .$args( "X-Browser-Testing-Token", "" ) - .$results( "unit-test-token" ) - event = get( route = "/__browser-testing/login/7" ) - expect( event.getStatusCode() ).toBe( 200 ) - expect( request.browserTestingCalls ).toHaveLength( 1 ) - expect( request.browserTestingCalls[ 1 ].id ).toBe( 7 ) - } ) - - it( "logs out through the logout closure", () => { - var event = get( route = "/__browser-testing/logout", params = { token : "unit-test-token" } ) - expect( event.getStatusCode() ).toBe( 200 ) - expect( request.browserTestingCalls ).toHaveLength( 1 ) - expect( request.browserTestingCalls[ 1 ].action ).toBe( "logout" ) - } ) - - it( "answers 404 outside the testing environment", () => { - getController().setSetting( "environment", "development" ) - expectNotFound( "/__browser-testing/login/1", { token : "unit-test-token" } ) - getController().setSetting( "environment", "production" ) - expectNotFound( "/__browser-testing/logout", { token : "unit-test-token" } ) - } ) - - it( "answers 404 when disabled", () => { - variables.settings.enabled = false - expectNotFound( "/__browser-testing/login/1", { token : "unit-test-token" } ) - variables.settings.enabled = "yes please" - expectNotFound( "/__browser-testing/login/1", { token : "unit-test-token" } ) - } ) - - it( "answers 404 when no token is configured, even for an empty request token", () => { - variables.settings.token = "" - expectNotFound( "/__browser-testing/login/1", { token : "" } ) - expectNotFound( "/__browser-testing/login/1", {} ) - } ) - - it( "answers 404 for a missing or wrong request token", () => { - expectNotFound( "/__browser-testing/login/1", {} ) - expectNotFound( "/__browser-testing/login/1", { token : "wrong-token" } ) - expectNotFound( "/__browser-testing/login/1", { token : "unit-test-toke" } ) - expectNotFound( "/__browser-testing/logout", { token : "UNIT-TEST-TOKEN" } ) - } ) - - it( "answers 404 when the closure of the endpoint is not set", () => { - variables.settings.login = "" - expectNotFound( "/__browser-testing/login/1", { token : "unit-test-token" } ) - variables.settings.logout = "not a closure" - expectNotFound( "/__browser-testing/logout", { token : "unit-test-token" } ) - } ) - - it( "guards the convention route of the module too", () => { - variables.settings.enabled = false - expectNotFound( "/__browser-testing/auth/login", { token : "unit-test-token", id : 1 } ) - } ) - - it( "only accepts GET requests", () => { - var event = this.post( - route = "/__browser-testing/login/1", - params = { token : "unit-test-token" } - ) - expect( event.getRenderedContent() ).notToBe( "OK" ) - expect( request.browserTestingCalls ).toBeEmpty() - } ) - - it( "builds its named routes under the entry point", () => { - var link = getRequestContext().route( "login@BrowserTesting", { id : 3 } ) - expect( link ).toInclude( "/__browser-testing/login/3" ) - expect( getRequestContext().route( "logout@BrowserTesting" ) ).toInclude( "/__browser-testing/logout" ) - } ) - } ) - } - - /** - * Execute a GET request and expect the plain 404 of the module, without any closure call - * - * @route The route to execute - * @params The request parameters - */ - private function expectNotFound( required string route, struct params = {} ){ - var event = get( route = arguments.route, params = arguments.params ) - expect( event.getStatusCode() ).toBe( - 404, - "Expected a 404 for #arguments.route# #serializeJSON( arguments.params )#" - ) - expect( event.getRenderedContent() ).toBe( "Not Found" ) - expect( request.browserTestingCalls ).toBeEmpty() - setup() - } - -} From af17e822a746089699a646f3ae58d5670bdacf1e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:09:55 +0000 Subject: [PATCH 8/8] Address the Copilot review: visitRoute() and optional routes - visitRoute() no longer skips the spec without browser support: it only visits the page it gets, and pages come from browse(), which skips. - assertRouteIs() matches every route registered with the name, so a route with optional placeholders (/posts/:id?, registered as /posts/:id and /posts) matches with and without them. Module routes use the module routing table. - The harness login form renders the logged-in page instead of relocating: the harness runs with the test controller, which intercepts relocations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UU9aLossoYUkQNwubnitBi --- system/testing/BrowserTestCase.bx | 49 ++++++++++++++-------- test-harness/config/Router.cfc | 1 + test-harness/handlers/browserTesting.cfc | 5 ++- tests/specs/browser/BrowserTestCaseSpec.bx | 21 ++++++---- 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/system/testing/BrowserTestCase.bx b/system/testing/BrowserTestCase.bx index d0e82fab8..086a9f100 100644 --- a/system/testing/BrowserTestCase.bx +++ b/system/testing/BrowserTestCase.bx @@ -172,7 +172,7 @@ class extends="coldbox.system.testing.BaseTestCase" { /** * Visit a named route: page.visit( routeURL( name, params ) ). Relative routes resolve against the - * `baseURL` annotation. Skips the spec when browser testing is not available. + * `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 @@ -181,7 +181,6 @@ class extends="coldbox.system.testing.BaseTestCase" { * @return The page */ function visitRoute( required page, required string name, struct params = {} ) { - ensureBrowser() arguments.page.visit( routeURL( arguments.name, arguments.params ) ) return arguments.page } @@ -238,15 +237,6 @@ class extends="coldbox.system.testing.BaseTestCase" { * -------------------------------------------------------------------------- */ - /** - * Skip the running spec when browser testing is not available. - */ - private void function ensureBrowser() { - if ( !browserAvailable() ) { - skip( browserUnavailableReason() ) - } - } - /** * Skip the running spec when the TestBox install has no browser support. */ @@ -277,7 +267,8 @@ class extends="coldbox.system.testing.BaseTestCase" { /** * 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. Not anchored, and without the trailing slash. + * 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 * @@ -286,18 +277,42 @@ class extends="coldbox.system.testing.BaseTestCase" { * @throws InvalidArgumentException When the named route does not exist */ private string function routePathRegex( required string name ) { - var route = getController().getWireBox().getInstance( "router@coldbox" ).findRouteByName( arguments.name ) - if ( route.isEmpty() ) { + 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" ) - var routeRegex = ( route.regexPattern ?: "" ).reReplace( "^/+|/+$", "", "all" ) if ( len( entryPoint ) ) { regex &= "/" & quoteRegex( entryPoint ) } - if ( len( routeRegex ) ) { - regex &= "/" & routeRegex + 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 } diff --git a/test-harness/config/Router.cfc b/test-harness/config/Router.cfc index e45b5f098..c9d62f038 100644 --- a/test-harness/config/Router.cfc +++ b/test-harness/config/Router.cfc @@ -128,6 +128,7 @@ component { // 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" ) diff --git a/test-harness/handlers/browserTesting.cfc b/test-harness/handlers/browserTesting.cfc index a0a0b3b05..bed4e46f7 100644 --- a/test-harness/handlers/browserTesting.cfc +++ b/test-harness/handlers/browserTesting.cfc @@ -11,12 +11,13 @@ component { } /** - * A login form, like an application login page: GET shows the form, POST logs the user in + * 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 ?: "" - relocate( "browserTesting.whoami" ) + return whoami( argumentCollection = arguments ) } return "
" } diff --git a/tests/specs/browser/BrowserTestCaseSpec.bx b/tests/specs/browser/BrowserTestCaseSpec.bx index 67a86d75d..3fcd321c5 100644 --- a/tests/specs/browser/BrowserTestCaseSpec.bx +++ b/tests/specs/browser/BrowserTestCaseSpec.bx @@ -98,15 +98,18 @@ class extends="coldbox.system.testing.BrowserTestCase" { } } ) - it( - title = "visits named routes", - body = () => { - var page = new tests.resources.browser.FakePage() - expect( visitRoute( page, "users.show", { id : 7 } ) ).toBe( page ) - expect( page.getVisits() ).toBe( [ basePath & "/users/7/" ] ) - }, - skip = !browserAvailable() - ) + 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(