diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b70998d..cedfa92d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +- Added a `--pageHost` CLI option to `bedrock-auto`. This can be useful to set `host.docker.internal` when the browser is in a docker container. #TINYMCE-14879 + +### Fixed +- The grunt task passed config values to bedrock without type coercion, so string values for `Number` or `Boolean` options (e.g. `--webdriverPort=14444` or `--useSelenium=false` via `grunt.option`) were rejected or misread. #TINYMCE-14879 +- Extended test start pings to every page load, allowing updates during a chunk or fail-retry reload. This should reduce how often the server's no-updates watchdog is tripped. #TINYMCE-14879 +- Safari 26 failed to load the test page through the LambdaTest tunnel on `localhost`. The LT tunnel URL now uses LambdaTest's `localhost.lambdatest.com` DNS alias for all browsers. #TINYMCE-14879 + ## 17.0.1 - 2026-08-28 ### Fixed diff --git a/modules/runner/src/main/ts/reporter/Reporter.ts b/modules/runner/src/main/ts/reporter/Reporter.ts index c4bec087..219020c0 100644 --- a/modules/runner/src/main/ts/reporter/Reporter.ts +++ b/modules/runner/src/main/ts/reporter/Reporter.ts @@ -57,6 +57,7 @@ export const Reporter = (params: UrlParams, callbacks: Callbacks, ui: ReporterUi let skipCount = 0; let failCount = 0; let finished = false; + let sentPageStart = false; // A list of test results we are going to send as a batch to the server const testResults: TestReport[] = []; @@ -123,8 +124,10 @@ export const Reporter = (params: UrlParams, callbacks: Callbacks, ui: ReporterUi const testUi = ui.test(); const sendStart = (): Promise => { - if (currentCount === 1) { - // we need to send test start once to establish the session + if (!sentPageStart) { + // We don't want one start notification per test, these are blocking which is very slow over remote connections. + // This is a balance; once every page load (initial and chunk/retry loads). + sentPageStart = true; requestsInFlight.push(callbacks.sendTestStart(params.session, currentCount, totalNumTests, file, name, takeResults())); return Promise.resolve(); } else if (mouse.hasMoved()) { diff --git a/modules/runner/src/test/ts/reporter/ReporterTest.ts b/modules/runner/src/test/ts/reporter/ReporterTest.ts index 6e6a054f..a9986761 100644 --- a/modules/runner/src/test/ts/reporter/ReporterTest.ts +++ b/modules/runner/src/test/ts/reporter/ReporterTest.ts @@ -51,6 +51,7 @@ describe('Reporter.test', () => { let reporter: Reporter, startTestData: StartTestData[], endTestData: EndTestData[]; let doneCalled: boolean, doneError: string | undefined, offset: number; let mouseMoved: boolean, startFails: boolean; + let startGate: Promise | null; const mouse: MouseWatch = { hasMoved: () => mouseMoved, clear: () => { @@ -67,7 +68,7 @@ describe('Reporter.test', () => { } startTestData.push({ session, currentCount, totalTests, file, name, results }); results.forEach(r => endTestData.push({ session, ...r })); - return Promise.resolve(); + return startGate !== null ? startGate : Promise.resolve(); }, sendTestResults: (session, results) => { results.forEach(r => endTestData.push({session, ...r})); @@ -84,6 +85,7 @@ describe('Reporter.test', () => { offset = newOffset; mouseMoved = false; startFails = false; + startGate = null; reporter = Reporter({ ...params, offset }, callbacks, ui, mouse); startTestData = []; endTestData = []; @@ -130,8 +132,9 @@ describe('Reporter.test', () => { mouseMoved = true; return reporter.test('SomeTest.ts', 'second', 10).start(); }).then(() => { - assert.equal(startTestData.length, 1, 'Checking start test data was sent'); - assert.deepEqual(startTestData[0].results.map((r) => r.name), [ 'first' ], 'Checking the batch rode along'); + assert.equal(startTestData.length, 2, 'Checking both test starts were sent'); + assert.deepEqual(startTestData[0].results, [], 'Checking the page load start had nothing to send'); + assert.deepEqual(startTestData[1].results.map((r) => r.name), [ 'first' ], 'Checking the batch rode along'); assert.equal(endTestData.length, 1, 'Checking the result was recorded'); return reporter.waitForResults().then(() => { assert.equal(endTestData.length, 1, 'Checking the result was not also sent separately'); @@ -148,7 +151,7 @@ describe('Reporter.test', () => { startFails = true; return reporter.test('SomeTest.ts', 'second', 10).start(); }).then(() => { - assert.equal(startTestData.length, 0, 'Checking the start was not recorded'); + assert.equal(startTestData.length, 1, 'Checking only the page load start was recorded'); assert.isTrue(mouseMoved, 'Checking the mouse watch was not cleared'); return reporter.waitForResults().then(() => { assert.deepEqual(endTestData.map((r) => r.name), [ 'first' ], 'Checking the result was sent later instead of lost'); @@ -160,16 +163,40 @@ describe('Reporter.test', () => { reset(5); const first = reporter.test('SomeTest.ts', 'first', 10); return first.start().then(() => { - assert.equal(startTestData.length, 0, 'Checking no start test data was sent'); - mouseMoved = true; + assert.equal(startTestData.length, 1, 'Checking only the page load start was sent'); return reporter.test('SomeTest.ts', 'second', 10).start(); }).then(() => { - assert.equal(startTestData.length, 1, 'Checking start test data was sent'); - assert.equal(startTestData[0].currentCount, 7, 'Checking the test number'); + assert.equal(startTestData.length, 1, 'Checking no start was sent while the mouse was still'); + mouseMoved = true; + return reporter.test('SomeTest.ts', 'third', 10).start(); + }).then(() => { + assert.equal(startTestData.length, 2, 'Checking start test data was sent'); + assert.equal(startTestData[1].currentCount, 8, 'Checking the test number'); assert.isFalse(mouseMoved, 'Checking the mouse watch was cleared'); }); }); + it('TINYMCE-14879: posts a start for the first test after a page reload without awaiting it', () => { + reset(5); + let releaseStart: () => void = noop; + startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + return reporter.test('SomeTest.ts', 'first', 10).start().then(() => { + assert.equal(startTestData.length, 1, 'Checking the start was posted before its request resolved'); + assert.deepEqual(startTestData[0], { + currentCount: offset + 1, + session: sessionId, + totalTests: 10, + file: 'SomeTest.ts', + name: 'first', + results: [] + }, 'Checking start test data contents'); + releaseStart(); + return reporter.waitForResults(); + }); + }); + it('should report the session id, file, name, passed state and time on a skipped test', () => { return fc.assert(fc.asyncProperty(fc.string(), fc.string(), fc.string(), fc.integer({ max: offset }), (fileName, testName, skippedMessage, testCount) => { reset(); diff --git a/modules/sample/src/test/ts/client/pass/SecureContextTest.ts b/modules/sample/src/test/ts/client/pass/SecureContextTest.ts new file mode 100644 index 00000000..364e337c --- /dev/null +++ b/modules/sample/src/test/ts/client/pass/SecureContextTest.ts @@ -0,0 +1,6 @@ +import { Assert, UnitTest } from '@ephox/bedrock-client'; + +// We now use secure contexts a lot in TinyMCE. Verify that bedrock remote (AWS and LT) both offer secure contexts. +UnitTest.test('Secure context Test', () => { + Assert.eq('bedrock should be served on a secure context', true, window.isSecureContext); +}); diff --git a/modules/server/src/main/ts/BedrockAuto.ts b/modules/server/src/main/ts/BedrockAuto.ts index 1ff54d94..ab4cfad3 100644 --- a/modules/server/src/main/ts/BedrockAuto.ts +++ b/modules/server/src/main/ts/BedrockAuto.ts @@ -26,7 +26,7 @@ async function makeWebDriver(settings: BedrockAutoSettings, servicePort: number, key: accesskey }; - const tunnel = await Tunnel.prepareConnection(servicePort, remoteWebdriver, sishDomain, tunnelCredentials); + const tunnel = await Tunnel.prepareConnection(servicePort, remoteWebdriver, sishDomain, tunnelCredentials, settings.pageHost); shutdownServices.push(tunnel.shutdown); const location = tunnel.url.href; diff --git a/modules/server/src/main/ts/bedrock/auto/Tunnel.ts b/modules/server/src/main/ts/bedrock/auto/Tunnel.ts index 279aeeb3..da55734f 100644 --- a/modules/server/src/main/ts/bedrock/auto/Tunnel.ts +++ b/modules/server/src/main/ts/bedrock/auto/Tunnel.ts @@ -81,10 +81,14 @@ const createSSH = async (port: number | string, domain: string): Promise } }; +// Safari 26 cannot load `localhost` through the LambdaTest tunnel. LambdaTest publish +// `localhost.lambdatest.com` as a public DNS alias for 127.0.0.1. +const LAMBDA_LOCALHOST_ALIAS = 'localhost.lambdatest.com'; + // LambdaTest supplied tunnel // @lambdatest/nodetunnel has some weird quasi-overriden promise-based versions of functions // and no proper typing for it. Excuse the hard type casting -const createLambda = async (port: number | string, credentials: LambdaCredentials): Promise => { +const createLambda = async (port: number | string, credentials: LambdaCredentials, pageHost: string): Promise => { const tunnel = new LambdaTunnel(); const suffix = crypto.randomUUID(); const tunnelName = 'bedrock-tunnel-' + suffix; @@ -101,8 +105,11 @@ const createLambda = async (port: number | string, credentials: LambdaCredential return tunnel.stop(null as unknown as ((_: boolean) => void)); }; + const url = pageHost === 'localhost' + ? new URL('https://' + LAMBDA_LOCALHOST_ALIAS + ':' + port) + : new URL('http://' + pageHost + ':' + port); const result: Tunnel = { - url: new URL('http://localhost:' + port), + url, name: tunnelName, shutdown }; @@ -117,8 +124,8 @@ const createLambda = async (port: number | string, credentials: LambdaCredential }); }; -const createTunnel = async (port: number, domain: string | undefined, credentials: LambdaCredentials): Promise => { - return domain ? createSSH(port, domain) : createLambda(port, credentials); +const createTunnel = async (port: number, domain: string | undefined, credentials: LambdaCredentials, pageHost: string): Promise => { + return domain ? createSSH(port, domain) : createLambda(port, credentials, pageHost); }; /** @@ -127,11 +134,18 @@ const createTunnel = async (port: number, domain: string | undefined, credential * @param port Dev server connection port * @param remote Name of remote service or undefined * @param domain Domain for sish connection or undefined + * @param pageHost Hostname the browser uses to reach the bedrock server * @returns Connection tunnel */ -export const prepareConnection = async (port: number, remote: string | undefined, domain: string | undefined, credentials: LambdaCredentials ): Promise => { - return remote ? createTunnel(port, domain, credentials) : Promise.resolve({ - url: new URL('http://localhost:' + port), +export const prepareConnection = async (port: number, remote: string | undefined, domain: string | undefined, credentials: LambdaCredentials, pageHost: string): Promise => { + if (remote) { + if (domain && pageHost !== 'localhost') { + console.warn('--pageHost is ignored with a sish tunnel; the tunnel determines the page URL.'); + } + return createTunnel(port, domain, credentials, pageHost); + } + return Promise.resolve({ + url: new URL('http://' + pageHost + ':' + port), shutdown: () => Promise.resolve() }); }; diff --git a/modules/server/src/main/ts/bedrock/cli/ClOptions.ts b/modules/server/src/main/ts/bedrock/cli/ClOptions.ts index c384bc5c..0220e2e2 100644 --- a/modules/server/src/main/ts/bedrock/cli/ClOptions.ts +++ b/modules/server/src/main/ts/bedrock/cli/ClOptions.ts @@ -403,3 +403,12 @@ export const webdriverPort: ClOption = { defaultValue: 4444, uncommon: true }; + +export const pageHost: ClOption = { + name: 'pageHost', + type: String, + defaultValue: 'localhost', + description: 'Hostname the browser uses to reach the bedrock server. Useful when the browser runs in a container or VM (e.g. host.docker.internal for a dockerized selenium grid). With the LambdaTest tunnel, localhost is replaced by the localhost.lambdatest.com alias. Ignored with a sish tunnel.', + validate: Extraction.any, + uncommon: true +}; diff --git a/modules/server/src/main/ts/bedrock/cli/Clis.ts b/modules/server/src/main/ts/bedrock/cli/Clis.ts index 761f73fd..9a763dd2 100644 --- a/modules/server/src/main/ts/bedrock/cli/Clis.ts +++ b/modules/server/src/main/ts/bedrock/cli/Clis.ts @@ -51,6 +51,7 @@ export const forAuto = (directories: Directories, argv: string[] = process.argv) ClOptions.wipeBrowserCache, ClOptions.remote, ClOptions.webdriverPort, + ClOptions.pageHost, ClOptions.sishDomain, ClOptions.username, ClOptions.accesskey, diff --git a/modules/server/src/main/ts/bedrock/core/Settings.ts b/modules/server/src/main/ts/bedrock/core/Settings.ts index 9daa4e18..6185b596 100644 --- a/modules/server/src/main/ts/bedrock/core/Settings.ts +++ b/modules/server/src/main/ts/bedrock/core/Settings.ts @@ -44,4 +44,5 @@ export interface BedrockAutoSettings extends BedrockSettings { readonly devicefarmArn: string; readonly browserVersion: string; readonly platformName?: string; + readonly pageHost: string; } diff --git a/modules/server/src/main/ts/bedrock/server/Controller.ts b/modules/server/src/main/ts/bedrock/server/Controller.ts index 807c4651..93faa2e9 100644 --- a/modules/server/src/main/ts/bedrock/server/Controller.ts +++ b/modules/server/src/main/ts/bedrock/server/Controller.ts @@ -125,7 +125,7 @@ export const create = (stickyFirstSession: boolean, overallTimeout: number, test session.totalTests = totalTests; session.currentTest = currentCount; session.done = false; - // a bit of a lie, but we only ever get 1 start now + // after a start this names the test about to run, not the last one that completed session.previous = { name, file, diff --git a/modules/server/src/test/ts/ClisTest.ts b/modules/server/src/test/ts/ClisTest.ts index 4e41f698..7a5b0887 100644 --- a/modules/server/src/test/ts/ClisTest.ts +++ b/modules/server/src/test/ts/ClisTest.ts @@ -64,7 +64,8 @@ describe('Clis.forAuto', () => { skipResetMousePosition: false, wipeBrowserCache: false, remote: '', - webdriverPort: 4444 + webdriverPort: 4444, + pageHost: 'localhost' }, cleanResult(actual)); }); @@ -106,7 +107,8 @@ describe('Clis.forAuto', () => { skipResetMousePosition: false, wipeBrowserCache: false, remote: '', - webdriverPort: 4444 + webdriverPort: 4444, + pageHost: 'localhost' }, cleanResult(actual)); }); @@ -149,7 +151,8 @@ describe('Clis.forAuto', () => { skipResetMousePosition: false, wipeBrowserCache: false, remote: '', - webdriverPort: 4444 + webdriverPort: 4444, + pageHost: 'localhost' }, cleanResult(actual)); }); @@ -191,7 +194,8 @@ describe('Clis.forAuto', () => { skipResetMousePosition: false, wipeBrowserCache: false, remote: '', - webdriverPort: 4444 + webdriverPort: 4444, + pageHost: 'localhost' }, cleanResult(actual)); }); @@ -239,6 +243,7 @@ describe('Clis.forAuto', () => { username: 'lt_username', accesskey: 'lt_access_key', webdriverPort: 4444, + pageHost: 'localhost', platformName: 'macOS Catalina' }, cleanResult(actual)); }); @@ -286,7 +291,8 @@ describe('Clis.forAuto', () => { sishDomain: 'sish.osu.tiny.work', devicefarmRegion: 'us-west-2', devicefarmArn: 'arn:aws:devicefarm:us-west-2:123123123123:project-name:123a123b-123c-123d-1234a-123a123b123c', - webdriverPort: 4444 + webdriverPort: 4444, + pageHost: 'localhost' }, cleanResult(actual)); }); @@ -347,7 +353,51 @@ describe('Clis.forAuto', () => { skipResetMousePosition: false, wipeBrowserCache: false, remote: '', - webdriverPort: 4444 + webdriverPort: 4444, + pageHost: 'localhost' + }, cleanResult(actual)); + }); + + it('TINYMCE-14879: parses a custom page host', () => { + const args = [ + '--browser', 'chrome-headless', + '--files', 'src/test/resources/test.file1', + '--config', 'src/test/resources/tsconfig.sample.json', + '--pageHost', 'host.docker.internal' + ]; + const actual = Clis.forAuto(directories, args); + AttemptUtils.assertResult({ + browser: 'chrome-headless', + browserVersion: 'latest', + bundler: 'rspack', + config: 'src/test/resources/tsconfig.sample.json', + name: 'bedrock-run', + output: 'scratch', + help: false, + testfiles: [ + 'src/test/resources/test.file1' + ], + delayExit: false, + singleTimeout: 30000, + stopOnFailure: false, + overallTimeout: 600000, + loglevel: 'advanced', + version: false, + chunk: 2000, + retries: 0, + polyfills: [ 'Symbol' ], + verbose: false, + bucket: 1, + buckets: 1, + skipTypecheck: false, + useSandboxForHeadless: false, + useSelenium: false, + extraBrowserCapabilities: '', + skipResetMousePosition: false, + wipeBrowserCache: false, + remote: '', + webdriverPort: 4444, + pageHost: 'host.docker.internal' }, cleanResult(actual)); }); diff --git a/modules/server/tasks/bedrock.js b/modules/server/tasks/bedrock.js index 856740bc..cd3f681d 100644 --- a/modules/server/tasks/bedrock.js +++ b/modules/server/tasks/bedrock.js @@ -5,17 +5,28 @@ const bedrockAuto = require('../lib/main/ts/BedrockAuto'); module.exports = function(grunt) { + // The CLI path coerces values via each option's declared type, but grunt + // options arrive as strings. Note Boolean('false') is true, so values + // are compared against the string 'true' instead. + const coerceSetting = function (clo, value) { + if (clo === undefined || typeof value !== 'string') return value; + if (clo.type === Boolean) return value === 'true'; + return clo.type(value); + }; + const enrichSettings = function (settings) { const newSettings = { }; + const optionsByKey = { }; for (const j in cloptions) { const clo = cloptions[j]; const outputKey = clo.output !== undefined ? clo.output : clo.name; + optionsByKey[outputKey] = clo; if (clo.defaultValue !== undefined) newSettings[outputKey] = clo.defaultValue; } for (const k in settings) { - newSettings[k] = settings[k]; + newSettings[k] = coerceSetting(optionsByKey[k], settings[k]); } const testfiles = getFiles(settings.testfiles);