diff --git a/.github/stale.yml b/.github/stale.yml index 2e530c6c097..ec8b0859ea6 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -12,6 +12,7 @@ exemptLabels: - Black hole bug - Special case Bug - Upstream bug + - Feature Request # Label to use when marking an issue as stale staleLabel: wontfix # Comment to post when marking an issue as stale. Set to `false` to disable diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index a7c254042ee..e88b3273144 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - node: [10, 12, 14, 15] + node: [12, 14, 16] steps: - name: Checkout repository @@ -50,7 +50,7 @@ jobs: strategy: fail-fast: false matrix: - node: [10, 12, 14, 15] + node: [12, 14, 16] steps: - name: Checkout repository diff --git a/.github/workflows/frontend-admin-tests.yml b/.github/workflows/frontend-admin-tests.yml index bff9228a732..44cb697f2dc 100644 --- a/.github/workflows/frontend-admin-tests.yml +++ b/.github/workflows/frontend-admin-tests.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - node: [10, 12, 14, 15] + node: [12, 14, 16] steps: - name: Generate Sauce Labs strings diff --git a/.github/workflows/major-version-git-pull-update.yml b/.github/workflows/upgrade-from-latest-release.yml similarity index 74% rename from .github/workflows/major-version-git-pull-update.yml rename to .github/workflows/upgrade-from-latest-release.yml index 5d3b0d748c6..e57e63ebb66 100644 --- a/.github/workflows/major-version-git-pull-update.yml +++ b/.github/workflows/upgrade-from-latest-release.yml @@ -1,4 +1,4 @@ -name: "In-place git pull from master" +name: "Upgrade from latest release" # any branch is useful for testing before a PR is submitted on: [push, pull_request] @@ -16,10 +16,10 @@ jobs: strategy: fail-fast: false matrix: - node: [10, 12, 14, 15] + node: [12, 14, 16] steps: - - name: Checkout master repository + - name: Check out latest release uses: actions/checkout@v2 with: ref: master @@ -60,10 +60,18 @@ jobs: - name: Run the backend tests run: cd src && npm test - - name: Git fetch - run: git fetch + # Because actions/checkout@v2 is called with "ref: master" and without + # "fetch-depth: 0", the local clone does not have the ${GITHUB_SHA} commit. + # Fetch ${GITHUB_REF} to get the ${GITHUB_SHA} commit. Note that a plain + # "git fetch" only fetches "normal" references (refs/heads/* and + # refs/tags/*), and for pull requests none of the normal references include + # ${GITHUB_SHA}, so we have to explicitly tell Git to fetch ${GITHUB_REF}. + - name: Fetch the new Git commits + run: git fetch --depth=1 origin "${GITHUB_REF}" - - name: Checkout this branch over master + - name: Upgrade to the new Git revision + # For pull requests, ${GITHUB_SHA} is the automatically generated merge + # commit that merges the PR's source branch to its destination branch. run: git checkout "${GITHUB_SHA}" - name: Install all dependencies and symlink for ep_etherpad-lite diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f60b235f36..05d61e1ffee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,68 @@ +# 1.8.14 + +### Security fixes + +* Fixed a persistent XSS vulnerability in the Chat component. In case you can't + update to 1.8.14 directly, we strongly recommend to cherry-pick + a7968115581e20ef47a533e030f59f830486bdfa. Thanks to sonarsource for the + professional disclosure. + +### Compatibility changes + +* Node.js v12.13.0 or later is now required. +* The `favicon` setting is now interpreted as a pathname to a favicon file, not + a URL. Please see the documentation comment in `settings.json.template`. +* The undocumented `faviconPad` and `faviconTimeslider` settings have been + removed. +* MySQL/MariaDB now uses connection pooling, which means you will see up to 10 + connections to the MySQL/MariaDB server (by default) instead of 1. This might + cause Etherpad to crash with a "ER_CON_COUNT_ERROR: Too many connections" + error if your server is configured with a low connection limit. +* Changes to environment variable substitution in `settings.json` (see the + documentation comments in `settings.json.template` for details): + * An environment variable set to the string "null" now becomes `null` instead + of the string "null". Similarly, if the environment variable is unset and + the default value is "null" (e.g., `"${UNSET_VAR:null}"`), the value now + becomes `null` instead of the string "null". It is no longer possible to + produce the string "null" via environment variable substitution. + * An environment variable set to the string "undefined" now causes the setting + to be removed instead of set to the string "undefined". Similarly, if the + environment variable is unset and the default value is "undefined" (e.g., + `"${UNSET_VAR:undefined}"`), the setting is now removed instead of set to + the string "undefined". It is no longer possible to produce the string + "undefined" via environment variable substitution. + * Support for unset variables without a default value is now deprecated. + Please change all instances of `"${FOO}"` in your `settings.json` to + `${FOO:null}` to keep the current behavior. + * The `DB_*` variable substitutions in `settings.json.docker` that previously + defaulted to `null` now default to "undefined". +* Calling `next` without argument when using `Changeset.opIterator` does always + return a new Op. See b9753dcc7156d8471a5aa5b6c9b85af47f630aa8 for details. + +### Notable enhancements and fixes + +* MySQL/MariaDB now uses connection pooling, which should improve stability and + reduce latency. +* Bulk database writes are now retried individually on write failure. +* Minify: Avoid crash due to unhandled Promise rejection if stat fails. +* padIds are now included in /socket.io query string, e.g. + `https://video.etherpad.com/socket.io/?padId=AWESOME&EIO=3&transport=websocket&t=...&sid=...`. + This is useful for directing pads to separate socket.io nodes. +* diff --git a/src/templates/pad.html b/src/templates/pad.html index 7bf2346f9c8..d7c3c082309 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -39,7 +39,7 @@ - + <% e.begin_block("styles"); %> diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html index b3fd3d0064a..fe43668c8c8 100644 --- a/src/templates/timeslider.html +++ b/src/templates/timeslider.html @@ -33,7 +33,7 @@ - + <% e.begin_block("timesliderStyles"); %> diff --git a/src/tests/backend/specs/api/api.js b/src/tests/backend/specs/api/api.js index d05a9989d5a..1415795b277 100644 --- a/src/tests/backend/specs/api/api.js +++ b/src/tests/backend/specs/api/api.js @@ -55,5 +55,4 @@ describe(__filename, function () { } }); }); - }); diff --git a/src/tests/backend/specs/api/importexportGetPost.js b/src/tests/backend/specs/api/importexportGetPost.js index 9261aafa6e0..a68ba40110e 100644 --- a/src/tests/backend/specs/api/importexportGetPost.js +++ b/src/tests/backend/specs/api/importexportGetPost.js @@ -109,22 +109,24 @@ describe(__filename, function () { .expect((res) => assert.equal(res.body.data.text, padText.toString())); }); - it('gets read only pad Id and exports the html and text for this pad', async function () { - this.timeout(250); - const ro = await agent.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`) - .expect(200) - .expect((res) => assert.ok(JSON.parse(res.text).data.readOnlyID)); - const readOnlyId = JSON.parse(ro.text).data.readOnlyID; - - await agent.get(`/p/${readOnlyId}/export/html`) - .expect(200) - .expect((res) => assert(res.text.indexOf('This is the') !== -1)); - - await agent.get(`/p/${readOnlyId}/export/txt`) - .expect(200) - .expect((res) => assert(res.text.indexOf('This is the') !== -1)); - }); - + for (const authn of [false, true]) { + it(`can export from read-only pad ID, authn ${authn}`, async function () { + this.timeout(250); + settings.requireAuthentication = authn; + const get = (ep) => { + let req = agent.get(ep); + if (authn) req = req.auth('user', 'user-password'); + return req.expect(200); + }; + const ro = await get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`) + .expect((res) => assert.ok(JSON.parse(res.text).data.readOnlyID)); + const readOnlyId = JSON.parse(ro.text).data.readOnlyID; + await get(`/p/${readOnlyId}/export/html`) + .expect((res) => assert(res.text.indexOf('This is the') !== -1)); + await get(`/p/${readOnlyId}/export/txt`) + .expect((res) => assert(res.text.indexOf('This is the') !== -1)); + }); + } describe('Import/Export tests requiring AbiWord/LibreOffice', function () { this.timeout(10000); diff --git a/src/tests/backend/specs/favicon-test-custom.png b/src/tests/backend/specs/favicon-test-custom.png new file mode 100644 index 00000000000..9c6532c9610 Binary files /dev/null and b/src/tests/backend/specs/favicon-test-custom.png differ diff --git a/src/tests/backend/specs/favicon-test-skin.png b/src/tests/backend/specs/favicon-test-skin.png new file mode 100644 index 00000000000..87bdadbbb32 Binary files /dev/null and b/src/tests/backend/specs/favicon-test-skin.png differ diff --git a/src/tests/backend/specs/favicon.js b/src/tests/backend/specs/favicon.js new file mode 100644 index 00000000000..a5e3095dee8 --- /dev/null +++ b/src/tests/backend/specs/favicon.js @@ -0,0 +1,91 @@ +'use strict'; + +const assert = require('assert').strict; +const common = require('../common'); +const fs = require('fs'); +const fsp = fs.promises; +const path = require('path'); +const settings = require('../../../node/utils/Settings'); +const superagent = require('superagent'); + +describe(__filename, function () { + let agent; + let backupSettings; + let skinDir; + let wantCustomIcon; + let wantDefaultIcon; + let wantSkinIcon; + + before(async function () { + agent = await common.init(); + wantCustomIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-custom.png')); + wantDefaultIcon = await fsp.readFile(path.join(settings.root, 'src', 'static', 'favicon.ico')); + wantSkinIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-skin.png')); + }); + + beforeEach(async function () { + backupSettings = {...settings}; + skinDir = await fsp.mkdtemp(path.join(settings.root, 'src', 'static', 'skins', 'test-')); + settings.skinName = path.basename(skinDir); + }); + + afterEach(async function () { + delete settings.favicon; + delete settings.skinName; + Object.assign(settings, backupSettings); + try { + // TODO: The {recursive: true} option wasn't added to fsp.rmdir() until Node.js v12.10.0 so we + // can't rely on it until support for Node.js v10 is dropped. + await fsp.unlink(path.join(skinDir, 'favicon.ico')); + await fsp.rmdir(skinDir, {recursive: true}); + } catch (err) { /* intentionally ignored */ } + }); + + it('uses custom favicon if set (relative pathname)', async function () { + settings.favicon = + path.relative(settings.root, path.join(__dirname, 'favicon-test-custom.png')); + assert(!path.isAbsolute(settings.favicon)); + const {body: gotIcon} = await agent.get('/favicon.ico') + .accept('png').buffer(true).parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantCustomIcon)); + }); + + it('uses custom favicon if set (absolute pathname)', async function () { + settings.favicon = path.join(__dirname, 'favicon-test-custom.png'); + assert(path.isAbsolute(settings.favicon)); + const {body: gotIcon} = await agent.get('/favicon.ico') + .accept('png').buffer(true).parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantCustomIcon)); + }); + + it('falls back if custom favicon is missing', async function () { + // The previous default for settings.favicon was 'favicon.ico', so many users will continue to + // have that in their settings.json for a long time. There is unlikely to be a favicon at + // path.resolve(settings.root, 'favicon.ico'), so this test ensures that 'favicon.ico' won't be + // a problem for those users. + settings.favicon = 'favicon.ico'; + const {body: gotIcon} = await agent.get('/favicon.ico') + .accept('png').buffer(true).parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantDefaultIcon)); + }); + + it('uses skin favicon if present', async function () { + await fsp.writeFile(path.join(skinDir, 'favicon.ico'), wantSkinIcon); + settings.favicon = null; + const {body: gotIcon} = await agent.get('/favicon.ico') + .accept('png').buffer(true).parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantSkinIcon)); + }); + + it('falls back to default favicon', async function () { + settings.favicon = null; + const {body: gotIcon} = await agent.get('/favicon.ico') + .accept('png').buffer(true).parse(superagent.parse.image) + .expect(200); + assert(gotIcon.equals(wantDefaultIcon)); + }); +}); diff --git a/src/tests/backend/specs/regression-db.js b/src/tests/backend/specs/regression-db.js new file mode 100644 index 00000000000..221193c3b87 --- /dev/null +++ b/src/tests/backend/specs/regression-db.js @@ -0,0 +1,31 @@ +'use strict'; + +const AuthorManager = require('../../../node/db/AuthorManager'); +const assert = require('assert').strict; +const common = require('../common'); +const db = require('../../../node/db/DB'); + +describe(__filename, function () { + let setBackup; + + before(async function () { + await common.init(); + setBackup = db.set; + + db.set = async (...args) => { + // delay db.set + await new Promise((resolve) => { setTimeout(() => resolve(), 500); }); + return await setBackup.call(db, ...args); + }; + }); + + after(async function () { + db.set = setBackup; + }); + + it('regression test for missing await in createAuthor (#5000)', async function () { + this.timeout(700); + const {authorID} = await AuthorManager.createAuthor(); // Should block until db.set() finishes. + assert(await AuthorManager.doesAuthorExist(authorID)); + }); +}); diff --git a/src/tests/backend/specs/sanitizePathname.js b/src/tests/backend/specs/sanitizePathname.js new file mode 100644 index 00000000000..767221920dd --- /dev/null +++ b/src/tests/backend/specs/sanitizePathname.js @@ -0,0 +1,96 @@ +'use strict'; + +const assert = require('assert').strict; +const path = require('path'); +const sanitizePathname = require('../../../node/utils/sanitizePathname'); + +describe(__filename, function () { + describe('absolute paths rejected', function () { + const testCases = [ + ['posix', '/'], + ['posix', '/foo'], + ['win32', '/'], + ['win32', '\\'], + ['win32', 'C:/foo'], + ['win32', 'C:\\foo'], + ['win32', 'c:/foo'], + ['win32', 'c:\\foo'], + ['win32', '/foo'], + ['win32', '\\foo'], + ]; + for (const [platform, p] of testCases) { + it(`${platform} ${p}`, async function () { + assert.throws(() => sanitizePathname(p, path[platform]), {message: /absolute path/}); + }); + } + }); + describe('directory traversal rejected', function () { + const testCases = [ + ['posix', '..'], + ['posix', '../'], + ['posix', '../foo'], + ['posix', 'foo/../..'], + ['win32', '..'], + ['win32', '../'], + ['win32', '..\\'], + ['win32', '../foo'], + ['win32', '..\\foo'], + ['win32', 'foo/../..'], + ['win32', 'foo\\..\\..'], + ]; + for (const [platform, p] of testCases) { + it(`${platform} ${p}`, async function () { + assert.throws(() => sanitizePathname(p, path[platform]), {message: /travers/}); + }); + } + }); + + describe('accepted paths', function () { + const testCases = [ + ['posix', '', '.'], + ['posix', '.'], + ['posix', './'], + ['posix', 'foo'], + ['posix', 'foo/'], + ['posix', 'foo/bar/..', 'foo'], + ['posix', 'foo/bar/../', 'foo/'], + ['posix', './foo', 'foo'], + ['posix', 'foo/bar'], + ['posix', 'foo\\bar'], + ['posix', '\\foo'], + ['posix', '..\\foo'], + ['posix', 'foo/../bar', 'bar'], + ['posix', 'C:/foo'], + ['posix', 'C:\\foo'], + ['win32', '', '.'], + ['win32', '.'], + ['win32', './'], + ['win32', '.\\', './'], + ['win32', 'foo'], + ['win32', 'foo/'], + ['win32', 'foo\\', 'foo/'], + ['win32', 'foo/bar/..', 'foo'], + ['win32', 'foo\\bar\\..', 'foo'], + ['win32', 'foo/bar/../', 'foo/'], + ['win32', 'foo\\bar\\..\\', 'foo/'], + ['win32', './foo', 'foo'], + ['win32', '.\\foo', 'foo'], + ['win32', 'foo/bar'], + ['win32', 'foo\\bar', 'foo/bar'], + ['win32', 'foo/../bar', 'bar'], + ['win32', 'foo\\..\\bar', 'bar'], + ['win32', 'foo/..\\bar', 'bar'], + ['win32', 'foo\\../bar', 'bar'], + ]; + for (const [platform, p, tcWant] of testCases) { + const want = tcWant == null ? p : tcWant; + it(`${platform} ${p || ''} -> ${want}`, async function () { + assert.equal(sanitizePathname(p, path[platform]), want); + }); + } + }); + + it('default path API', async function () { + assert.equal(sanitizePathname('foo'), 'foo'); + }); +}); diff --git a/src/tests/backend/specs/settings.js b/src/tests/backend/specs/settings.js new file mode 100644 index 00000000000..e737f4f3445 --- /dev/null +++ b/src/tests/backend/specs/settings.js @@ -0,0 +1,61 @@ +'use strict'; + +const assert = require('assert').strict; +const {parseSettings} = require('../../../node/utils/Settings').exportedForTestingOnly; +const path = require('path'); +const process = require('process'); + +describe(__filename, function () { + describe('parseSettings', function () { + let settings; + const envVarSubstTestCases = [ + {name: 'true', val: 'true', var: 'SET_VAR_TRUE', want: true}, + {name: 'false', val: 'false', var: 'SET_VAR_FALSE', want: false}, + {name: 'null', val: 'null', var: 'SET_VAR_NULL', want: null}, + {name: 'undefined', val: 'undefined', var: 'SET_VAR_UNDEFINED', want: undefined}, + {name: 'number', val: '123', var: 'SET_VAR_NUMBER', want: 123}, + {name: 'string', val: 'foo', var: 'SET_VAR_STRING', want: 'foo'}, + {name: 'empty string', val: '', var: 'SET_VAR_EMPTY_STRING', want: ''}, + ]; + + before(async function () { + for (const tc of envVarSubstTestCases) process.env[tc.var] = tc.val; + delete process.env.UNSET_VAR; + settings = parseSettings(path.join(__dirname, 'settings.json'), true); + assert(settings != null); + }); + + describe('environment variable substitution', function () { + describe('set', function () { + for (const tc of envVarSubstTestCases) { + it(tc.name, async function () { + const obj = settings['environment variable substitution'].set; + if (tc.name === 'undefined') { + assert(!(tc.name in obj)); + } else { + assert.equal(obj[tc.name], tc.want); + } + }); + } + }); + + describe('unset', function () { + it('no default', async function () { + const obj = settings['environment variable substitution'].unset; + assert.equal(obj['no default'], null); + }); + + for (const tc of envVarSubstTestCases) { + it(tc.name, async function () { + const obj = settings['environment variable substitution'].unset; + if (tc.name === 'undefined') { + assert(!(tc.name in obj)); + } else { + assert.equal(obj[tc.name], tc.want); + } + }); + } + }); + }); + }); +}); diff --git a/src/tests/backend/specs/settings.json b/src/tests/backend/specs/settings.json new file mode 100644 index 00000000000..12b4748c097 --- /dev/null +++ b/src/tests/backend/specs/settings.json @@ -0,0 +1,39 @@ +// line comment +/* + * block comment + */ +{ + "trailing commas": { + "lists": { + "multiple lines": [ + "", + ] + }, + "objects": { + "multiple lines": { + "key": "", + } + } + }, + "environment variable substitution": { + "set": { + "true": "${SET_VAR_TRUE}", + "false": "${SET_VAR_FALSE}", + "null": "${SET_VAR_NULL}", + "undefined": "${SET_VAR_UNDEFINED}", + "number": "${SET_VAR_NUMBER}", + "string": "${SET_VAR_STRING}", + "empty string": "${SET_VAR_EMPTY_STRING}" + }, + "unset": { + "no default": "${UNSET_VAR}", + "true": "${UNSET_VAR:true}", + "false": "${UNSET_VAR:false}", + "null": "${UNSET_VAR:null}", + "undefined": "${UNSET_VAR:undefined}", + "number": "${UNSET_VAR:123}", + "string": "${UNSET_VAR:foo}", + "empty string": "${UNSET_VAR:}" + } + } +} diff --git a/src/tests/backend/specs/socketio.js b/src/tests/backend/specs/socketio.js index fdb578b5532..9b9e2101b16 100644 --- a/src/tests/backend/specs/socketio.js +++ b/src/tests/backend/specs/socketio.js @@ -5,6 +5,7 @@ const common = require('../common'); const io = require('socket.io-client'); const padManager = require('../../../node/db/PadManager'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); +const readOnlyManager = require('../../../node/db/ReadOnlyManager'); const setCookieParser = require('set-cookie-parser'); const settings = require('../../../node/utils/Settings'); @@ -52,12 +53,16 @@ const connect = async (res) => { ([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; '); logger.debug('socket.io connecting...'); + let padId = null; + if (res) { + padId = res.req.path.split('/p/')[1]; + } const socket = io(`${common.baseUrl}/`, { forceNew: true, // Different tests will have different query parameters. path: '/socket.io', // socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the // express_sid cookie must be passed as a query parameter. - query: {cookie: reqCookieHdr}, + query: {cookie: reqCookieHdr, padId}, }); try { await getSocketEvent(socket, 'connect'); @@ -164,6 +169,33 @@ describe(__filename, function () { const clientVars = await handshake(socket, 'pad'); assert.equal(clientVars.type, 'CLIENT_VARS'); }); + + for (const authn of [false, true]) { + const desc = authn ? 'authn user' : '!authn anonymous'; + it(`${desc} read-only /p/pad -> 200, ok`, async function () { + this.timeout(400); + const get = (ep) => { + let res = agent.get(ep); + if (authn) res = res.auth('user', 'user-password'); + return res.expect(200); + }; + settings.requireAuthentication = authn; + let res = await get('/p/pad'); + socket = await connect(res); + let clientVars = await handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, false); + const readOnlyId = clientVars.data.readOnlyId; + assert(readOnlyManager.isReadOnlyId(readOnlyId)); + socket.close(); + res = await get(`/p/${readOnlyId}`); + socket = await connect(res); + clientVars = await handshake(socket, readOnlyId); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, true); + }); + } + it('authz user /p/pad -> 200, ok', async function () { this.timeout(400); settings.requireAuthentication = true; @@ -199,6 +231,24 @@ describe(__filename, function () { const message = await handshake(socket, 'pad'); assert.equal(message.accessStatus, 'deny'); }); + + it('authn anonymous read-only /p/pad -> 401, error', async function () { + this.timeout(400); + settings.requireAuthentication = true; + let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await connect(res); + const clientVars = await handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + const readOnlyId = clientVars.data.readOnlyId; + assert(readOnlyManager.isReadOnlyId(readOnlyId)); + socket.close(); + res = await agent.get(`/p/${readOnlyId}`).expect(401); + // Despite the 401, try to read the pad via a socket.io connection anyway. + socket = await connect(res); + const message = await handshake(socket, readOnlyId); + assert.equal(message.accessStatus, 'deny'); + }); + it('authn !cookie -> error', async function () { this.timeout(400); settings.requireAuthentication = true; diff --git a/src/tests/frontend/helper.js b/src/tests/frontend/helper.js index 4b18760868e..9e63cc8f524 100644 --- a/src/tests/frontend/helper.js +++ b/src/tests/frontend/helper.js @@ -6,17 +6,16 @@ const helper = {}; let $iframe; const jsLibraries = {}; - helper.init = (cb) => { - $.get('/static/js/vendors/jquery.js').done((code) => { - // make sure we don't override existing jquery - jsLibraries.jquery = `if(typeof $ === 'undefined') {\n${code}\n}`; - - $.get('/tests/frontend/lib/sendkeys.js').done((code) => { - jsLibraries.sendkeys = code; - - cb(); - }); - }); + helper.init = async () => { + [ + jsLibraries.jquery, + jsLibraries.sendkeys, + ] = await Promise.all([ + $.get('../../static/js/vendors/jquery.js'), + $.get('lib/sendkeys.js'), + ]); + // make sure we don't override existing jquery + jsLibraries.jquery = `if (typeof $ === 'undefined') {\n${jsLibraries.jquery}\n}`; }; helper.randomString = (len) => { @@ -51,26 +50,21 @@ const helper = {}; }; helper.clearSessionCookies = () => { - // Expire cookies, so author and language are changed after reloading the pad. See: - // https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_4_reset_the_previous_cookie - window.document.cookie = 'token=;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; - window.document.cookie = 'language=;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; + window.Cookies.remove('token'); + window.Cookies.remove('language'); }; // Can only happen when the iframe exists, so we're doing it separately from other cookies helper.clearPadPrefCookie = () => { - helper.padChrome$.document.cookie = 'prefsHttp=;expires=Thu, 01 Jan 1970 00:00:00 GMT'; + const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie'); + padcookie.clear(); }; - // Overwrite all prefs in pad cookie. Assumes http, not https. - // - // `helper.padChrome$.document.cookie` (the iframe) and `window.document.cookie` - // seem to have independent cookies, UNLESS we put path=/ here (which we don't). - // I don't fully understand it, but this function seems to properly simulate - // padCookie.setPref in the client code + // Overwrite all prefs in pad cookie. helper.setPadPrefCookie = (prefs) => { - helper.padChrome$.document.cookie = - (`prefsHttp=${escape(JSON.stringify(prefs))};expires=Thu, 01 Jan 3000 00:00:00 GMT`); + const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie'); + padcookie.clear(); + for (const [key, value] of Object.entries(prefs)) padcookie.setPref(key, value); }; // Functionality for knowing what key event type is required for tests @@ -89,19 +83,22 @@ const helper = {}; } helper.evtType = evtType; - // @todo needs fixing asap - // newPad occasionally timeouts, might be a problem with ready/onload code during page setup - // This ensures that tests run regardless of this problem - helper.retry = 0; - - helper.newPad = (cb, padName) => { - // build opts object - let opts = {clearCookies: true}; - if (typeof cb === 'function') { - opts.cb = cb; - } else { - opts = _.defaults(cb, opts); - } + // Deprecated; use helper.aNewPad() instead. + helper.newPad = (opts, id) => { + if (!id) id = `FRONTEND_TEST_${helper.randomString(20)}`; + opts = Object.assign({id}, typeof opts === 'function' ? {cb: opts} : opts); + const {cb = (err) => { if (err != null) throw err; }} = opts; + delete opts.cb; + helper.aNewPad(opts).then((id) => cb(null, id), (err) => cb(err || new Error(err))); + return id; + }; + + helper.aNewPad = async (opts = {}) => { + opts = Object.assign({ + _retry: 0, + clearCookies: true, + id: `FRONTEND_TEST_${helper.randomString(20)}`, + }, opts); // if opts.params is set we manipulate the URL to include URL parameters IE ?foo=Bah. let encodedParams; @@ -118,10 +115,7 @@ const helper = {}; helper.clearSessionCookies(); } - if (!padName) padName = `FRONTEND_TEST_${helper.randomString(20)}`; - $iframe = $(``); - // needed for retry - const origPadName = padName; + $iframe = $(``); // clean up inner iframe references helper.padChrome$ = helper.padOuter$ = helper.padInner$ = null; @@ -130,55 +124,53 @@ const helper = {}; $('#iframe-container iframe').remove(); // set new iframe $('#iframe-container').append($iframe); - $iframe.one('load', () => { - helper.padChrome$ = getFrameJQuery($('#iframe-container iframe')); - if (opts.clearCookies) { - helper.clearPadPrefCookie(); - } - if (opts.padPrefs) { - helper.setPadPrefCookie(opts.padPrefs); - } - helper.waitFor(() => !$iframe.contents().find('#editorloadingbox') - .is(':visible'), 10000).done(() => { - helper.padOuter$ = getFrameJQuery(helper.padChrome$('iframe[name="ace_outer"]')); - helper.padInner$ = getFrameJQuery(helper.padOuter$('iframe[name="ace_inner"]')); - - // disable all animations, this makes tests faster and easier - helper.padChrome$.fx.off = true; - helper.padOuter$.fx.off = true; - helper.padInner$.fx.off = true; - - /* - * chat messages received - * @type {Array} - */ - helper.chatMessages = []; - - /* - * changeset commits from the server - * @type {Array} - */ - helper.commits = []; - - /* - * userInfo messages from the server - * @type {Array} - */ - helper.userInfos = []; - - // listen for server messages - helper.spyOnSocketIO(); - opts.cb(); - }).fail(() => { - if (helper.retry > 3) { - throw new Error('Pad never loaded'); - } - helper.retry++; - helper.newPad(cb, origPadName); - }); - }); + await new Promise((resolve) => $iframe.one('load', resolve)); + helper.padChrome$ = getFrameJQuery($('#iframe-container iframe')); + helper.padChrome$.padeditor = + helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_editor').padeditor; + if (opts.clearCookies) { + helper.clearPadPrefCookie(); + } + if (opts.padPrefs) { + helper.setPadPrefCookie(opts.padPrefs); + } + try { + await helper.waitForPromise( + () => !$iframe.contents().find('#editorloadingbox').is(':visible'), 10000); + } catch (err) { + if (opts._retry++ >= 4) throw new Error('Pad never loaded'); + return await helper.aNewPad(opts); + } + helper.padOuter$ = getFrameJQuery(helper.padChrome$('iframe[name="ace_outer"]')); + helper.padInner$ = getFrameJQuery(helper.padOuter$('iframe[name="ace_inner"]')); + + // disable all animations, this makes tests faster and easier + helper.padChrome$.fx.off = true; + helper.padOuter$.fx.off = true; + helper.padInner$.fx.off = true; + + /* + * chat messages received + * @type {Array} + */ + helper.chatMessages = []; + + /* + * changeset commits from the server + * @type {Array} + */ + helper.commits = []; - return padName; + /* + * userInfo messages from the server + * @type {Array} + */ + helper.userInfos = []; + + // listen for server messages + helper.spyOnSocketIO(); + + return opts.id; }; helper.newAdmin = async (page) => { @@ -269,6 +261,22 @@ const helper = {}; selection.addRange(range); }; + // Temporarily reduces minimum time between commits and calls the provided function with a single + // argument: a function that immediately incorporates all pad edits (as opposed to waiting for the + // idle timer to fire). + helper.withFastCommit = async (fn) => { + const incorp = () => helper.padChrome$.padeditor.ace.callWithAce( + (ace) => ace.ace_inCallStackIfNecessary('helper.edit', () => ace.ace_fastIncorp())); + const cc = helper.padChrome$.window.pad.collabClient; + const {commitDelay} = cc; + cc.commitDelay = 0; + try { + return await fn(incorp); + } finally { + cc.commitDelay = commitDelay; + } + }; + const getTextNodeAndOffsetOf = ($targetLine, targetOffsetAtLine) => { const $textNodes = $targetLine.find('*').contents().filter(function () { return this.nodeType === Node.TEXT_NODE; diff --git a/src/tests/frontend/helper/methods.js b/src/tests/frontend/helper/methods.js index 4b03917741a..253bfbc0df6 100644 --- a/src/tests/frontend/helper/methods.js +++ b/src/tests/frontend/helper/methods.js @@ -6,14 +6,13 @@ */ helper.spyOnSocketIO = () => { helper.contentWindow().pad.socket.on('message', (msg) => { - if (msg.type === 'COLLABROOM') { - if (msg.data.type === 'ACCEPT_COMMIT') { - helper.commits.push(msg); - } else if (msg.data.type === 'USER_NEWINFO') { - helper.userInfos.push(msg); - } else if (msg.data.type === 'CHAT_MESSAGE') { - helper.chatMessages.push(msg); - } + if (msg.type !== 'COLLABROOM') return; + if (msg.data.type === 'ACCEPT_COMMIT') { + helper.commits.push(msg); + } else if (msg.data.type === 'USER_NEWINFO') { + helper.userInfos.push(msg); + } else if (msg.data.type === 'CHAT_MESSAGE') { + helper.chatMessages.push(msg); } }); }; @@ -33,8 +32,11 @@ helper.spyOnSocketIO = () => { helper.edit = async (message, line) => { const editsNum = helper.commits.length; line = line ? line - 1 : 0; - helper.linesDiv()[line].sendkeys(message); - return helper.waitForPromise(() => editsNum + 1 === helper.commits.length); + await helper.withFastCommit(async (incorp) => { + helper.linesDiv()[line].sendkeys(message); + incorp(); + await helper.waitForPromise(() => editsNum + 1 === helper.commits.length); + }); }; /** @@ -45,11 +47,7 @@ helper.edit = async (message, line) => { * * @returns {Array.} array of divs */ -helper.linesDiv = () => { - return helper.padInner$('.ace-line').map(function () { - return $(this); - }).get(); -}; +helper.linesDiv = () => helper.padInner$('.ace-line').map(function () { return $(this); }).get(); /** * The pad text as an array of lines @@ -81,10 +79,10 @@ helper.defaultText = * @param {string} message the chat message to be sent * @returns {Promise} */ -helper.sendChatMessage = (message) => { +helper.sendChatMessage = async (message) => { const noOfChatMessages = helper.chatMessages.length; helper.padChrome$('#chatinput').sendkeys(message); - return helper.waitForPromise(() => noOfChatMessages + 1 === helper.chatMessages.length); + await helper.waitForPromise(() => noOfChatMessages + 1 === helper.chatMessages.length); }; /** @@ -92,11 +90,10 @@ helper.sendChatMessage = (message) => { * * @returns {Promise} */ -helper.showSettings = () => { - if (!helper.isSettingsShown()) { - helper.settingsButton().click(); - return helper.waitForPromise(() => helper.isSettingsShown(), 2000); - } +helper.showSettings = async () => { + if (helper.isSettingsShown()) return; + helper.settingsButton().click(); + await helper.waitForPromise(() => helper.isSettingsShown(), 2000); }; /** @@ -105,11 +102,10 @@ helper.showSettings = () => { * @returns {Promise} * @todo untested */ -helper.hideSettings = () => { - if (helper.isSettingsShown()) { - helper.settingsButton().click(); - return helper.waitForPromise(() => !helper.isSettingsShown(), 2000); - } +helper.hideSettings = async () => { + if (!helper.isSettingsShown()) return; + helper.settingsButton().click(); + await helper.waitForPromise(() => !helper.isSettingsShown(), 2000); }; /** @@ -118,12 +114,11 @@ helper.hideSettings = () => { * * @returns {Promise} */ -helper.enableStickyChatviaSettings = () => { +helper.enableStickyChatviaSettings = async () => { const stickyChat = helper.padChrome$('#options-stickychat'); - if (helper.isSettingsShown() && !stickyChat.is(':checked')) { - stickyChat.click(); - return helper.waitForPromise(() => helper.isChatboxSticky(), 2000); - } + if (!helper.isSettingsShown() || stickyChat.is(':checked')) return; + stickyChat.click(); + await helper.waitForPromise(() => helper.isChatboxSticky(), 2000); }; /** @@ -132,12 +127,11 @@ helper.enableStickyChatviaSettings = () => { * * @returns {Promise} */ -helper.disableStickyChatviaSettings = () => { +helper.disableStickyChatviaSettings = async () => { const stickyChat = helper.padChrome$('#options-stickychat'); - if (helper.isSettingsShown() && stickyChat.is(':checked')) { - stickyChat.click(); - return helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); - } + if (!helper.isSettingsShown() || !stickyChat.is(':checked')) return; + stickyChat.click(); + await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); }; /** @@ -146,12 +140,11 @@ helper.disableStickyChatviaSettings = () => { * * @returns {Promise} */ -helper.enableStickyChatviaIcon = () => { +helper.enableStickyChatviaIcon = async () => { const stickyChat = helper.padChrome$('#titlesticky'); - if (helper.isChatboxShown() && !helper.isChatboxSticky()) { - stickyChat.click(); - return helper.waitForPromise(() => helper.isChatboxSticky(), 2000); - } + if (!helper.isChatboxShown() || helper.isChatboxSticky()) return; + stickyChat.click(); + await helper.waitForPromise(() => helper.isChatboxSticky(), 2000); }; /** @@ -160,11 +153,10 @@ helper.enableStickyChatviaIcon = () => { * * @returns {Promise} */ -helper.disableStickyChatviaIcon = () => { - if (helper.isChatboxShown() && helper.isChatboxSticky()) { - helper.titlecross().click(); - return helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); - } +helper.disableStickyChatviaIcon = async () => { + if (!helper.isChatboxShown() || !helper.isChatboxSticky()) return; + helper.titlecross().click(); + await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000); }; /** @@ -179,12 +171,12 @@ helper.disableStickyChatviaIcon = () => { * @todo for some reason this does only work the first time, you cannot * goto rev 0 and then via the same method to rev 5. Use buttons instead */ -helper.gotoTimeslider = (revision) => { +helper.gotoTimeslider = async (revision) => { revision = Number.isInteger(revision) ? `#${revision}` : ''; const iframe = $('#iframe-container iframe'); iframe.attr('src', `${iframe.attr('src')}/timeslider${revision}`); - return helper.waitForPromise(() => helper.timesliderTimerTime() && + await helper.waitForPromise(() => helper.timesliderTimerTime() && !Number.isNaN(new Date(helper.timesliderTimerTime()).getTime()), 10000); }; @@ -227,7 +219,10 @@ helper.clearPad = async () => { await helper.waitForPromise(() => !helper.padInner$.document.getSelection().isCollapsed); const e = new helper.padInner$.Event(helper.evtType); e.keyCode = 8; // delete key - helper.padInner$('#innerdocbody').trigger(e); - await helper.waitForPromise(helper.padIsEmpty); - await helper.waitForPromise(() => helper.commits.length > commitsBefore); + await helper.withFastCommit(async (incorp) => { + helper.padInner$('#innerdocbody').trigger(e); + incorp(); + await helper.waitForPromise(helper.padIsEmpty); + await helper.waitForPromise(() => helper.commits.length > commitsBefore); + }); }; diff --git a/src/tests/frontend/helper/multipleUsers.js b/src/tests/frontend/helper/multipleUsers.js new file mode 100644 index 00000000000..d34676a66b0 --- /dev/null +++ b/src/tests/frontend/helper/multipleUsers.js @@ -0,0 +1,121 @@ +'use strict'; + +helper.multipleUsers = { + _user0: null, + _user1: null, + + // open the same pad on different frames (allows concurrent editions to pad) + async init() { + this._user0 = { + $frame: $('#iframe-container iframe'), + token: getToken(), + // we'll switch between pads, need to store current values of helper.pad* + // to be able to restore those values later + padChrome$: helper.padChrome$, + padOuter$: helper.padOuter$, + padInner$: helper.padInner$, + }; + this._user1 = {}; + // Force generation of a new token. + clearToken(); + // need to perform as the other user, otherwise we'll get the userdup error message + await this.performAsOtherUser(this._createUser1Frame.bind(this)); + }, + + async performAsOtherUser(action) { + startActingLike(this._user1); + await action(); + startActingLike(this._user0); + }, + + close() { + this._user0.$frame.attr('style', ''); // make the default ocopy the full height + this._user1.$frame.remove(); + }, + + async _loadJQueryForUser1Frame() { + const code = await $.get('/static/js/jquery.js'); + + // make sure we don't override existing jquery + const jQueryCode = `if(typeof $ === "undefined") {\n${code}\n}`; + const sendkeysCode = await $.get('/tests/frontend/lib/sendkeys.js'); + const codesToLoad = [jQueryCode, sendkeysCode]; + + this._user1.padChrome$ = getFrameJQuery(codesToLoad, this._user1.$frame); + this._user1.padOuter$ = + getFrameJQuery(codesToLoad, this._user1.padChrome$('iframe[name="ace_outer"]')); + this._user1.padInner$ = + getFrameJQuery(codesToLoad, this._user1.padOuter$('iframe[name="ace_inner"]')); + + // update helper vars now that they are available + helper.padChrome$ = this._user1.padChrome$; + helper.padOuter$ = this._user1.padOuter$; + helper.padInner$ = this._user1.padInner$; + }, + + async _createUser1Frame() { + // create the iframe + const padUrl = this._user0.$frame.attr('src'); + this._user1.$frame = $('`); - $originalPadFrame = $('#iframe-container iframe'); - $otherIframeWithSamePad.insertAfter($originalPadFrame); + // make sure there's a timeout set, otherwise automatic reconnect won't be enabled + helper.padChrome$.window.clientVars.automaticReconnectionTimeout = 2; - // wait for modal to be displayed - helper.waitFor(() => $errorMessageModal.is(':visible'), 50000).done(done); - }); + // open same pad on another iframe, to force userdup error + const $otherIframeWithSamePad = $(``); + $originalPadFrame = $('#iframe-container iframe'); + $otherIframeWithSamePad.insertAfter($originalPadFrame); - this.timeout(60000); + // wait for modal to be displayed + await helper.waitForPromise(() => $errorMessageModal.is(':visible'), 50000); }); - it('displays a count down timer to automatically reconnect', function (done) { + it('displays a count down timer to automatically reconnect', async function () { const $errorMessageModal = helper.padChrome$('#connectivity .userdup'); const $countDownTimer = $errorMessageModal.find('.reconnecttimer'); expect($countDownTimer.is(':visible')).to.be(true); - - done(); }); context('and user clicks on Cancel', function () { @@ -41,31 +37,20 @@ describe('Automatic pad reload on Force Reconnect message', function () { () => helper.padChrome$('#connectivity .userdup').is(':visible') === true); }); - it('does not show Cancel button nor timer anymore', function (done) { + it('does not show Cancel button nor timer anymore', async function () { const $errorMessageModal = helper.padChrome$('#connectivity .userdup'); const $countDownTimer = $errorMessageModal.find('.reconnecttimer'); const $cancelButton = $errorMessageModal.find('#cancelreconnect'); expect($countDownTimer.is(':visible')).to.be(false); expect($cancelButton.is(':visible')).to.be(false); - - done(); }); }); context('and user does not click on Cancel until timer expires', function () { - let padWasReloaded = false; - - beforeEach(async function () { - $originalPadFrame.one('load', () => { - padWasReloaded = true; - }); - }); - - it('reloads the pad', function (done) { - helper.waitFor(() => padWasReloaded, 10000).done(done); - + it('reloads the pad', async function () { this.timeout(10000); + await new Promise((resolve) => $originalPadFrame.one('load', resolve)); }); }); }); diff --git a/src/tests/frontend/travis/remote_runner.js b/src/tests/frontend/travis/remote_runner.js index 13498e4e75d..264d8032c8b 100644 --- a/src/tests/frontend/travis/remote_runner.js +++ b/src/tests/frontend/travis/remote_runner.js @@ -1,194 +1,134 @@ 'use strict'; -const async = require('async'); -const wd = require('wd'); +// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an +// unhandled rejection into an uncaught exception, which does cause Node.js to exit. +process.on('unhandledRejection', (err) => { throw err; }); -const config = { - host: 'ondemand.saucelabs.com', - port: 80, - username: process.env.SAUCE_USER, - accessKey: process.env.SAUCE_ACCESS_KEY, -}; +const async = require('async'); +const swd = require('selenium-webdriver'); +const swdChrome = require('selenium-webdriver/chrome'); +const swdEdge = require('selenium-webdriver/edge'); +const swdFirefox = require('selenium-webdriver/firefox'); const isAdminRunner = process.argv[2] === 'admin'; -let allTestsPassed = true; -// overwrite the default exit code -// in case not all worker can be run (due to saucelabs limits), -// `queue.drain` below will not be called -// and the script would silently exit with error code 0 -process.exitCode = 2; -process.on('exit', (code) => { - if (code === 2) { - console.log('\x1B[31mFAILED\x1B[39m Not all saucelabs runner have been started.'); - } -}); - -const sauceTestWorker = async.queue((testSettings, callback) => { - const browser = wd.promiseChainRemote( - config.host, config.port, config.username, config.accessKey); - const name = [process.env.GIT_HASH].concat(process.env.SAUCE_NAME || []).concat([ - `${testSettings.browserName} ${testSettings.version}, ${testSettings.platform}`, - ]).join(' - '); - testSettings.name = name; - testSettings.public = true; - testSettings.build = process.env.GIT_HASH; - // console.json can be downloaded via saucelabs, - // don't know how to print them into output of the tests - testSettings.extendedDebugging = true; - testSettings.tunnelIdentifier = process.env.TRAVIS_JOB_NUMBER; - - browser.init(testSettings).get('http://localhost:9001/tests/frontend/', () => { - const url = `https://saucelabs.com/jobs/${browser.sessionID}`; - console.log(`Remote sauce test '${name}' started! ${url}`); - - // tear down the test excecution - const stopSauce = (success, timesup) => { - clearInterval(getStatusInterval); - clearTimeout(timeout); - - browser.quit(() => { - if (!success) { - allTestsPassed = false; - } - - // if stopSauce is called via timeout - // (in contrast to via getStatusInterval) than the log of up to the last - // five seconds may not be available here. It's an error anyway, so don't care about it. - printLog(logIndex); - - if (timesup) { - console.log(`[${testSettings.browserName} ${testSettings.platform}` + - `${testSettings.version === '' ? '' : (` ${testSettings.version}`)}]` + - ' \x1B[31mFAILED\x1B[39m allowed test duration exceeded'); - } - console.log(`Remote sauce test '${name}' finished! ${url}`); - - callback(); - }); - }; +const colorSubst = { + red: '\x1B[31m', + yellow: '\x1B[33m', + green: '\x1B[32m', + clear: '\x1B[39m', +}; +const colorRegex = new RegExp(`\\[(${Object.keys(colorSubst).join('|')})\\]`, 'g'); - /** - * timeout if a test hangs or the job exceeds 14.5 minutes - * It's necessary because if travis kills the saucelabs session due to inactivity, - * we don't get any output - * @todo this should be configured in testSettings, see - * https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options#TestConfigurationOptions-Timeouts - */ - const timeout = setTimeout(() => { - stopSauce(false, true); - }, 870000); // travis timeout is 15 minutes, set this to a slightly lower value +const log = (msg, pfx = '') => { + console.log(`${pfx}${msg.replace(colorRegex, (m, p1) => colorSubst[p1])}`); +}; - let knownConsoleText = ''; +const finishedRegex = /FINISHED.*[0-9]+ tests passed, ([0-9]+) tests failed/; + +const sauceTestWorker = async.queue(async ({name, pfx, testSettings}) => { + const chromeOptions = new swdChrome.Options() + .addArguments('use-fake-device-for-media-stream', 'use-fake-ui-for-media-stream'); + const edgeOptions = new swdEdge.Options() + .addArguments('use-fake-device-for-media-stream', 'use-fake-ui-for-media-stream'); + const firefoxOptions = new swdFirefox.Options() + .setPreference('media.navigator.permission.disabled', true) + .setPreference('media.navigator.streams.fake', true); + const driver = await new swd.Builder() + .usingServer('https://ondemand.saucelabs.com/wd/hub') + .withCapabilities(Object.assign({ + 'sauce:options': { + username: process.env.SAUCE_USERNAME, + accessKey: process.env.SAUCE_ACCESS_KEY, + name: [process.env.GIT_HASH].concat(process.env.SAUCE_NAME || [], name).join(' - '), + public: true, + build: process.env.GIT_HASH, + // console.json can be downloaded via saucelabs, + // don't know how to print them into output of the tests + extendedDebugging: true, + tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER, + }, + }, testSettings)) + .setChromeOptions(chromeOptions) + .setEdgeOptions(edgeOptions) + .setFirefoxOptions(firefoxOptions) + .build(); + const url = `https://saucelabs.com/jobs/${(await driver.getSession()).getId()}`; + try { + await driver.get('http://localhost:9001/tests/frontend/'); + log(`Remote sauce test started! ${url}`, pfx); + // @TODO this should be configured in testSettings, see + // https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options#TestConfigurationOptions-Timeouts + const deadline = Date.now() + 14.5 * 60 * 1000; // Slightly less than overall test timeout. // how many characters of the log have been sent to travis let logIndex = 0; - const getStatusInterval = setInterval(() => { - browser.eval("$('#console').text()", (err, consoleText) => { - if (!consoleText || err) { - return; - } - knownConsoleText = consoleText; - - if (knownConsoleText.indexOf('FINISHED') > 0) { - const match = knownConsoleText.match( - /FINISHED.*([0-9]+) tests passed, ([0-9]+) tests failed/); - // finished without failures - if (match[2] && match[2] === '0') { - stopSauce(true); - - // finished but some tests did not return or some tests failed - } else { - stopSauce(false); - } - } else { - // not finished yet - printLog(logIndex); - logIndex = knownConsoleText.length; - } - }); - }, 5000); - - /** - * Replaces color codes in the test runners log, appends - * browser name, platform etc. to every line and prints them. - * - * @param {number} index offset from where to start - */ - const printLog = (index) => { - let testResult = knownConsoleText.substring(index) - .replace(/\[red\]/g, '\x1B[31m').replace(/\[yellow\]/g, '\x1B[33m') - .replace(/\[green\]/g, '\x1B[32m').replace(/\[clear\]/g, '\x1B[39m'); - testResult = testResult.split('\\n').map((line) => `[${testSettings.browserName} ` + - `${testSettings.platform}` + - `${testSettings.version === '' ? '' : (` ${testSettings.version}`)}]` + - `${line}`).join('\n'); - - console.log(testResult); + const remoteFn = (skipChars) => { + const console = document.getElementById('console'); // eslint-disable-line no-undef + if (console == null) return ''; + let text = ''; + for (const n of console.childNodes) { + if (n.nodeType === n.TEXT_NODE) text += n.data; + } + return text.substring(skipChars); }; - }); + while (true) { + const consoleText = await driver.executeScript(remoteFn, logIndex); + (consoleText ? consoleText.split('\n') : []).forEach((line) => log(line, pfx)); + logIndex += consoleText.length; + const [finished, nFailedStr] = consoleText.match(finishedRegex) || []; + if (finished) { + if (nFailedStr !== '0') process.exitCode = 1; + break; + } + if (Date.now() >= deadline) { + log('[red]FAILED[clear] allowed test duration exceeded'); + process.exitCode = 1; + break; + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } finally { + log(`Remote sauce test finished! ${url}`, pfx); + await driver.quit(); + } }, 6); // run 6 tests in parrallel -if (!isAdminRunner) { - // 1) Firefox on Linux - sauceTestWorker.push({ - platform: 'Windows 10', - browserName: 'firefox', - version: '84.0', - }); - - // 2) Chrome on Linux - sauceTestWorker.push({ - platform: 'Windows 7', - browserName: 'chrome', - version: '55.0', - args: ['--use-fake-device-for-media-stream'], - }); - - /* - // 3) Safari on OSX 10.15 - sauceTestWorker.push({ - 'platform' : 'OS X 10.15' - , 'browserName' : 'safari' - , 'version' : '13.1' - }); - */ - - // 4) Safari on OSX 10.14 - sauceTestWorker.push({ - platform: 'OS X 10.15', +Promise.all([ + { + platformName: 'macOS 11.00', browserName: 'safari', - version: '13.1', - }); - // IE 10 doesn't appear to be working anyway - /* - // 4) IE 10 on Win 8 - sauceTestWorker.push({ - 'platform' : 'Windows 8' - , 'browserName' : 'iexplore' - , 'version' : '10.0' - }); - */ - // 5) Edge on Win 10 - sauceTestWorker.push({ - platform: 'Windows 10', - browserName: 'microsoftedge', - version: '83.0', - }); - // 6) Firefox on Win 7 - sauceTestWorker.push({ - platform: 'Windows 7', - browserName: 'firefox', - version: '78.0', - }); -} else { - // 4) Safari on OSX 10.14 - sauceTestWorker.push({ - platform: 'OS X 10.15', - browserName: 'safari', - version: '13.1', - }); -} - -sauceTestWorker.drain(() => { - process.exit(allTestsPassed ? 0 : 1); -}); + browserVersion: 'latest', + }, + ...(isAdminRunner ? [] : [ + { + platformName: 'Windows 10', + browserName: 'firefox', + browserVersion: 'latest', + }, + { + platformName: 'Windows 10', + browserName: 'MicrosoftEdge', + browserVersion: 'latest', + }, + { + platformName: 'Windows 10', + browserName: 'chrome', + browserVersion: 'latest', + }, + { + platformName: 'Windows 7', + browserName: 'chrome', + browserVersion: '55.0', + }, + ]), +].map(async (testSettings) => { + const name = + `${testSettings.browserName} ${testSettings.browserVersion}, ${testSettings.platformName}`; + const pfx = `[${name}] `; + try { + await sauceTestWorker.push({name, pfx, testSettings}); + } catch (err) { + log(`[red]FAILED[clear] ${err.stack || err}`, pfx); + process.exitCode = 1; + } +}));