diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5a9223d9a..10fdaadcc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,7 @@ jobs: - '@percy/cli-build' - '@percy/cli-config' - '@percy/sdk-utils' + - '@percy/webdriver-utils' runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/karma.config.cjs b/karma.config.cjs index 7b9624684..09fdb39d8 100644 --- a/karma.config.cjs +++ b/karma.config.cjs @@ -23,7 +23,9 @@ module.exports = async config => { { pattern: 'test/**/*.test.js', type: 'module', watched: false }, { pattern: 'test/assets/**', watched: false, included: false } ], - + exclude: [ + '**/test/request.test.js', + ], proxies: { // useful when the contents of a fake asset do not matter '/_/': 'localhost/' diff --git a/packages/core/src/api.js b/packages/core/src/api.js index ae7afa16f..848e836f3 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -4,6 +4,8 @@ import { createRequire } from 'module'; import logger from '@percy/logger'; import { normalize } from '@percy/config/utils'; import { getPackageJSON, Server } from './utils.js'; +// TODO Remove below esline disable once we publish webdriver-util +import WebdriverUtils from '@percy/webdriver-utils'; // eslint-disable-line import/no-extraneous-dependencies // need require.resolve until import.meta.resolve can be transpiled export const PERCY_DOM = createRequire(import.meta.url).resolve('@percy/dom'); @@ -115,6 +117,9 @@ export function createPercyServer(percy, port) { .route('post', '/percy/flush', async (req, res) => res.json(200, { success: await percy.flush(req.body).then(() => true) })) + .route('post', '/percy/automateScreenshot', async (req, res) => res.json(200, { + success: await (percy.upload(await new WebdriverUtils(req.body).automateScreenshot())).then(() => true) + })) // stops percy at the end of the current event loop .route('/percy/stop', (req, res) => { setImmediate(() => percy.stop()); diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js index d6ea28a6c..370917064 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -2,6 +2,7 @@ import path from 'path'; import PercyConfig from '@percy/config'; import { logger, setupTest, fs } from './helpers/index.js'; import Percy from '@percy/core'; +import WebdriverUtils from '@percy/webdriver-utils'; // eslint-disable-line import/no-extraneous-dependencies describe('API Server', () => { let percy; @@ -129,6 +130,17 @@ describe('API Server', () => { }); }); + it('has a /automateScreenshot endpoint that calls #upload()', async () => { + spyOn(percy, 'upload').and.resolveTo(); + spyOn(WebdriverUtils.prototype, 'automateScreenshot').and.resolveTo(true); + await percy.start(); + + await expectAsync(request('/percy/automateScreenshot', { + body: { name: 'Snapshot name' }, + method: 'post' + })).toBeResolvedTo({ success: true }); + }); + it('has a /stop endpoint that calls #stop()', async () => { spyOn(percy, 'stop').and.resolveTo(); await percy.start(); diff --git a/packages/sdk-utils/src/request.js b/packages/sdk-utils/src/request.js index 3c9553f0b..19be18213 100644 --- a/packages/sdk-utils/src/request.js +++ b/packages/sdk-utils/src/request.js @@ -2,7 +2,8 @@ import percy from './percy-info.js'; // Helper to send a request to the local CLI API export async function request(path, options = {}) { - let response = await request.fetch(`${percy.address}${path}`, options); + let url = path.startsWith('http') ? path : `${percy.address}${path}`; + let response = await request.fetch(url, options); // maybe parse response body as json if (typeof response.body === 'string' && @@ -48,7 +49,9 @@ if (process.env.__PERCY_BROWSERIFIED__) { } else { // use http.request in node request.fetch = async function fetch(url, options) { - let { default: http } = await import('http'); + let { protocol } = new URL(url); + // rollup throws error for -> await import(protocol === 'https:' ? 'https' : 'http') + let { default: http } = protocol === 'https:' ? await import('https') : await import('http'); return new Promise((resolve, reject) => { http.request(url, options) diff --git a/packages/sdk-utils/test/assets/certs/test.crt b/packages/sdk-utils/test/assets/certs/test.crt new file mode 100644 index 000000000..b8bad337b --- /dev/null +++ b/packages/sdk-utils/test/assets/certs/test.crt @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICpDCCAYwCCQCyX37Mj8zDtDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwHhcNMjEwNDI4MTgzMjA5WhcNMzEwNDI2MTgzMjA5WjAUMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT +3bm7xMop/ZiyU1dRZNOhfd8vhEaIsT2bs0mtZ8CP0bgtMz0qqF9R/bnoOmg0mE3S +C+eZEmsrCkqBUWN673SVkJH/B0umvMajEM0JZZxCE0mmx+MT5X5J60UEKipCxOR5 +i+fNObwoY0sly9mFGwpYZkzRLzxB2JLUwRyqkTvODcIIs2qDxUiVgT6pTFM4noMn +u9ev+OoAgWhPoJ/dzf2w+U/dXDB3oWskbrYoN2deEKHfwcmkh4lFuuU3V2+eAaqG +l2wdZvrjml2HmIeXF/Ae/BOLFIoORLVOrGzBYVU+Hoz+I6P3q9IplLKePzu8pAtI +jPrWQF2PAtRSbcSh2K8FAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAKqkWAgpl3Qa +7hUc2BaGmJcuVNI/QnuyRBjp1TlIprEFpe3JxsNLT0yN052hlTAU2d5yYPF7D+e4 +uY+opg+Z4t/rc/JrQoupihh523MIOLAXLzjXcfb16qQ3v78rMIdDZWuzW/8r+u3m +vD7kFfInYy7jS4o5wNCcU7pFDKsbhd3FeaoueVqihLCBzvnoOuzlIjm/p83BP4gq +8mo0sSZtYXId3SQ7szLu7PN6hqZm/gFrqplzwOGfoidepwEoG/ZZMWZtvoOg0cab +7aS4PHUipjtzFW8CHHtA5IL9JOlKutP1Bv5U+sV/BRClHzcUnL4oJux9zZoxyZn9 +1E9tlgkXsSQ= +-----END CERTIFICATE----- diff --git a/packages/sdk-utils/test/assets/certs/test.key b/packages/sdk-utils/test/assets/certs/test.key new file mode 100644 index 000000000..2a43f582f --- /dev/null +++ b/packages/sdk-utils/test/assets/certs/test.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDT3bm7xMop/Ziy +U1dRZNOhfd8vhEaIsT2bs0mtZ8CP0bgtMz0qqF9R/bnoOmg0mE3SC+eZEmsrCkqB +UWN673SVkJH/B0umvMajEM0JZZxCE0mmx+MT5X5J60UEKipCxOR5i+fNObwoY0sl +y9mFGwpYZkzRLzxB2JLUwRyqkTvODcIIs2qDxUiVgT6pTFM4noMnu9ev+OoAgWhP +oJ/dzf2w+U/dXDB3oWskbrYoN2deEKHfwcmkh4lFuuU3V2+eAaqGl2wdZvrjml2H +mIeXF/Ae/BOLFIoORLVOrGzBYVU+Hoz+I6P3q9IplLKePzu8pAtIjPrWQF2PAtRS +bcSh2K8FAgMBAAECggEBAKmvVteNWEFjS83fM/sLnvgjgQJklb1a/zXZ1XOdujs1 +w6Xn+OBWc+mOJjxZsyYUqZdGU5pkhxK0rlF+ZweKCzzSyiuQo0WKwijOBvm0uP6u +xfle9H71+jynwuIAB1LssPSsWd4jlJBgXkqKRs/1hUahwHp1s3QlSgw+EoCFy3lO +UyUoqmH5MKLNT+IC1psfPTcBgk+O3HMOIrg9aLndhR/AIUYfvFaDdC3paubjPgzq +ds+k8+9A9ICUFZdj6E4Gw/MmscIFOGKdeCTjBnpY77padrvepMCaA3Nzvvr+iYdj +wwgHrETmUyocjrHTIvVOEbM9mq/tc5cVLBFXsswtNm0CgYEA/aPSMlJDbZRCW0Rg +avU/HriO9mjHYvxIzmjol76W9ACVcwGMveJgmlWE6zQTpXFmJKncL8550cGztkWC +EPRs45eG+f9TI7ohICKtH76D2oZotFYvNokxF/XnfOjfuRKb0n1ucpyzWlRiqHpF +FAya+2q+AhgyeFfYeSEFpbbyKfsCgYEA1dZl1oiqRR0Qxpe70eCApfzZlJ4kwQMH +OHKlPXAzLNZ4Wg4xIEshXS/xRnyyL+KlSpRk3nPgNrI6HsJ/aQ+bcwCW/S7Oc01I +ZU1+VHPQL5XS8OlmS9UHp8vdm82I8BytealHIxTzTsT4aBLjY6abjI0FVpoMziOH +7MA6Ln76Ov8CgYBRAbhJUBKu9bH3ui/dGTSumB04v6AmkhKiscjPZhSKG4GfuHf9 +0UYvJG8OO5Smuz/3J7TmI9iuUGIYLbzrs1Tvn16Bi7U+7NxVih2mzM8JxPG93uS3 +Uzu1vljPgQSq9DGGGX9j5X42tErKKjrTu27oK2BCBP5hhxThItXN5k8TbwKBgGRT +ftw0qo5aoLBMKFbD2hgGlZ7gw6W64fxd7aDxr1DuHvFBj1LzbOfnwm+ruX41/A8N +qHWmMB/5ZsNfxZ9pLym5sR2AhGQcckb1ILxGyfpJdPqKxu/1Nu5G++ZJfGILUmiu +Py36el0OlO1fT0hFtt0unL6Q8EkW6oLtfV6rPIPJAoGAKf8t4UzlfEXf71RrX94E +y5bpi1yhxRHgac2fnCwa/u+PYDLKXviWlluJgzB8aoEHz2em2QOEfaFzfa1JW8R+ +eaEAuQCpbXEA8eVT82V76uT5RBKPNCEK7fSFf1a1ZUO442AEwtHwOwIBQqzA4XvZ +KF2JXsWy1k9/9UQf6lo3CHk= +-----END PRIVATE KEY----- diff --git a/packages/sdk-utils/test/request.test.js b/packages/sdk-utils/test/request.test.js new file mode 100644 index 000000000..f5adda889 --- /dev/null +++ b/packages/sdk-utils/test/request.test.js @@ -0,0 +1,104 @@ +import utils from '@percy/sdk-utils'; +import https from 'https'; +import http from 'http'; +import fs from 'fs'; +import path from 'path'; + +const ssl = { + cert: fs.readFileSync(path.join(__dirname, 'assets', 'certs', 'test.crt')), + key: fs.readFileSync(path.join(__dirname, 'assets', 'certs', 'test.key')) +}; + +// Returns the port number of a URL object. Defaults to port 443 for https +// protocols or port 80 otherwise. +function port(options) { + if (options.port) return options.port; + return options.protocol === 'https:' ? 443 : 80; +} + +// Returns a string representation of a URL-like object +function href(options) { + let { protocol, hostname, path, pathname, search, hash } = options; + return `${protocol}//${hostname}:${port(options)}` + + (path || `${pathname || ''}${search || ''}${hash || ''}`); +}; + +function createTestServer({ type = 'http', ...options } = {}, handler) { + let { createServer } = type === 'http' ? http : https; + let connections = new Set(); + let received = []; + + let url = new URL(href({ + protocol: `${type}:`, + hostname: 'localhost', + port: options.port + })); + + let server = createServer(ssl, (req, res) => { + req.on('data', chunk => { + req.body = (req.body || '') + chunk; + }).on('end', () => { + received.push(req); + if (handler) return handler(req, res); + let [status = 200, body = 'test'] = ( + options.routes?.[req.url]?.(req, res) ?? []); + if (!res.headersSent) res.writeHead(status).end(body); + }); + }); + + server.on('connection', socket => { + connections.add(socket.on('close', () => { + connections.delete(socket); + })); + }); + + return { + server, + received, + port: port(url), + address: url.href, + + reply: (url, handler) => { + (options.routes ||= {})[url] = handler; + }, + + async start() { + return new Promise((resolve, reject) => { + server.listen(this.port) + .on('listening', () => resolve(this)) + .on('error', reject); + }); + }, + + async close() { + connections.forEach(s => s.destroy()); + await new Promise(r => server.close(r)); + } + }; +} + +describe('Utils Requests', () => { + let server; + + // Adding below env variables to support self signed certs + beforeAll(() => { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + }); + + afterAll(() => { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + }); + + beforeEach(async () => { + server = await createTestServer({ type: 'https', port: 8080 }).start(); + }); + + afterEach(async () => { + await server?.close(); + }); + + it('returns the successful response body', async () => { + let res = await utils.request(server.address); + expect(res.body).toBe('test'); + }); +}); diff --git a/packages/webdriver-utils/package-lock.json b/packages/webdriver-utils/package-lock.json new file mode 100644 index 000000000..9a4b88f71 --- /dev/null +++ b/packages/webdriver-utils/package-lock.json @@ -0,0 +1,148 @@ +{ + "name": "@percy/webdriver-utils", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@percy/sdk-utils": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.22.0.tgz", + "integrity": "sha512-H2af9lRZy26XuAl9IraeZ7qcXVkowamkdawY35RA28DIBoZKAaitVjlCgQG5fPg4hMiUgLFMuMRf3IXWm5Jh3g==" + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" + }, + "fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "requires": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + } + }, + "formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "requires": { + "fetch-blob": "^3.1.2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" + }, + "node-fetch": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.1.tgz", + "integrity": "sha512-cRVc/kyto/7E5shrWca1Wsea4y6tL9iYJE5FBCius3JQfb/4P4I295PfhgbJQBLTx6lATE4z+wK0rPM4VS2uow==", + "requires": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, + "tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "requires": { + "rimraf": "^3.0.0" + } + }, + "web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + } +} diff --git a/packages/webdriver-utils/package.json b/packages/webdriver-utils/package.json new file mode 100644 index 000000000..0d65d4ed8 --- /dev/null +++ b/packages/webdriver-utils/package.json @@ -0,0 +1,30 @@ +{ + "name": "@percy/webdriver-utils", + "version": "1.22.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/percy/cli", + "directory": "packages/webdriver-utils" + }, + "engines": { + "node": ">=14" + }, + "files": [ + "dist" + ], + "main": "./dist/index.js", + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "build": "node ../../scripts/build", + "lint": "eslint --ignore-path ../../.gitignore .", + "test": "node ../../scripts/test", + "test:coverage": "yarn test --coverage" + }, + "dependencies": { + "@percy/sdk-utils": "1.22.0" + } +} diff --git a/packages/webdriver-utils/src/driver.js b/packages/webdriver-utils/src/driver.js new file mode 100644 index 000000000..65cf266a8 --- /dev/null +++ b/packages/webdriver-utils/src/driver.js @@ -0,0 +1,51 @@ +import utils from '@percy/sdk-utils'; +import Cache from './util/cache.js'; +const { request } = utils; + +export default class Driver { + constructor(sessionId, executorUrl) { + this.sessionId = sessionId; + this.executorUrl = executorUrl.includes('@') ? `https://${executorUrl.split('@')[1]}` : executorUrl; + } + + async getCapabilites() { + return await Cache.withCache(Cache.caps, this.sessionId, async () => { + const baseUrl = `${this.executorUrl}/session/${this.sessionId}`; + const caps = JSON.parse((await request(baseUrl)).body); + return caps.value; + }); + } + + async getWindowSize() { + const baseUrl = `${this.executorUrl}/session/${this.sessionId}/window/current/size`; + const windowSize = JSON.parse((await request(baseUrl)).body); + return windowSize; + } + + // command => {script: "", args: []} + async executeScript(command) { + if ((!command.constructor === Object) || + !(Object.keys(command).length === 2 && + Object.keys(command).includes('script') && + Object.keys(command).includes('args')) + ) { + throw new Error('Please pass command as {script: "", args: []}'); + } + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + body: JSON.stringify(command) + }; + const baseUrl = `${this.executorUrl}/session/${this.sessionId}/execute/sync`; + const response = JSON.parse((await request(baseUrl, options)).body); + return response; + } + + async takeScreenshot() { + const baseUrl = `${this.executorUrl}/session/${this.sessionId}/screenshot`; + const screenShot = JSON.parse((await request(baseUrl)).body); + return screenShot.value; + } +} diff --git a/packages/webdriver-utils/src/index.js b/packages/webdriver-utils/src/index.js new file mode 100644 index 000000000..35c3ed8b8 --- /dev/null +++ b/packages/webdriver-utils/src/index.js @@ -0,0 +1,20 @@ +import ProviderResolver from './providers/providerResolver.js'; +import utils from '@percy/sdk-utils'; + +export default class WebdriverUtils { + log = utils.logger('webdriver-utils:main'); + constructor({ sessionId, commandExecutorUrl, capabilities, sessionCapabilites, snapshotName }) { + this.sessionId = sessionId; + this.commandExecutorUrl = commandExecutorUrl; + this.capabilities = capabilities; + this.sessionCapabilites = sessionCapabilites; + this.snapshotName = snapshotName; + } + + async automateScreenshot() { + this.log.info('Starting automate screenshot'); + const automate = ProviderResolver.resolve(this.sessionId, this.commandExecutorUrl, this.capabilities, this.sessionCapabilites); + await automate.createDriver(); + return await automate.screenshot(this.snapshotName); + } +} diff --git a/packages/webdriver-utils/src/metadata/desktopMetaData.js b/packages/webdriver-utils/src/metadata/desktopMetaData.js new file mode 100644 index 000000000..0977060b4 --- /dev/null +++ b/packages/webdriver-utils/src/metadata/desktopMetaData.js @@ -0,0 +1,43 @@ +export default class DesktopMetaData { + constructor(driver, opts) { + this.driver = driver; + this.capabilities = opts; + } + + browserName() { + return this.capabilities.browserName.toLowerCase(); + } + + osName() { + let osName = this.capabilities.osVersion; + if (osName) return osName.toLowerCase(); + + osName = this.capabilities.platform; + return osName; + } + + // desktop will show this as browser version + osVersion() { + return this.capabilities.version.split('.')[0]; + } + + deviceName() { + return this.browserName() + '_' + this.osVersion() + '_' + this.osName(); + } + + orientation() { + return 'landscape'; + } + + async windowSize() { + const dpr = await this.devicePixelRatio(); + const data = await this.driver.getWindowSize(); + const width = parseInt(data.value.width * dpr), height = parseInt(data.value.height * dpr); + return { width, height }; + } + + async devicePixelRatio() { + const devicePixelRatio = await this.driver.executeScript({ script: 'return window.devicePixelRatio;', args: [] }); + return devicePixelRatio.value; + } +} diff --git a/packages/webdriver-utils/src/metadata/metaDataResolver.js b/packages/webdriver-utils/src/metadata/metaDataResolver.js new file mode 100644 index 000000000..c7f79177b --- /dev/null +++ b/packages/webdriver-utils/src/metadata/metaDataResolver.js @@ -0,0 +1,15 @@ +import DesktopMetaData from './desktopMetaData.js'; +import MobileMetaData from './mobileMetaData.js'; + +export default class MetaDataResolver { + static resolve(driver, capabilities, opts) { + if (!driver) throw new Error('Please pass a Driver object'); + + const platform = opts.platformName || opts.platform; + if (['ios', 'android'].includes(platform.toLowerCase())) { + return new MobileMetaData(driver, capabilities); + } else { + return new DesktopMetaData(driver, capabilities); + } + } +} diff --git a/packages/webdriver-utils/src/metadata/mobileMetaData.js b/packages/webdriver-utils/src/metadata/mobileMetaData.js new file mode 100644 index 000000000..fa839251b --- /dev/null +++ b/packages/webdriver-utils/src/metadata/mobileMetaData.js @@ -0,0 +1,42 @@ +export default class MobileMetaData { + constructor(driver, opts) { + this.driver = driver; + this.capabilities = opts; + } + + browserName() { + return this.capabilities.browserName.toLowerCase(); + } + + osName() { + let osName = this.capabilities.os.toLowerCase(); + if (osName === 'mac' && this.browserName() === 'iphone') { + osName = 'ios'; + } + return osName; + } + + osVersion() { + return this.capabilities.osVersion.split('.')[0]; + } + + deviceName() { + return this.capabilities.deviceName.split('-')[0]; + } + + orientation() { + return this.capabilities.orientation; + } + + async windowSize() { + const dpr = await this.devicePixelRatio(); + const data = await this.driver.getWindowSize(); + const width = parseInt(data.value.width * dpr), height = parseInt(data.value.height * dpr); + return { width, height }; + } + + async devicePixelRatio() { + const devicePixelRatio = await this.driver.executeScript({ script: 'return window.devicePixelRatio;', args: [] }); + return devicePixelRatio.value; + } +} diff --git a/packages/webdriver-utils/src/providers/automateProvider.js b/packages/webdriver-utils/src/providers/automateProvider.js new file mode 100644 index 000000000..0369ac592 --- /dev/null +++ b/packages/webdriver-utils/src/providers/automateProvider.js @@ -0,0 +1,24 @@ +import GenericProvider from './genericProvider.js'; +import Cache from '../util/cache.js'; + +export default class AutomateProvider extends GenericProvider { + static supports(commandExecutorUrl) { + return commandExecutorUrl.includes(process.env.AA_DOMAIN || 'browserstack'); + } + + async browserstackExecutor(action, args) { + if (!this.driver) throw new Error('Driver is null, please initialize driver with createDriver().'); + let options = args ? { action, arguments: args } : { action }; + let res = await this.driver.executeScript({ script: `browserstack_executor: ${JSON.stringify(options)}`, args: [] }); + return res; + } + + async setDebugUrl() { + if (!this.driver) throw new Error('Driver is null, please initialize driver with createDriver().'); + this.debugUrl = await Cache.withCache(Cache.bstackSessionDetails, this.driver.sessionId, + async () => { + const sessionDetails = await this.browserstackExecutor('getSessionDetails'); + return JSON.parse(sessionDetails.value).browser_url; + }); + } +} diff --git a/packages/webdriver-utils/src/providers/genericProvider.js b/packages/webdriver-utils/src/providers/genericProvider.js new file mode 100644 index 000000000..da0c5204c --- /dev/null +++ b/packages/webdriver-utils/src/providers/genericProvider.js @@ -0,0 +1,95 @@ +import utils from '@percy/sdk-utils'; + +import MetaDataResolver from '../metadata/metaDataResolver.js'; +import Tile from '../util/tile.js'; +import Driver from '../driver.js'; + +const log = utils.logger('webdriver-utils:genericProvider'); +// TODO: Need to pass parameter from sdk and catch in cli +const CLIENT_INFO = 'local-poc-poa'; +const ENV_INFO = 'staging-poc-poa'; + +export default class GenericProvider { + constructor( + sessionId, + commandExecutorUrl, + capabilities, + sessionCapabilites + ) { + this.sessionId = sessionId; + this.commandExecutorUrl = commandExecutorUrl; + this.capabilities = capabilities; + this.sessionCapabilites = sessionCapabilites; + this.driver = null; + this.metaData = null; + this.debugUrl = null; + } + + async createDriver() { + this.driver = new Driver(this.sessionId, this.commandExecutorUrl); + const caps = await this.driver.getCapabilites(); + this.metaData = await MetaDataResolver.resolve(this.driver, caps, this.capabilities); + } + + static supports(_commandExecutorUrl) { + return true; + } + + async screenshot(name) { + let fullscreen = false; + + const tag = await this.getTag(); + const tiles = await this.getTiles(fullscreen); + await this.setDebugUrl(); + + log.debug(`${name} : Tag ${JSON.stringify(tag)}`); + log.debug(`${name} : Tiles ${JSON.stringify(tiles)}`); + log.debug(`${name} : Debug url ${this.debugUrl}`); + return { + name, + tag, + tiles, + // TODO: Fetch this one for bs automate, check appium sdk + externalDebugUrl: this.debugUrl, + environmentInfo: ENV_INFO, + clientInfo: CLIENT_INFO + }; + } + + async getTiles(fullscreen) { + if (!this.driver) throw new Error('Driver is null, please initialize driver with createDriver().'); + const base64content = await this.driver.takeScreenshot(); + return [ + new Tile({ + content: base64content, + // TODO: Need to add method to fetch these attr + statusBarHeight: 0, + navBarHeight: 0, + headerHeight: 0, + footerHeight: 0, + fullscreen + }) + ]; + } + + async getTag() { + if (!this.driver) throw new Error('Driver is null, please initialize driver with createDriver().'); + const { width, height } = await this.metaData.windowSize(); + const orientation = this.metaData.orientation(); + return { + name: this.metaData.deviceName(), + osName: this.metaData.osName(), + osVersion: this.metaData.osVersion(), + width, + height, + orientation: orientation, + browserName: this.metaData.browserName(), + // TODO + browserVersion: 'unknown' + }; + } + + async setDebugUrl() { + this.debugUrl = 'https://localhost/v1'; + } +} diff --git a/packages/webdriver-utils/src/providers/providerResolver.js b/packages/webdriver-utils/src/providers/providerResolver.js new file mode 100644 index 000000000..d0d3c6a92 --- /dev/null +++ b/packages/webdriver-utils/src/providers/providerResolver.js @@ -0,0 +1,10 @@ +import GenericProvider from './genericProvider.js'; +import AutomateProvider from './automateProvider.js'; + +export default class ProviderResolver { + static resolve(sessionId, commandExecutorUrl, capabilities, sessionCapabilities) { + // We can safely do [0] because GenericProvider is catch all + const Klass = [AutomateProvider, GenericProvider].filter(x => x.supports(commandExecutorUrl))[0]; + return new Klass(sessionId, commandExecutorUrl, capabilities, sessionCapabilities); + } +} diff --git a/packages/webdriver-utils/src/util/cache.js b/packages/webdriver-utils/src/util/cache.js new file mode 100644 index 000000000..b5cc99ea8 --- /dev/null +++ b/packages/webdriver-utils/src/util/cache.js @@ -0,0 +1,65 @@ + +import validations from './validations.js'; +const { Undefined } = validations; + +export default class Cache { + static cache = {}; + + // Common stores, const, dont modify outside + static caps = 'caps'; + static bstackSessionDetails = 'bstackSessionDetails'; + static systemBars = 'systemBars'; + + // maintainance + static lastTime = Date.now(); + static timeout = 5 * 60 * 1000; + + static async withCache(store, key, func, cacheExceptions = false) { + this.maintain(); + if (Undefined(this.cache[store])) this.cache[store] = {}; + + store = this.cache[store]; + if (store[key]) { + if (store[key].success) { + return store[key].val; + } else { + throw store[key].val; + } + } + + const obj = { success: false, val: null, time: Date.now() }; + try { + obj.val = await func(); + obj.success = true; + } catch (e) { + obj.val = e; + } + + // We seem to have correct coverage for both flows but nyc is marking it as missing + // branch coverage anyway + /* istanbul ignore next */ + if (obj.success || cacheExceptions) { + store[key] = obj; + } + + if (!obj.success) throw obj.val; + return obj.val; + } + + static maintain() { + if (this.lastTime + this.timeout > Date.now()) return; + + for (const [, store] of Object.entries(this.cache)) { + for (const [key, item] of Object.entries(store)) { + if (item.time + this.timeout < Date.now()) { + delete store[key]; + } + } + } + this.lastTime = Date.now(); + } + + static reset() { + this.cache = {}; + } +} diff --git a/packages/webdriver-utils/src/util/tile.js b/packages/webdriver-utils/src/util/tile.js new file mode 100644 index 000000000..cbfa341fb --- /dev/null +++ b/packages/webdriver-utils/src/util/tile.js @@ -0,0 +1,19 @@ +export default class Tile { + constructor({ + content, + statusBarHeight, + navBarHeight, + headerHeight, + footerHeight, + fullscreen, + sha + }) { + this.content = content; + this.statusBarHeight = statusBarHeight; + this.navBarHeight = navBarHeight; + this.headerHeight = headerHeight; + this.footerHeight = footerHeight; + this.fullscreen = fullscreen; + this.sha = sha; + } +} diff --git a/packages/webdriver-utils/src/util/validations.js b/packages/webdriver-utils/src/util/validations.js new file mode 100644 index 000000000..dd1555e2e --- /dev/null +++ b/packages/webdriver-utils/src/util/validations.js @@ -0,0 +1,10 @@ +function Undefined(obj) { + return obj === undefined; +} + +export { + Undefined +}; + +// export the namespace by default +export * as default from './validations.js'; diff --git a/packages/webdriver-utils/test/.eslintrc b/packages/webdriver-utils/test/.eslintrc new file mode 100644 index 000000000..e9b386cb0 --- /dev/null +++ b/packages/webdriver-utils/test/.eslintrc @@ -0,0 +1,4 @@ +env: + jasmine: true +rules: + import/no-extraneous-dependencies: off diff --git a/packages/webdriver-utils/test/driver.test.js b/packages/webdriver-utils/test/driver.test.js new file mode 100644 index 000000000..63f54987b --- /dev/null +++ b/packages/webdriver-utils/test/driver.test.js @@ -0,0 +1,81 @@ +import Driver from '../src/driver.js'; +import utils from '@percy/sdk-utils'; + +describe('Driver', () => { + let requestSpy; + let mockResponseObject = { + body: '{"value": "mockVal"}', + status: 200, + headers: { 'content-type': 'application/text' } + }; + let sessionId = '123'; + let executorUrl = 'http://localhost/wd/hub'; + let driver; + + beforeEach(() => { + requestSpy = spyOn(utils.request, 'fetch').and.returnValue( + Promise.resolve(mockResponseObject) + ); + driver = new Driver(sessionId, executorUrl); + }); + + describe('constructor', () => { + it('sanitizes embedded url', () => { + let newDriver = new Driver('123', 'https://test:123@localhost/wd/hub'); + expect(newDriver.executorUrl).toBe('https://localhost/wd/hub'); + }); + }); + + describe('getCapabilities', () => { + it('calls requests', async () => { + let res = await driver.getCapabilites(); + expect(requestSpy).toHaveBeenCalledOnceWith(`${executorUrl}/session/${sessionId}`, Object({})); + expect(res).toBe('mockVal'); + }); + }); + + describe('getWindowsize', () => { + it('calls requests', async () => { + let res = await driver.getWindowSize(); + expect(requestSpy).toHaveBeenCalledOnceWith( + `${executorUrl}/session/${sessionId}/window/current/size`, + Object({})); + expect(res).toEqual({ value: 'mockVal' }); + }); + }); + + describe('executeScript', () => { + it('calls requests', async () => { + let command = { script: 'abc', args: [] }; + let res = await driver.executeScript(command); + expect(requestSpy).toHaveBeenCalledOnceWith( + `${executorUrl}/session/${sessionId}/execute/sync`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + body: JSON.stringify(command) + } + ); + expect(res).toEqual({ value: 'mockVal' }); + }); + + it('throws error', async () => { + let command = 'abc'; + await expectAsync(driver.executeScript(command)).toBeRejectedWith( + new Error('Please pass command as {script: "", args: []}') + ); + }); + }); + + describe('takeScreenshot', () => { + it('calls requests', async () => { + let res = await driver.takeScreenshot(); + expect(requestSpy).toHaveBeenCalledOnceWith( + `${executorUrl}/session/${sessionId}/screenshot`, + Object({})); + expect(res).toEqual('mockVal'); + }); + }); +}); diff --git a/packages/webdriver-utils/test/metadata/desktopMetaData.test.js b/packages/webdriver-utils/test/metadata/desktopMetaData.test.js new file mode 100644 index 000000000..6cbd24be1 --- /dev/null +++ b/packages/webdriver-utils/test/metadata/desktopMetaData.test.js @@ -0,0 +1,89 @@ +import DesktopMetaData from '../../src/metadata/desktopMetaData.js'; +import Driver from '../../src/driver.js'; + +describe('DesktopMetaData', () => { + let getWindowSizeSpy; + let executeScriptSpy; + let desktopMetaData; + + beforeEach(() => { + getWindowSizeSpy = spyOn(Driver.prototype, 'getWindowSize'); + executeScriptSpy = spyOn(Driver.prototype, 'executeScript'); + desktopMetaData = new DesktopMetaData(new Driver('123', 'http:executorUrl'), { + browserName: 'Chrome', + version: '111.0', + platform: 'win' + }); + }); + + describe('browserName', () => { + it('calculates browserName', () => { + expect(desktopMetaData.browserName()).toEqual('chrome'); + }); + }); + + describe('osName', () => { + it('calculates osName', () => { + expect(desktopMetaData.osName()).toEqual('win'); + }); + + it('calculates alternate osName', () => { + desktopMetaData = new DesktopMetaData(new Driver('123', 'http:executorUrl'), { + browserName: 'Chrome', + version: '111.0', + osVersion: '10' + }); + expect(desktopMetaData.osName()).toEqual('10'); + }); + }); + + describe('osVersin', () => { + it('calculates OsVersion', () => { + expect(desktopMetaData.osVersion()).toEqual('111'); + }); + }); + + describe('deviceName', () => { + it('calculates deviceName', () => { + expect(desktopMetaData.deviceName()).toEqual('chrome_111_win'); + }); + }); + + describe('orientation', () => { + it('calculates browserName', () => { + expect(desktopMetaData.orientation()).toEqual('landscape'); + }); + }); + + describe('windowSize', () => { + let devicePixelRatioSpy; + let windowSize; + + beforeEach(() => { + devicePixelRatioSpy = spyOn(DesktopMetaData.prototype, 'devicePixelRatio').and.returnValue(Promise.resolve(2)); + getWindowSizeSpy.and.returnValue(Promise.resolve({ value: { width: 1000, height: 500 } })); + }); + + it('calculates windowSize', async () => { + windowSize = await desktopMetaData.windowSize(); + expect(devicePixelRatioSpy).toHaveBeenCalledTimes(1); + expect(getWindowSizeSpy).toHaveBeenCalledTimes(1); + expect(windowSize).toEqual({ width: 2000, height: 1000 }); + }); + }); + + describe('devicePixelRatio', () => { + let devicePixelRatio; + + beforeEach(() => { + executeScriptSpy.and.returnValue(Promise.resolve({ value: 2 })); + }); + + it('calculates devicePixelRatio', async () => { + devicePixelRatio = await desktopMetaData.devicePixelRatio(); + expect(devicePixelRatio).toEqual(2); + expect(executeScriptSpy) + .toHaveBeenCalledWith({ script: 'return window.devicePixelRatio;', args: [] }); + }); + }); +}); diff --git a/packages/webdriver-utils/test/metadata/metaDataResolver.test.js b/packages/webdriver-utils/test/metadata/metaDataResolver.test.js new file mode 100644 index 000000000..040792439 --- /dev/null +++ b/packages/webdriver-utils/test/metadata/metaDataResolver.test.js @@ -0,0 +1,36 @@ +import MetaDataResolver from '../../src/metadata/metaDataResolver.js'; +import Driver from '../../src/driver.js'; +import DesktopMetaData from '../../src/metadata/desktopMetaData.js'; +import MobileMetaData from '../../src/metadata/mobileMetaData.js'; + +describe('MetaDataResolver', () => { + describe('resolve', () => { + let driver; + let capabilities; + let metadata; + + beforeEach(() => { + driver = new Driver('123', 'http:executorUrl'); + capabilities = {}; + }); + + it('throws error is driver is not sent', () => { + expect(() => MetaDataResolver.resolve(null, capabilities, { platform: 'ios' })) + .toThrowError('Please pass a Driver object'); + }); + + it('resolves MobileMetaData correctly', () => { + metadata = MetaDataResolver.resolve(driver, capabilities, { platform: 'ios' }); + expect(metadata).toBeInstanceOf(MobileMetaData); + expect(metadata.driver).toEqual(driver); + expect(metadata.capabilities).toEqual({}); + }); + + it('resolves DesktopMetaData correctly', () => { + metadata = MetaDataResolver.resolve(driver, capabilities, { platform: 'win' }); + expect(metadata).toBeInstanceOf(DesktopMetaData); + expect(metadata.driver).toEqual(driver); + expect(metadata.capabilities).toEqual({}); + }); + }); +}); diff --git a/packages/webdriver-utils/test/metadata/mobileMetaData.test.js b/packages/webdriver-utils/test/metadata/mobileMetaData.test.js new file mode 100644 index 000000000..38bb0f2f3 --- /dev/null +++ b/packages/webdriver-utils/test/metadata/mobileMetaData.test.js @@ -0,0 +1,97 @@ +import MobileMetaData from '../../src/metadata/mobileMetaData.js'; +import Driver from '../../src/driver.js'; + +describe('MobileMetaData', () => { + let getWindowSizeSpy; + let executeScriptSpy; + let mobileMetaData; + + beforeEach(() => { + getWindowSizeSpy = spyOn(Driver.prototype, 'getWindowSize'); + executeScriptSpy = spyOn(Driver.prototype, 'executeScript'); + mobileMetaData = new MobileMetaData(new Driver('123', 'http:executorUrl'), { + osVersion: '12.0', + browserName: 'Chrome', + os: 'android', + version: '111.0', + orientation: 'landscape', + deviceName: 'SamsungS21-XYZ', + platform: 'win' + }); + }); + + describe('browserName', () => { + it('calculates browserName', () => { + expect(mobileMetaData.browserName()).toEqual('chrome'); + }); + }); + + describe('osName', () => { + it('calculates osName', () => { + expect(mobileMetaData.osName()).toEqual('android'); + }); + + it('calculates alternate osName', () => { + mobileMetaData = new MobileMetaData(new Driver('123', 'http:executorUrl'), { + osVersion: '12.0', + browserName: 'iphone', + os: 'mac', + version: '111.0', + orientation: 'landscape', + deviceName: 'SamsungS21-XYZ', + platform: 'win' + }); + expect(mobileMetaData.osName()).toEqual('ios'); + }); + }); + + describe('osVersin', () => { + it('calculates OsVersion', () => { + expect(mobileMetaData.osVersion()).toEqual('12'); + }); + }); + + describe('deviceName', () => { + it('calculates deviceName', () => { + expect(mobileMetaData.deviceName()).toEqual('SamsungS21'); + }); + }); + + describe('orientation', () => { + it('calculates browserName', () => { + expect(mobileMetaData.orientation()).toEqual('landscape'); + }); + }); + + describe('windowSize', () => { + let devicePixelRatioSpy; + let windowSize; + + beforeEach(() => { + devicePixelRatioSpy = spyOn(MobileMetaData.prototype, 'devicePixelRatio').and.returnValue(Promise.resolve(2)); + getWindowSizeSpy.and.returnValue(Promise.resolve({ value: { width: 1000, height: 500 } })); + }); + + it('calculates windowSize', async () => { + windowSize = await mobileMetaData.windowSize(); + expect(devicePixelRatioSpy).toHaveBeenCalledTimes(1); + expect(getWindowSizeSpy).toHaveBeenCalledTimes(1); + expect(windowSize).toEqual({ width: 2000, height: 1000 }); + }); + }); + + describe('devicePixelRatio', () => { + let devicePixelRatio; + + beforeEach(() => { + executeScriptSpy.and.returnValue(Promise.resolve({ value: 2 })); + }); + + it('calculates devicePixelRatio', async () => { + devicePixelRatio = await mobileMetaData.devicePixelRatio(); + expect(devicePixelRatio).toEqual(2); + expect(executeScriptSpy) + .toHaveBeenCalledWith({ script: 'return window.devicePixelRatio;', args: [] }); + }); + }); +}); diff --git a/packages/webdriver-utils/test/providers/automateProvider.test.js b/packages/webdriver-utils/test/providers/automateProvider.test.js new file mode 100644 index 000000000..8dbb22e8b --- /dev/null +++ b/packages/webdriver-utils/test/providers/automateProvider.test.js @@ -0,0 +1,69 @@ +import Driver from '../../src/driver.js'; +import AutomateProvider from '../../src/providers/automateProvider.js'; + +describe('AutomateProvider', () => { + describe('browserstackExecutor', () => { + let executeScriptSpy; + + beforeEach(async () => { + executeScriptSpy = spyOn(Driver.prototype, 'executeScript'); + spyOn(Driver.prototype, 'getCapabilites'); + }); + + it('throws Error when called without initializing driver', async () => { + let automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + await expectAsync(automateProvider.browserstackExecutor('getSessionDetails')) + .toBeRejectedWithError('Driver is null, please initialize driver with createDriver().'); + }); + + it('calls browserstackExecutor with correct arguemnts for actions only', async () => { + let automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + await automateProvider.createDriver(); + await automateProvider.browserstackExecutor('getSessionDetails'); + expect(executeScriptSpy) + .toHaveBeenCalledWith({ script: 'browserstack_executor: {"action":"getSessionDetails"}', args: [] }); + }); + + it('calls browserstackExecutor with correct arguemnts for actions + args', async () => { + let automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + await automateProvider.createDriver(); + await automateProvider.browserstackExecutor('getSessionDetails', 'new'); + expect(executeScriptSpy) + .toHaveBeenCalledWith({ script: 'browserstack_executor: {"action":"getSessionDetails","arguments":"new"}', args: [] }); + }); + }); + + describe('setDebugUrl', () => { + let browserstackExecutorSpy; + + beforeEach(async () => { + spyOn(Driver.prototype, 'getCapabilites'); + browserstackExecutorSpy = spyOn(AutomateProvider.prototype, 'browserstackExecutor') + .and.returnValue(Promise.resolve({ value: '{"browser_url": "http:localhost"}' })); + }); + + it('calls browserstackExecutor getSessionDetails', async () => { + let automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + await automateProvider.createDriver(); + await automateProvider.setDebugUrl(); + expect(browserstackExecutorSpy).toHaveBeenCalledWith('getSessionDetails'); + expect(automateProvider.debugUrl).toEqual('http:localhost'); + }); + + it('throws error if driver is not initialized', async () => { + let automateProvider = new AutomateProvider('1234', 'https://localhost/command-executor', { platform: 'win' }, {}); + await expectAsync(automateProvider.setDebugUrl()) + .toBeRejectedWithError('Driver is null, please initialize driver with createDriver().'); + }); + }); + + describe('supports', () => { + it('returns true for browserstack automate', () => { + expect(AutomateProvider.supports('http:browserstack')).toEqual(true); + }); + + it('returns false for outside automate', () => { + expect(AutomateProvider.supports('http:outside')).toEqual(false); + }); + }); +}); diff --git a/packages/webdriver-utils/test/providers/genericProvider.test.js b/packages/webdriver-utils/test/providers/genericProvider.test.js new file mode 100644 index 000000000..7ef63defd --- /dev/null +++ b/packages/webdriver-utils/test/providers/genericProvider.test.js @@ -0,0 +1,114 @@ +import GenericProvider from '../../src/providers/genericProvider.js'; +import Driver from '../../src/driver.js'; +import MetaDataResolver from '../../src/metadata/metaDataResolver.js'; +import DesktopMetaData from '../../src/metadata/desktopMetaData.js'; + +describe('GenericProvider', () => { + let genericProvider; + let capabilitiesSpy; + + beforeEach(() => { + capabilitiesSpy = spyOn(Driver.prototype, 'getCapabilites') + .and.returnValue(Promise.resolve({ browserName: 'Chrome' })); + }); + + describe('createDriver', () => { + let metaDataResolverSpy; + let expectedDriver; + + beforeEach(() => { + metaDataResolverSpy = spyOn(MetaDataResolver, 'resolve'); + expectedDriver = new Driver('123', 'http:executorUrl'); + }); + + it('creates driver', async () => { + genericProvider = new GenericProvider('123', 'http:executorUrl', {}, {}); + await genericProvider.createDriver(); + expect(genericProvider.driver).toEqual(expectedDriver); + expect(capabilitiesSpy).toHaveBeenCalledTimes(1); + expect(metaDataResolverSpy).toHaveBeenCalledWith(expectedDriver, { browserName: 'Chrome' }, {}); + }); + }); + + describe('getTiles', () => { + beforeEach(() => { + spyOn(Driver.prototype, 'takeScreenshot').and.returnValue(Promise.resolve('123b=')); + }); + + it('creates tiles from screenshot', async () => { + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + genericProvider.createDriver(); + const tiles = await genericProvider.getTiles(false); + expect(tiles.length).toEqual(1); + }); + + it('throws error if driver not initailized', async () => { + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + await expectAsync(genericProvider.getTiles(false)).toBeRejectedWithError('Driver is null, please initialize driver with createDriver().'); + }); + }); + + describe('getTag', () => { + beforeEach(() => { + spyOn(DesktopMetaData.prototype, 'windowSize') + .and.returnValue(Promise.resolve({ width: 1000, height: 1000 })); + spyOn(DesktopMetaData.prototype, 'orientation') + .and.returnValue('landscape'); + spyOn(DesktopMetaData.prototype, 'deviceName') + .and.returnValue('mockDeviceName'); + spyOn(DesktopMetaData.prototype, 'osName') + .and.returnValue('mockOsName'); + spyOn(DesktopMetaData.prototype, 'osVersion') + .and.returnValue('mockOsVersion'); + spyOn(DesktopMetaData.prototype, 'browserName') + .and.returnValue('mockBrowserName'); + }); + + it('returns correct tag', async () => { + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + await genericProvider.createDriver(); + const tag = await genericProvider.getTag(); + expect(tag).toEqual({ + name: 'mockDeviceName', + osName: 'mockOsName', + osVersion: 'mockOsVersion', + width: 1000, + height: 1000, + orientation: 'landscape', + browserName: 'mockBrowserName', + browserVersion: 'unknown' + }); + }); + + it('throws error if driver not initailized', async () => { + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + await expectAsync(genericProvider.getTag()).toBeRejectedWithError('Driver is null, please initialize driver with createDriver().'); + }); + }); + + describe('screenshot', () => { + let getTagSpy; + let getTilesSpy; + + beforeEach(() => { + getTagSpy = spyOn(GenericProvider.prototype, 'getTag').and.returnValue(Promise.resolve('mock-tag')); + getTilesSpy = spyOn(GenericProvider.prototype, 'getTiles').and.returnValue(Promise.resolve('mock-tile')); + }); + + it('calls correct funcs', async () => { + genericProvider = new GenericProvider('123', 'http:executorUrl', { platform: 'win' }, {}); + await genericProvider.createDriver(); + let res = await genericProvider.screenshot('mock-name'); + expect(getTagSpy).toHaveBeenCalledTimes(1); + expect(getTilesSpy).toHaveBeenCalledOnceWith(false); + expect(res).toEqual({ + name: 'mock-name', + tag: 'mock-tag', + tiles: 'mock-tile', + externalDebugUrl: 'https://localhost/v1', + environmentInfo: 'staging-poc-poa', + clientInfo: 'local-poc-poa' + }); + }); + }); +}); diff --git a/packages/webdriver-utils/test/providers/providerResolver.test.js b/packages/webdriver-utils/test/providers/providerResolver.test.js new file mode 100644 index 000000000..714ee3319 --- /dev/null +++ b/packages/webdriver-utils/test/providers/providerResolver.test.js @@ -0,0 +1,15 @@ +import ProviderResolver from '../../src/providers/providerResolver.js'; +import AutomateProvider from '../../src/providers/automateProvider.js'; +import GenericProvider from '../../src/providers/genericProvider.js'; + +describe('ProviderResolver', () => { + describe('resolve', () => { + it('returns automateProvider correctly', () => { + expect(ProviderResolver.resolve('123', 'http:browserstack', {}, {})).toBeInstanceOf(AutomateProvider); + }); + + it('returns genericProvider correctly', () => { + expect(ProviderResolver.resolve('123', 'http:outside', {}, {})).toBeInstanceOf(GenericProvider); + }); + }); +}); diff --git a/packages/webdriver-utils/test/util/cache.test.js b/packages/webdriver-utils/test/util/cache.test.js new file mode 100644 index 000000000..facd7dd07 --- /dev/null +++ b/packages/webdriver-utils/test/util/cache.test.js @@ -0,0 +1,157 @@ +import Cache from '../../src/util/cache.js'; + +describe('Cache', () => { + const store = 'abc'; + const key = 'key'; + + beforeEach(async () => { + Cache.reset(); + }); + + describe('withCache', () => { + it('caches response', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + let val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + }); + + describe('with different key but same store', () => { + it('calls func again and caches it', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + const key2 = 'key2'; + + let val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store, key2, func); + expect(func.calls.count()).toEqual(2); + expect(val).toEqual(expectedVal); + + // test both cache + val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(2); // does not increment + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store, key2, func); + expect(func.calls.count()).toEqual(2); // does not increment + expect(val).toEqual(expectedVal); + }); + }); + + describe('with different store but same key', () => { + it('calls func again and caches it', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + const store2 = 'store2'; + + let val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store2, key, func); + expect(func.calls.count()).toEqual(2); + expect(val).toEqual(expectedVal); + + // test both cache + val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(2); // does not increment + expect(val).toEqual(expectedVal); + + val = await Cache.withCache(store2, key, func); + expect(func.calls.count()).toEqual(2); // does not increment + expect(val).toEqual(expectedVal); + }); + }); + + describe('with cacheExceptions', () => { + it('caches exceptions', async () => { + const expectedError = new Error('Some error'); + const func = jasmine.createSpy('func').and.throwError(expectedError); + + let actualError = null; + try { + await Cache.withCache(store, key, func, true); + } catch (e) { + actualError = e; + } + + expect(func.calls.count()).toEqual(1); + expect(actualError).toEqual(expectedError); + + try { + await Cache.withCache(store, key, func, true); + } catch (e) { + actualError = e; + } + + expect(func.calls.count()).toEqual(1); + expect(actualError).toEqual(expectedError); + }); + }); + + describe('with expired cache', () => { + const originalCacheTimeout = Cache.timeout; + beforeAll(() => { + Cache.timeout = 7; // 7ms + }); + + afterAll(() => { + Cache.timeout = originalCacheTimeout; + }); + + it('calls func again and caches it', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + + let val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(1); + expect(val).toEqual(expectedVal); + + // wait for expiry + await new Promise((resolve) => setTimeout(resolve, 10)); + + // create a test entry that should not get deleted + Cache.cache.random_store = {}; + Cache.cache.random_store.some_new_key = { val: 1, time: Date.now(), success: true }; + + // test expired cache + val = await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(2); + expect(val).toEqual(expectedVal); + + // Not deleted + expect(Cache.cache.random_store.some_new_key).toBeTruthy(); + }); + + it('it invalidates all expired keys on any call', async () => { + const expectedVal = 123; + const func = jasmine.createSpy('func').and.returnValue(expectedVal); + const key2 = 'key2'; + const store2 = 'store2'; + + await Cache.withCache(store, key, func); + await Cache.withCache(store, key2, func); + await Cache.withCache(store2, key, func); + + // wait for expiry + await new Promise((resolve) => setTimeout(resolve, 10)); + + // test expired cache + await Cache.withCache(store, key, func); + expect(func.calls.count()).toEqual(4); + + // check internal to avoid calling via withCache + expect(Cache.cache[store2][key]).toBeUndefined(); + expect(Cache.cache[store2][key2]).toBeUndefined(); + }); + }); + }); +});