diff --git a/.eslintrc.js b/.eslintrc.js index ddc62c7c..4bb4b1e3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,7 @@ module.exports = { env: { node: true, es6: true, + mocha: true, }, // Set parser to support, e.g. import() function for dynamic imports (see // https://stackoverflow.com/a/47833471/328565 and https://stackoverflow.com/a/69557309/328565). diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0ad6ad10..2c3df5e7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,6 +13,9 @@ jobs: build_and_test: runs-on: ubuntu-latest strategy: + # it is helpful to know which sets of tests would have succeeded, + # even when there is a failure. + fail-fast: false matrix: python-version: [3.9] node-version: [14.x] @@ -20,11 +23,11 @@ jobs: - ':lint:python:client:common:smoke:' - ':server-1-of-2:' - ':server-2-of-2:' - - ':nbrowser-1-of-5:' - - ':nbrowser-2-of-5:' - - ':nbrowser-3-of-5:' - - ':nbrowser-4-of-5:' - - ':nbrowser-5-of-5:' + - ':nbrowser-^[A-G]:' + - ':nbrowser-^[H-L]:' + - ':nbrowser-^[M-O]:' + - ':nbrowser-^[P-S]:' + - ':nbrowser-^[^A-S]:' steps: - uses: actions/checkout@v3 @@ -95,8 +98,8 @@ jobs: - name: Run main tests without minio and redis if: contains(matrix.tests, ':nbrowser-') run: | - export TEST_SPLITS=$(echo $TESTS | sed "s/.*:nbrowser-\([^:]*\).*/\1/") - MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser + export GREP_TESTS=$(echo $TESTS | sed "s/.*:nbrowser-\([^:]*\).*/\1/") + MOCHA_WEBDRIVER_SKIP_CLEANUP=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser --parallel --jobs 3 env: TESTS: ${{ matrix.tests }} diff --git a/buildtools/webpack.config.js b/buildtools/webpack.config.js index 26c2832e..9e35f6a7 100644 --- a/buildtools/webpack.config.js +++ b/buildtools/webpack.config.js @@ -15,6 +15,7 @@ module.exports = { account: "app/client/accountMain", billing: "app/client/billingMain", activation: "app/client/activationMain", + test: "test/client-harness/client", }, output: { filename: "[name].bundle.js", @@ -78,4 +79,10 @@ module.exports = { // To strip all locales except “en” new MomentLocalesPlugin() ], + externals: { + // for test bundle: jsdom should not be touched within browser + jsdom: 'alert', + // for test bundle: jquery will be available as jQuery + jquery: 'jQuery' + }, }; diff --git a/package.json b/package.json index 8e920b57..071256ea 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,12 @@ "install:python3": "buildtools/prepare_python3.sh", "build:prod": "buildtools/build.sh", "start:prod": "sandbox/run.sh", - "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+-b --no-exit} --slow 8000 ${DEBUG:---forbid-only} -g ${GREP_TESTS:-''} '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", - "test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+-b --no-exit} ${DEBUG:---forbid-only} -g ${GREP_TESTS:-''} --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'", - "test:client": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'", - "test:common": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'", - "test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+'-b'} -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", - "test:smoke": "NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/nbrowser/Smoke.js", + "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} --slow 8000 ${DEBUG:---forbid-only} -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", + "test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} ${DEBUG:---forbid-only} -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'", + "test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'", + "test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'", + "test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", + "test:smoke": "mocha _build/test/nbrowser/Smoke.js", "test:docker": "./test/test_under_docker.sh", "test:python": "sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \"test*${GREP_TESTS}*.py\"}", "cli": "NODE_PATH=_build:_build/stubs:_build/ext node _build/app/server/companion.js", @@ -91,8 +91,8 @@ "http-proxy": "1.18.1", "i18next-scanner": "4.1.0", "jsdom": "16.5.0", - "mocha": "5.2.0", - "mocha-webdriver": "0.2.9", + "mocha": "10.2.0", + "mocha-webdriver": "0.2.13", "moment-locales-webpack-plugin": "^1.2.0", "nodemon": "^2.0.4", "otplib": "12.0.1", @@ -187,5 +187,13 @@ "jquery": "3.5.0", "ts-interface-checker": "1.0.2", "@gristlabs/sqlite3": "5.1.4-grist.8" + }, + "mocha": { + "require": ["test/setupPaths", + "source-map-support/register", + "test/report-why-tests-hang", + "test/init-mocha-webdriver", + "test/split-tests", + "test/chai-as-promised"] } } diff --git a/static/mocha.css b/static/mocha.css new file mode 120000 index 00000000..50e61022 --- /dev/null +++ b/static/mocha.css @@ -0,0 +1 @@ +../node_modules/mocha/mocha.css \ No newline at end of file diff --git a/static/mocha.js b/static/mocha.js new file mode 120000 index 00000000..39d77e78 --- /dev/null +++ b/static/mocha.js @@ -0,0 +1 @@ +../node_modules/mocha/mocha.js \ No newline at end of file diff --git a/static/test.html b/static/test.html new file mode 100644 index 00000000..c2bf553b --- /dev/null +++ b/static/test.html @@ -0,0 +1,104 @@ + + + + + + + Grist Tests + + + + + + + + + + + + + +
+
+ Run tests with timings +
+
+
 
+
TBD - RUNNING...
+ + + + diff --git a/static/testWebdriverJQuery.html b/static/testWebdriverJQuery.html new file mode 100644 index 00000000..f1c67f35 --- /dev/null +++ b/static/testWebdriverJQuery.html @@ -0,0 +1,24 @@ + + + + + + WebdriverJQuery test + + + + +
+ + Hello world + +
+
+ + Good bye + + +
+ + + diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts index 0aa3776f..dc462275 100644 --- a/stubs/app/server/server.ts +++ b/stubs/app/server/server.ts @@ -110,6 +110,9 @@ export async function main() { if (process.env.GRIST_TESTING_SOCKET) { await server.addTestingHooks(); } + if (process.env.GRIST_SERVE_PLUGINS_PORT) { + await server.startCopy('pluginServer', parseInt(process.env.GRIST_SERVE_PLUGINS_PORT, 10)); + } return server; } diff --git a/test/client-harness/client.js b/test/client-harness/client.js new file mode 100644 index 00000000..6f6dbe1e --- /dev/null +++ b/test/client-harness/client.js @@ -0,0 +1,30 @@ +/* global window */ +window.loadTests = function() { + require('test/common/BinaryIndexedTree'); + require('test/common/CircularArray'); + require('test/common/MemBuffer'); + require('test/common/arraySplice'); + require('test/common/gutil'); + require('test/common/marshal'); + require('test/common/promises'); + require('test/common/serializeTiming'); + require('test/common/timeFormat'); + require('test/common/ValueFormatter'); + require('test/common/InactivityTimer'); + + require('test/client/clientUtil'); + require('test/client/components/Layout'); + require('test/client/components/commands'); + require('test/client/components/sampleLayout'); + require('test/client/lib/ObservableMap'); + require('test/client/lib/ObservableSet'); + require('test/client/lib/dispose'); + require('test/client/lib/dom'); + require('test/client/lib/koArray'); + require('test/client/lib/koDom'); + require('test/client/lib/koForm'); + require('test/client/lib/koUtil'); + require('test/client/models/modelUtil'); + require('test/client/models/rowset'); + require('test/client/lib/localStorageObs'); +} diff --git a/test/client/clientUtil.js b/test/client/clientUtil.js index 3bcba972..e922689c 100644 --- a/test/client/clientUtil.js +++ b/test/client/clientUtil.js @@ -16,7 +16,6 @@ function setTmpMochaGlobals() { return; } - /* global before, after */ const {JSDOM} = require('jsdom'); var prevGlobals; diff --git a/test/client/components/Layout.js b/test/client/components/Layout.js index 1eeaa65f..baf56124 100644 --- a/test/client/components/Layout.js +++ b/test/client/components/Layout.js @@ -1,5 +1,3 @@ -/* global describe, beforeEach, afterEach, it */ - var assert = require('chai').assert; var clientUtil = require('../clientUtil'); var dom = require('app/client/lib/dom'); diff --git a/test/client/components/commands.js b/test/client/components/commands.js index 20f80324..4afb426a 100644 --- a/test/client/components/commands.js +++ b/test/client/components/commands.js @@ -1,5 +1,3 @@ -/* global describe, beforeEach, before, after, it */ - var _ = require('underscore'); var sinon = require('sinon'); var assert = require('chai').assert; diff --git a/test/client/lib/Delay.js b/test/client/lib/Delay.js index f98a9487..57291825 100644 --- a/test/client/lib/Delay.js +++ b/test/client/lib/Delay.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('chai').assert; var sinon = require('sinon'); var Promise = require('bluebird'); diff --git a/test/client/lib/ObservableMap.js b/test/client/lib/ObservableMap.js index 7601c184..707924b5 100644 --- a/test/client/lib/ObservableMap.js +++ b/test/client/lib/ObservableMap.js @@ -1,5 +1,3 @@ -/* global describe, it, before */ - const assert = require('chai').assert; const ko = require('knockout'); diff --git a/test/client/lib/ObservableSet.js b/test/client/lib/ObservableSet.js index d1a78dd9..4c1f36d9 100644 --- a/test/client/lib/ObservableSet.js +++ b/test/client/lib/ObservableSet.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('chai').assert; var ko = require('knockout'); diff --git a/test/client/lib/dispose.js b/test/client/lib/dispose.js index 0bee1eb4..d60e24ad 100644 --- a/test/client/lib/dispose.js +++ b/test/client/lib/dispose.js @@ -1,5 +1,3 @@ -/* global describe, it, before, after */ - var dispose = require('app/client/lib/dispose'); var bluebird = require('bluebird'); @@ -9,6 +7,8 @@ var sinon = require('sinon'); var clientUtil = require('../clientUtil'); var dom = require('app/client/lib/dom'); +require('chai').config.truncateThreshold = 10000; + describe('dispose', function() { clientUtil.setTmpMochaGlobals(); @@ -153,9 +153,12 @@ describe('dispose', function() { assert.equal(baz.dispose.callCount, 1); assert(baz.dispose.calledBefore(bar.dispose)); - assert.deepEqual(consoleErrors[0], ['Error constructing %s:', 'Foo', 'Error: test-error1']); - assert.deepEqual(consoleErrors[1], ['Error constructing %s:', 'Foo', 'Error: test-error2']); - assert.deepEqual(consoleErrors[2], ['Error constructing %s:', 'Foo', 'Error: test-error3']); + const name = consoleErrors[0][1]; // may be Foo, or minified. + assert(name === 'Foo' || name === 'o'); // this may not be reliable, + // just what I happen to see. + assert.deepEqual(consoleErrors[0], ['Error constructing %s:', name, 'Error: test-error1']); + assert.deepEqual(consoleErrors[1], ['Error constructing %s:', name, 'Error: test-error2']); + assert.deepEqual(consoleErrors[2], ['Error constructing %s:', name, 'Error: test-error3']); assert.equal(consoleErrors.length, 3); }); diff --git a/test/client/lib/dom.js b/test/client/lib/dom.js index 3c753954..5334cb0f 100644 --- a/test/client/lib/dom.js +++ b/test/client/lib/dom.js @@ -1,5 +1,3 @@ -/* global describe, it, before, after */ - var assert = require('chai').assert; var sinon = require('sinon'); var Promise = require('bluebird'); diff --git a/test/client/lib/koArray.js b/test/client/lib/koArray.js index f88ef69c..77b7d82e 100644 --- a/test/client/lib/koArray.js +++ b/test/client/lib/koArray.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var _ = require('underscore'); var assert = require('assert'); var ko = require('knockout'); diff --git a/test/client/lib/koDom.js b/test/client/lib/koDom.js index f2bf9a16..548eb7e5 100644 --- a/test/client/lib/koDom.js +++ b/test/client/lib/koDom.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('assert'); var ko = require('knockout'); var sinon = require('sinon'); diff --git a/test/client/lib/koDomScrolly.js b/test/client/lib/koDomScrolly.js index fa419a11..1a8d5bbb 100644 --- a/test/client/lib/koDomScrolly.js +++ b/test/client/lib/koDomScrolly.js @@ -4,8 +4,6 @@ const G = require('app/client/lib/browserGlobals').get('window', '$'); const sinon = require('sinon'); const assert = require('assert'); -/* global describe, it, after, before, beforeEach */ - describe("koDomScrolly", function() { clientUtil.setTmpMochaGlobals(); diff --git a/test/client/lib/koForm.js b/test/client/lib/koForm.js index 5883e4b1..69b187be 100644 --- a/test/client/lib/koForm.js +++ b/test/client/lib/koForm.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('chai').assert; var ko = require('knockout'); diff --git a/test/client/lib/koUtil.js b/test/client/lib/koUtil.js index e5868c13..f318929b 100644 --- a/test/client/lib/koUtil.js +++ b/test/client/lib/koUtil.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('assert'); var ko = require('knockout'); var sinon = require('sinon'); diff --git a/test/client/models/modelUtil.js b/test/client/models/modelUtil.js index 12b060e3..21e42817 100644 --- a/test/client/models/modelUtil.js +++ b/test/client/models/modelUtil.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('assert'); var ko = require('knockout'); diff --git a/test/client/models/rowset.js b/test/client/models/rowset.js index 134d4103..c010e690 100644 --- a/test/client/models/rowset.js +++ b/test/client/models/rowset.js @@ -1,5 +1,3 @@ -/* global describe, it, beforeEach */ - var _ = require('underscore'); var assert = require('chai').assert; var sinon = require('sinon'); diff --git a/test/client/models/rowuid.js b/test/client/models/rowuid.js index dd579cd0..61c8893b 100644 --- a/test/client/models/rowuid.js +++ b/test/client/models/rowuid.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('chai').assert; var rowuid = require('app/client/models/rowuid'); diff --git a/test/common/BinaryIndexedTree.js b/test/common/BinaryIndexedTree.js index 76c5d2fb..34807492 100644 --- a/test/common/BinaryIndexedTree.js +++ b/test/common/BinaryIndexedTree.js @@ -1,5 +1,3 @@ -/* global describe, before, it */ - var assert = require('assert'); var BinaryIndexedTree = require('app/common/BinaryIndexedTree'); diff --git a/test/common/CircularArray.js b/test/common/CircularArray.js index c6a91f01..43dc6598 100644 --- a/test/common/CircularArray.js +++ b/test/common/CircularArray.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('assert'); var CircularArray = require('app/common/CircularArray'); diff --git a/test/common/MemBuffer.js b/test/common/MemBuffer.js index ede4b4d7..c1497f89 100644 --- a/test/common/MemBuffer.js +++ b/test/common/MemBuffer.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('assert'); var MemBuffer = require('app/common/MemBuffer'); diff --git a/test/common/RecentItems.js b/test/common/RecentItems.js index 8d9b6935..ce03c20c 100644 --- a/test/common/RecentItems.js +++ b/test/common/RecentItems.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('chai').assert; var RecentItems = require('app/common/RecentItems'); diff --git a/test/common/arraySplice.js b/test/common/arraySplice.js index c2a7958b..11549b08 100644 --- a/test/common/arraySplice.js +++ b/test/common/arraySplice.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var _ = require('underscore'); var assert = require('chai').assert; var gutil = require('app/common/gutil'); diff --git a/test/common/gutil.js b/test/common/gutil.js index f83add1f..9755513e 100644 --- a/test/common/gutil.js +++ b/test/common/gutil.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('chai').assert; var gutil = require('app/common/gutil'); var _ = require('underscore'); diff --git a/test/common/marshal.js b/test/common/marshal.js index f8cc6dc3..13c7414b 100644 --- a/test/common/marshal.js +++ b/test/common/marshal.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('chai').assert; var marshal = require('app/common/marshal'); var MemBuffer = require('app/common/MemBuffer'); diff --git a/test/common/promises.js b/test/common/promises.js index 1b1e0b46..b457d0ce 100644 --- a/test/common/promises.js +++ b/test/common/promises.js @@ -6,8 +6,6 @@ */ -/* global describe, it, before */ - var assert = require('chai').assert; var bluebird = require('bluebird'); diff --git a/test/common/serializeTiming.js b/test/common/serializeTiming.js index c6a13df8..e5f2190f 100644 --- a/test/common/serializeTiming.js +++ b/test/common/serializeTiming.js @@ -1,5 +1,3 @@ -/* global describe, it, before, after */ - var _ = require('underscore'); var assert = require('assert'); var Chance = require('chance'); diff --git a/test/common/sortTiming.js b/test/common/sortTiming.js index d00fa625..45530909 100644 --- a/test/common/sortTiming.js +++ b/test/common/sortTiming.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('assert'); var gutil = require('app/common/gutil'); var _ = require('underscore'); diff --git a/test/common/timeFormat.js b/test/common/timeFormat.js index 8d00c354..50b1fc5d 100644 --- a/test/common/timeFormat.js +++ b/test/common/timeFormat.js @@ -1,5 +1,3 @@ -/* global describe, it */ - var assert = require('assert'); var {timeFormat} = require('app/common/timeFormat'); diff --git a/test/declarations.d.ts b/test/declarations.d.ts new file mode 100644 index 00000000..8ffda614 --- /dev/null +++ b/test/declarations.d.ts @@ -0,0 +1,8 @@ +declare module "test/nbrowser/gristUtil-nbrowser"; + +// Adds missing type declaration to chai +declare namespace Chai { + interface AssertStatic { + notIncludeMembers(superset: T[], subset: T[], message?: string): void; + } +} diff --git a/test/fixtures/plugins/.jshintrc b/test/fixtures/plugins/.jshintrc new file mode 100644 index 00000000..280ab938 --- /dev/null +++ b/test/fixtures/plugins/.jshintrc @@ -0,0 +1,6 @@ +{ + "undef": true, + "unused": "vars", + "globalstrict": true, + "esnext": true +} diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/browser-GristDocAPI/main.js b/test/fixtures/plugins/browserInstalledPlugins/plugins/browser-GristDocAPI/main.js new file mode 100644 index 00000000..60ac5959 --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/browser-GristDocAPI/main.js @@ -0,0 +1,31 @@ + + +/* global grist, self */ + +self.importScripts('/grist-plugin-api.js'); + +grist.rpc.registerImpl("testApiBrowser", { + getImportSource() { + const api = grist.rpc.getStub('GristDocAPI@grist', grist.checkers.GristDocAPI); + return api.getDocName() + .then((result) => { + const content = JSON.stringify({ + tables: [{ + table_name: '', + column_metadata: [{ + id: 'getDocName', + type: 'Text' + }], + table_data: [[result]] + }] + }); + const fileItem = {content, name: "GristDocAPI.jgrist"}; + return { + item: { kind: "fileList", files: [fileItem] }, + description: "GristDocAPI results" + }; + }); + } +}); + +grist.ready(); diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/browser-GristDocAPI/manifest.yml b/test/fixtures/plugins/browserInstalledPlugins/plugins/browser-GristDocAPI/manifest.yml new file mode 100644 index 00000000..4685dd9a --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/browser-GristDocAPI/manifest.yml @@ -0,0 +1,12 @@ +name: browser-GristDocAPI +version: 0.0.0 +description: +components: + safeBrowser: main.js + +contributions: + importSources: + - importSource: + component: safeBrowser + name: testApiBrowser + label: Test GristDocAPI diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/index-bis.html b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/index-bis.html new file mode 100644 index 00000000..605f3b2b --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/index-bis.html @@ -0,0 +1,28 @@ + + +

Hello Bis

+
+ + 0

+ 1

+ 2

+ 3

+ 4

+ 5

+ 6

+ 7

+ 8

+ 9

+ 10

+ 11

+ 12

+ 13

+ 14

+ 15

+ 16

+ 17

+ 18

+ 19

+ 20

+ + diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/index.html b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/index.html new file mode 100644 index 00000000..3941c7a7 --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/index.html @@ -0,0 +1,28 @@ + + +

Hello

+
+ + 0

+ 1

+ 2

+ 3

+ 4

+ 5

+ 6

+ 7

+ 8

+ 9

+ 10

+ 11

+ 12

+ 13

+ 14

+ 15

+ 16

+ 17

+ 18

+ 19

+ 20

+ + diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/main.js b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/main.js new file mode 100644 index 00000000..335cd437 --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/main.js @@ -0,0 +1,14 @@ + + +/* globals self, grist */ + +self.importScripts("/grist-plugin-api.js"); + +class CustomSection { + createSection(renderTarget) { + return grist.api.render('index.html', renderTarget); + } +} + +grist.rpc.registerImpl('hello', new CustomSection(), grist.CustomSectionDescription); +grist.ready(); diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/manifest.yml b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/manifest.yml new file mode 100644 index 00000000..66ddad2f --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/manifest.yml @@ -0,0 +1,12 @@ +name: helloSection +version: 0.0.0 +components: + safeBrowser: main.js +contributions: + customSections: + - path: index.html + name: Hello World + - path: index-bis.html + name: Hello World (bis) + - path: test-subscribe-api.html + name: dataAPI test diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/test-subscribe-api.html b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/test-subscribe-api.html new file mode 100644 index 00000000..5a62f7d7 --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/test-subscribe-api.html @@ -0,0 +1,11 @@ + + + + + + + +

Data API

+
+ + diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/test-subscribe-api.js b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/test-subscribe-api.js new file mode 100644 index 00000000..e8e929bf --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/test-subscribe-api.js @@ -0,0 +1,36 @@ + + +/* global grist, window, $, document */ +let tableId = 'Table1'; + +grist.ready(); +grist.api.subscribe(tableId); + +window.onload = () => { + showColumn('A'); +}; + +grist.rpc.on("message", (msg) => { + if (msg.type === "docAction") { + // There could by many doc actions and fetching table is expensive, in practice this call would + // be be throttle + if (msg.action[0] === 'RenameTable') { + tableId = msg.action[2]; + } + showColumn('A'); + } +}); + +// fetch table and call the view with values of coldId +function showColumn(colId) { + grist.docApi.fetchTable(tableId).then(cols => updateView(cols[colId])); +} + +// show the first column +function updateView(values) { + $("#panel").empty(); + const res = $('
'); + const text = document.createTextNode(JSON.stringify(values)); + res.append(text); + $("#panel").append(res); +} diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/index.html b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/index.html new file mode 100644 index 00000000..07a6fc70 --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + +
+ "name of the file: " + + + + diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/main.js b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/main.js new file mode 100644 index 00000000..a44e8d9a --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/main.js @@ -0,0 +1,9 @@ + + +/* global grist, self */ + +self.importScripts('/grist-plugin-api.js'); + +grist.addImporter('dummy', 'index.html', 'fullscreen'); +grist.addImporter('dummy-inlined', 'index.html', 'inline'); +grist.ready(); diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/manifest.yml b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/manifest.yml new file mode 100644 index 00000000..7ea06a35 --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/manifest.yml @@ -0,0 +1,16 @@ +name: pluginName +version: 0.0.1 +components: + safeBrowser: main.js + safePython: sandbox/main.py + unsafeNode: node/main.js +contributions: + importSources: + - importSource: + component: safeBrowser + name: dummy + label: Dummy importer + - importSource: + component: safeBrowser + name: dummy-inlined + label: Inline Importer diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/node/main.js b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/node/main.js new file mode 100644 index 00000000..67a2e18e --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/node/main.js @@ -0,0 +1,4 @@ +const grist = require('grist-plugin-api'); + +grist.rpc.registerFunc("func1", (name) => `Yo: ${name}`); +grist.ready(); diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/sandbox/main.py b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/sandbox/main.py new file mode 100644 index 00000000..9d599819 --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/sandbox/main.py @@ -0,0 +1,11 @@ +import sandbox + +def greet(val): + return "With love: " + val + +def main(): + sandbox.register("func1", greet) + sandbox.run() + +if __name__ == "__main__": + main() diff --git a/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/script.js b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/script.js new file mode 100644 index 00000000..64745491 --- /dev/null +++ b/test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/script.js @@ -0,0 +1,44 @@ + + +/* global grist, window, document, $ */ + +let resolve; // eslint-disable-line no-unused-vars + +const importer = { + getImportSource: () => new Promise((_resolve) => { + resolve = _resolve; + }) +}; + +grist.rpc.registerImpl('dummy', importer ); +grist.rpc.registerImpl('dummy-inlined', importer ); + +grist.ready(); + +window.onload = function() { + callFunctionOnClick('#call-safePython', 'func1@sandbox/main.py', 'Bob'); + callFunctionOnClick('#call-unsafeNode', 'func1@node/main.js', 'Alice'); + document.querySelector('#cancel').addEventListener('click', () => resolve()); + document.querySelector('#ok').addEventListener('click', () => { + const name = $('#name').val(); + resolve({ + item: { + kind: "fileList", + files: [{content: "A,B\n1,2\n", name}] + }, + description: name + " selected!" + }); + }); +}; + +function callFunctionOnClick(selector, funcName, ...args) { + document.querySelector(selector).addEventListener('click', () => { + grist.rpc.callRemoteFunc(funcName, ...args) + .then(val => { + const resElement = document.createElement('h1'); + resElement.classList.add(`result`); + resElement.textContent = val; + document.body.appendChild(resElement); + }); + }); +} diff --git a/test/fixtures/plugins/builtInPlugins/plugins/2/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/2/manifest.yml new file mode 100644 index 00000000..ae4c681a --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/2/manifest.yml @@ -0,0 +1,3 @@ +version: 0.0.1 +contributions: + importSources: diff --git a/test/fixtures/plugins/builtInPlugins/plugins/experimental-plugin/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/experimental-plugin/manifest.yml new file mode 100644 index 00000000..a11a634a --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/experimental-plugin/manifest.yml @@ -0,0 +1,12 @@ +name: crazy-plugin +version: 0.0.1 +experimental: true +components: + safePython: sandbox/main.py + +contributions: + fileParsers: + - fileExtensions: ["csv"] + parseFile: + component: "safePython" + name: "csv_parser" diff --git a/test/fixtures/plugins/builtInPlugins/plugins/experimental-plugin/sandbox/main.py b/test/fixtures/plugins/builtInPlugins/plugins/experimental-plugin/sandbox/main.py new file mode 100644 index 00000000..556df42e --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/experimental-plugin/sandbox/main.py @@ -0,0 +1 @@ +# nothing diff --git a/test/fixtures/plugins/builtInPlugins/plugins/invalid-contrib-point/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/invalid-contrib-point/manifest.yml new file mode 100644 index 00000000..490870ac --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/invalid-contrib-point/manifest.yml @@ -0,0 +1,3 @@ +version: 0.0.1 +contributions: + invalidContibutionPoint: diff --git a/test/fixtures/plugins/builtInPlugins/plugins/long-call/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/long-call/manifest.yml new file mode 100644 index 00000000..dc54e8f4 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/long-call/manifest.yml @@ -0,0 +1,14 @@ +name: pluginName +version: 0.0.1 +components: + safePython: sandbox/main.py + deactivate: + # Let's keep it low for tests to be fast, but big enough for test to be accurate. + inactivitySec: 0.1 + +contributions: + fileParsers: + - fileExtensions: ["csv"] + parseFile: + component: "safePython" + name: "csv_parser" diff --git a/test/fixtures/plugins/builtInPlugins/plugins/long-call/sandbox/main.py b/test/fixtures/plugins/builtInPlugins/plugins/long-call/sandbox/main.py new file mode 100644 index 00000000..b1bc9d95 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/long-call/sandbox/main.py @@ -0,0 +1,28 @@ +import time +import sandbox + +# pylint: disable=unused-argument +# pylint: disable=no-member + +def import_files(file_source, parse_options): + end = time.time() + 1 + while time.time() < end: + pass + return { + "parseOptions": {}, + # Make sure the output is a list of GristTables as documented at app/plugin/GristTable.ts + "tables": [{ + "table_name": "mytable", + "column_metadata": [], + "table_data": [], + }] + } + + +def main(): + sandbox.register("csv_parser.parseFile", import_files) + sandbox.run() # pylint: disable=no-member + + +if __name__ == "__main__": + main() diff --git a/test/fixtures/plugins/builtInPlugins/plugins/missing-component/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/missing-component/manifest.yml new file mode 100644 index 00000000..091a0df8 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/missing-component/manifest.yml @@ -0,0 +1,9 @@ +name: missing-components +version: 0.0.1 +# missing `components` entry +contributions: + fileParsers: + - fileExtensions: ["csv"] + parseFile: + component: "safePython" + name: "csv_parser" diff --git a/test/fixtures/plugins/builtInPlugins/plugins/missing-safePython/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/missing-safePython/manifest.yml new file mode 100644 index 00000000..96925f26 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/missing-safePython/manifest.yml @@ -0,0 +1,10 @@ +name: missing-safePython +version: 0.0.1 +components: + # missing `safePython` component +contributions: + fileParsers: + - fileExtensions: ["csv"] + parseFile: + component: "safePython" + name: "csv_parser" diff --git a/test/fixtures/plugins/builtInPlugins/plugins/safePython-deactivate-fast/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/safePython-deactivate-fast/manifest.yml new file mode 100644 index 00000000..dc54e8f4 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/safePython-deactivate-fast/manifest.yml @@ -0,0 +1,14 @@ +name: pluginName +version: 0.0.1 +components: + safePython: sandbox/main.py + deactivate: + # Let's keep it low for tests to be fast, but big enough for test to be accurate. + inactivitySec: 0.1 + +contributions: + fileParsers: + - fileExtensions: ["csv"] + parseFile: + component: "safePython" + name: "csv_parser" diff --git a/test/fixtures/plugins/builtInPlugins/plugins/safePython-deactivate-fast/sandbox/main.py b/test/fixtures/plugins/builtInPlugins/plugins/safePython-deactivate-fast/sandbox/main.py new file mode 100644 index 00000000..f83f3685 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/safePython-deactivate-fast/sandbox/main.py @@ -0,0 +1,28 @@ +import sandbox + +# pylint: disable=unused-argument +# pylint: disable=no-member + +# TODO: configure pylint behavior for both `test/fixtures/plugins` and +# `/plugins` folders: either to ignore them completely or to ignore +# above mentioned rules. + +def import_files(file_source, parse_options=None): + return { + "parseOptions": {}, + "tables": [{ + "table_name": "mytable", + "column_metadata": [], + "table_data": [] + }]} + + +def main(): + # Todo: Grist should expose a register method accepting arguments as + # follow: register('csv_parser', 'importFiles', can_parse) + sandbox.register("csv_parser.parseFile", import_files) + sandbox.run() # pylint: disable=no-member + + +if __name__ == "__main__": + main() diff --git a/test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/backend.js b/test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/backend.js new file mode 100644 index 00000000..816ee3f7 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/backend.js @@ -0,0 +1,5 @@ +const grist = require('grist-plugin-api'); + +grist.rpc.registerFunc("yo", (name) => `yo ${name}`); +grist.rpc.registerFunc("yoSafePython", (name) => grist.rpc.callRemoteFunc("yo@sandbox/main.py", name)); +grist.ready(); diff --git a/test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/manifest.yml new file mode 100644 index 00000000..c9c15e37 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/manifest.yml @@ -0,0 +1,17 @@ +name: testPluginFunction +version: 0.0.1 +components: + safePython: sandbox/main.py + unsafeNode: backend.js + safeBrowser: main.js + +# For the purpose of this unit-test contributions property is actually +# NOT need and only provided for the sake of making this manifest +# valid, + +contributions: + importSources: + - importSource: + component: "safeBrowser" + name: index.html + label: My safe importer diff --git a/test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/sandbox/main.py b/test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/sandbox/main.py new file mode 100644 index 00000000..2990989d --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/sandbox/main.py @@ -0,0 +1,15 @@ +import sandbox + +def greet(name): + return "Hi " + name + +def yo(name): + return "yo " + name + " from safePython" + +def main(): + sandbox.register("greet", greet) + sandbox.register("yo", yo) + sandbox.run() + +if __name__ == "__main__": + main() diff --git a/test/fixtures/plugins/builtInPlugins/plugins/valid-file-parser/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/valid-file-parser/manifest.yml new file mode 100644 index 00000000..00595d7e --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/valid-file-parser/manifest.yml @@ -0,0 +1,11 @@ +name: pluginName +version: 0.0.1 +components: + safePython: sandbox/main.py + +contributions: + fileParsers: + - fileExtensions: ["csv"] + parseFile: + component: "safePython" + name: "csv_parser" diff --git a/test/fixtures/plugins/builtInPlugins/plugins/valid-file-parser/sandbox/main.py b/test/fixtures/plugins/builtInPlugins/plugins/valid-file-parser/sandbox/main.py new file mode 100644 index 00000000..9b9a4d9d --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/valid-file-parser/sandbox/main.py @@ -0,0 +1,29 @@ +import sandbox + +# pylint: disable=unused-argument +# pylint: disable=no-member + +# TODO: configure pylint behavior for both `test/fixtures/plugins` and +# `/plugins` folders: either to ignore them completely or to ignore +# above mentioned rules. + +def import_files(file_source, parse_options): + parse_options.update({"NUM_ROWS" : 1}) + return { + "parseOptions": parse_options, + "tables": [{ + "table_name": "mytable", + "column_metadata": [], + "table_data": [] + }]} + + +def main(): + # Todo: Grist should expose a register method accepting arguments as + # follow: register('csv_parser', 'parseFile', can_parse) + sandbox.register("csv_parser.parseFile", import_files) + sandbox.run() # pylint: disable=no-member + + +if __name__ == "__main__": + main() diff --git a/test/fixtures/plugins/builtInPlugins/plugins/valid-import-source/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/valid-import-source/manifest.yml new file mode 100644 index 00000000..9ff8e565 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/valid-import-source/manifest.yml @@ -0,0 +1,10 @@ +name: pluginName +version: 0.0.1 +components: + safeBrowser: '.' +contributions: + importSources: + - importSource: + component: "safeBrowser" + name: index.html + label: My safe importer diff --git a/test/fixtures/plugins/builtInPlugins/plugins/wrong-json/manifest.json b/test/fixtures/plugins/builtInPlugins/plugins/wrong-json/manifest.json new file mode 100644 index 00000000..69d033b1 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/wrong-json/manifest.json @@ -0,0 +1 @@ +wrong manifest as well in json diff --git a/test/fixtures/plugins/builtInPlugins/plugins/wrong-yaml/manifest.yml b/test/fixtures/plugins/builtInPlugins/plugins/wrong-yaml/manifest.yml new file mode 100644 index 00000000..962d3f27 --- /dev/null +++ b/test/fixtures/plugins/builtInPlugins/plugins/wrong-yaml/manifest.yml @@ -0,0 +1 @@ +:some-wrong-manifest diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/TestSubscribe.js b/test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/TestSubscribe.js new file mode 100644 index 00000000..338631f2 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/TestSubscribe.js @@ -0,0 +1,34 @@ +const grist = require('grist-plugin-api'); + +const {foo} = grist.rpc.getStub('foo@grist'); +let tableId = 'Table1'; +const colId = 'A'; +let promise = Promise.resolve(true); + +grist.rpc.on('message', msg => { + if (msg.type === "docAction") { + if (msg.action[0] === 'RenameTable') { + tableId = msg.action[2]; + } + promise = getColValues(colId).then(foo); + } +}); + +function getColValues(colId) { + return grist.docApi.fetchTable(tableId).then(data => data[colId]); +} + +class TestSubscribe { + + invoke(api, name, args){ + return grist[api][name](...args); + } + + // Returns a promise that resolves when an ongoing call resolves. Resolves right-awa if plugin has + // no pending call. + waitForPlugin() { + return promise.then(() => true); + } +} + +module.exports = TestSubscribe; diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/main.js b/test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/main.js new file mode 100644 index 00000000..1d128dc5 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/main.js @@ -0,0 +1,21 @@ +const grist = require('grist-plugin-api'); +const TestSubscribe = require('./TestSubscribe'); + +grist.rpc.registerImpl("testApiNode", { // todo rename to testGristDocApiNode + invoke: (name, args) => { + const api = grist.rpc.getStub("GristDocAPI@grist", grist.checkers.GristDocAPI); + return api[name](...args) + .then((result) => [`node-GristDocAPI ${name}(${args.join(",")})`, result]); + }, +}); + +grist.rpc.registerImpl("testDocStorage", { + invoke: (name, args) => { + const api = grist.rpc.getStub("DocStorage@grist", grist.checkers.Storage); + return api[name](...args); + }, +}); + +grist.rpc.registerImpl("testSubscribe", new TestSubscribe()); + +grist.ready(); diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/manifest.yml b/test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/manifest.yml new file mode 100644 index 00000000..09adf486 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/manifest.yml @@ -0,0 +1,7 @@ +name: node-GristDocAPI +version: 0.0.0 +description: +components: + unsafeNode: main.js + +contributions: {} diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-fail/main.js b/test/fixtures/plugins/installedPlugins/plugins/node-fail/main.js new file mode 100644 index 00000000..6fb42f86 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-fail/main.js @@ -0,0 +1,3 @@ + + +// die immediately. diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-fail/manifest.yml b/test/fixtures/plugins/installedPlugins/plugins/node-fail/manifest.yml new file mode 100644 index 00000000..fec3a5d0 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-fail/manifest.yml @@ -0,0 +1,11 @@ +name: node-fail +version: 0.0.0 +description: +components: + unsafeNode: main.js +contributions: + fileParsers: + - fileExtensions: ["csv"] + parseFile: + component: unsafeNode + name: node-fail diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/manifest.yml b/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/manifest.yml new file mode 100644 index 00000000..28e77c02 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/manifest.yml @@ -0,0 +1,11 @@ +name: minicsv +version: 0.0.0 +description: minicsv +components: + unsafeNode: nodebox/main.js +contributions: + fileParsers: + - fileExtensions: ["csv"] + parseFile: + component: unsafeNode + name: MiniCSV diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/main.js b/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/main.js new file mode 100644 index 00000000..43b2aad7 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/main.js @@ -0,0 +1,69 @@ +/** + * + * A minimal CSV reader with no type detection. + * All communication done by hand - real plugins should have helper code for + * RPC. + * + */ + +const csv = require('csv'); +const fs = require('fs'); +const path = require('path'); + +function readCsv(data, replier) { + csv.parse(data, {}, function(err, output) { + const result = { + parseOptions: { + options: "" + }, + tables: [ + { + table_name: "space-monkey" + require('dependency_test'), + column_metadata: output[0].map(name => { + return { + id: name, + type: 'Text' + }; + }), + table_data: output[0].map((name, idx) => { + return output.slice(1).map(row => row[idx]); + }) + } + ] + }; + replier(result); + }); +} + +function processMessage(msg, replier, error_replier) { + if (msg.meth == 'parseFile') { + var dir = msg.dir; + var fname = msg.args[0].path; + var data = fs.readFileSync(path.resolve(dir, fname)); + readCsv(data, replier); + } else { + error_replier('unknown method'); + } +} + +process.on('message', (m) => { + const sendReply = (result) => { + process.send({ + mtype: 2, /* RespData */ + reqId: m.reqId, + data: result + }); + }; + const sendError = (txt) => { + process.send({ + mtype: 3, /* RespErr */ + reqId: m.reqId, + mesg: txt + }); + }; + processMessage(m, sendReply, sendError); +}); + +// Once we have a handler for 'message' set up, send home a ready +// message to give the all-clear. +process.send({ mtype: 4, data: {ready: true }}); diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/node_modules/dependency_test/index.js b/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/node_modules/dependency_test/index.js new file mode 100644 index 00000000..93a3fc2f --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/node_modules/dependency_test/index.js @@ -0,0 +1,3 @@ +"use strict"; + +module.exports = 42; diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/node_modules/dependency_test/package.json b/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/node_modules/dependency_test/package.json new file mode 100644 index 00000000..bc8cc9f1 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/node_modules/dependency_test/package.json @@ -0,0 +1,11 @@ +{ + "name": "dependency_test", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC" +} diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-wrong-message/main.js b/test/fixtures/plugins/installedPlugins/plugins/node-wrong-message/main.js new file mode 100644 index 00000000..54a4ce19 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-wrong-message/main.js @@ -0,0 +1,3 @@ + + +process.send({ greeny: true }); diff --git a/test/fixtures/plugins/installedPlugins/plugins/node-wrong-message/manifest.yml b/test/fixtures/plugins/installedPlugins/plugins/node-wrong-message/manifest.yml new file mode 100644 index 00000000..f9156585 --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/node-wrong-message/manifest.yml @@ -0,0 +1,11 @@ +name: node-wrong-message +version: 0.0.0 +description: +components: + unsafeNode: main.js +contributions: + fileParsers: + - fileExtensions: ["csv"] + parseFile: + component: unsafeNode + name: node-wrong-message diff --git a/test/fixtures/plugins/installedPlugins/plugins/valid-import-source/manifest.yml b/test/fixtures/plugins/installedPlugins/plugins/valid-import-source/manifest.yml new file mode 100644 index 00000000..4482468d --- /dev/null +++ b/test/fixtures/plugins/installedPlugins/plugins/valid-import-source/manifest.yml @@ -0,0 +1,10 @@ +name: validPluginName +version: 0.0.1 +components: + safeBrowser: '.' +contributions: + importSources: + - importSource: + component: "safeBrowser" + name: index.html + label: My custom safe importer diff --git a/test/init-mocha-webdriver.js b/test/init-mocha-webdriver.js index dc3faa7f..01745d1b 100644 --- a/test/init-mocha-webdriver.js +++ b/test/init-mocha-webdriver.js @@ -10,7 +10,7 @@ // Increase the threshold since the default (of 40 characters) is often too low. // You can override it using CHAI_TRUNCATE_THRESHOLD env var; 0 disables it. require('chai').config.truncateThreshold = process.env.CHAI_TRUNCATE_THRESHOLD ? - parseFloat(process.env.CHAI_TRUNCATE_THRESHOLD) : 200; + parseFloat(process.env.CHAI_TRUNCATE_THRESHOLD) : 4000; // Set an explicit window size (if not set by an external variable), to ensure that manully-run // and Jenkins-run tests, headless or not, use a consistent size. (Not that height is still not @@ -44,3 +44,22 @@ if (process.env.MOCHA_WEBDRIVER_IGNORE_CHROME_VERSION === undefined) { if (process.env.MOCHA_WEBDRIVER_NO_CONTROL_BANNER === undefined) { process.env.MOCHA_WEBDRIVER_NO_CONTROL_BANNER = "1"; } + +// Detect whether there is an nbrowser test. If so, +// set an environment variable that will be available +// in individual processes if --parallel is enabled. +for (const arg of process.argv) { + if (arg.includes('/nbrowser/')) { + process.env.MOCHA_WEBDRIVER = '1'; + } +} + +// If --parallel is enabled, and we are in an individual +// worker process, set up mochaHooks. Watch out: at the +// time of writing, there's no way to have hooks run at the +// start and end of the worker process. +if (process.env.MOCHA_WORKER_ID !== undefined && + process.env.MOCHA_WEBDRIVER !== undefined) { + const {getMochaHooks} = require('mocha-webdriver'); + exports.mochaHooks = getMochaHooks(); +} diff --git a/test/nbrowser/ClientUnitTests.ntest.js b/test/nbrowser/ClientUnitTests.ntest.js new file mode 100644 index 00000000..4b4f542a --- /dev/null +++ b/test/nbrowser/ClientUnitTests.ntest.js @@ -0,0 +1,24 @@ +import { driver } from 'mocha-webdriver'; +import { $, gu, server, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('ClientUnitTests.ntest', function() { + test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + var timingTests = process.env.ENABLE_TIMING_TESTS ? 1 : 0; + await driver.get(server.getHost() + '/v/gtag/test.html?timing=' + timingTests); + }); + + it('should reach 100% with no failures', async function() { + this.timeout(30000); // You've got 30 seconds + + await $('#mocha-status:contains(DONE)').wait(); + + const failures = await driver.executeScript('return mocha.failedTests;'); + if (failures.length > 0) { + var listing = failures.map(fail => fail.title + ': ' + fail.error).join("\n"); + throw new Error("Browser returned " + failures.length + " failed tests:\n" + listing); + } + }); +}); diff --git a/test/nbrowser/CodeEditor.ntest.js b/test/nbrowser/CodeEditor.ntest.js new file mode 100644 index 00000000..86c828a5 --- /dev/null +++ b/test/nbrowser/CodeEditor.ntest.js @@ -0,0 +1,61 @@ +/* global window */ + +import { assert, driver } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('CodeEditor.ntest', function() { + const cleanup = test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, '../uploads/CodeEditor.test.csv', true); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('Should activate on click of `Code View` button', async function() { + await gu.openSidePane('code'); + assert.match(await $('.g-code-viewer').wait().getText(), + /class CodeEditor_test:[^]*A = grist.Text\(\)[^]*B = grist.Numeric\(\)/); + }); + + it('Should update to reflect changes in schema', async function() { + await gu.actions.selectTabView('CodeEditor.test'); + // open the side menu + await gu.openSidePane('field'); + + await gu.getCellRC(0, 0).click(); + await $(".test-field-label").wait(assert.isDisplayed); + await $(".test-field-label").sendNewText('foo'); + await gu.waitForServer(); + + await gu.getCellRC(0, 1).click(); + await $(".test-field-label").sendNewText('bar'); + await gu.waitForServer(); // Must wait for colId change to finish + + await gu.setType('Reference'); + await gu.applyTypeConversion(); + await gu.setVisibleCol('foo'); + await gu.waitForServer(); + + // Check that type conversion worked correctly. + assert.equal(await gu.getCellRC(1, 1).text(), 'Bob'); + + await gu.openSidePane('code'); + assert.match(await $('.g-code-viewer').wait().getText(), + /foo = grist.Text\(\)[^]*bar = grist.Reference\('CodeEditor_test'\)/); + }); + + it('should filter out helper columns', async function() { + assert.notInclude(await $('.g-code-viewer').wait().getText(), 'gristHelper'); + }); + + it('should allow text selection', async function() { + const textElem = $('.hljs-title:contains(CodeEditor)'); + await textElem.click(); + await driver.withActions(a => a.doubleClick(textElem.elem())); + assert.equal(await driver.executeScript(() => window.getSelection().toString()), 'CodeEditor_test'); + }); +}); diff --git a/test/nbrowser/ColumnOps.ntest.js b/test/nbrowser/ColumnOps.ntest.js new file mode 100644 index 00000000..816d7bbb --- /dev/null +++ b/test/nbrowser/ColumnOps.ntest.js @@ -0,0 +1,276 @@ +import { assert, driver } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +const colHeaderScrollOpts = {block: "start", inline: "end"}; + +describe('ColumnOps.ntest', function() { + const cleanup = test.setupTestSuite(this); + + before(async function() { + this.timeout(Math.max(this.timeout(), 30000)); // Long-running test, unfortunately + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "World.grist", true); + await gu.toggleSidePanel('left', 'close'); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it("should allow adding and deleting columns", async function() { + await gu.clickColumnMenuItem('Name', 'Insert column to the right'); + await gu.waitForServer(); + // Newly created columns labels become editable automatically. The next line checks that the + // label is editable and then closes the editor. + await gu.userActionsCollect(true); + await gu.getOpenEditingLabel(await gu.getColumnHeader('A')).wait().sendKeys($.ENTER); + // Verify that no UserActions were actually sent. + await gu.waitForServer(); + await gu.userActionsVerify([]); + await gu.userActionsCollect(false); + await gu.waitAppFocus(true); + await assert.isPresent(gu.getColumnHeader('A'), true); + await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader('A')), false); + + await gu.clickColumnMenuItem('A', 'Delete column'); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('A'), false); + }); + + it("should allow adding columns with new column menu", async function() { + await assert.isPresent(gu.getColumnHeader('A'), false); + await $('.mod-add-column').scrollIntoView(true); + await $('.mod-add-column').click(); + await gu.actions.selectFloatingOption('Add Column'); + await gu.userActionsCollect(true); + await gu.waitToPass(() => gu.getColumnHeader('A')); + await gu.getOpenEditingLabel(await gu.getColumnHeader('A')).wait().sendKeys($.ENTER); + await gu.waitForServer(); + await gu.userActionsVerify([["AddRecord", "_grist_Views_section_field", null, + {"colRef":43, "parentId":4, "parentPos":null}]]); + await gu.userActionsCollect(false); + await gu.waitAppFocus(true); + await assert.isPresent(gu.getColumnHeader('A'), true); + await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader('A')), false); + assert.deepEqual(await gu.getGridLabels('City'), + ["Name", "Country", "District", "Population", "A"]); + }); + + it("should allow hiding columns", async function() { + await assert.isPresent(gu.getColumnHeader('Name'), true); + await gu.getColumnHeader('Name').scrollIntoView(colHeaderScrollOpts); + await gu.clickColumnMenuItem('Name', 'Hide column'); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('Name'), false); + + await $(".test-undo").click(); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('Name'), true); + }); + + it("[+] button should allow showing hidden columns", async function() { + // Hide a column first + await assert.isPresent(gu.getColumnHeader('Name'), true); + await gu.getColumnHeader('Name').scrollIntoView(colHeaderScrollOpts); + await gu.clickColumnMenuItem('Name', 'Hide column'); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('Name'), false); + + // Then show it using the add column menu + await $('.mod-add-column').scrollIntoView(true); + await $(".mod-add-column").click(); + await gu.actions.selectFloatingOption('Show column Name'); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('Name'), true); + }); + + it("[+] button show add column directly if no hidden columns", async function() { + await $('.mod-add-column').scrollIntoView(true); + await $(".mod-add-column").click(); + await gu.actions.selectFloatingOption('Show column Pop'); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader("Pop. '000"), true); + + await assert.isPresent(gu.getColumnHeader('B'), false); + await $('.mod-add-column').scrollIntoView(true); + await $(".mod-add-column").click(); + await gu.waitToPass(() => gu.getColumnHeader('B')); + await gu.getOpenEditingLabel(await gu.getColumnHeader('B')).wait().sendKeys($.ENTER); + await gu.waitForServer(); + await gu.waitAppFocus(true); + await assert.isPresent(gu.getColumnHeader('B'), true); + }); + + it("should allow renaming columns", async function() { + await gu.getColumnHeader('Name').scrollIntoView(colHeaderScrollOpts); + await gu.clickColumnMenuItem('Name', 'Rename column'); + await gu.getOpenEditingLabel(await gu.getColumnHeader('Name')).sendKeys('Renamed', $.ENTER); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('Renamed'), true); + assert.deepEqual(await gu.getGridValues({ + cols: ['Renamed'], + rowNums: [3, 4, 5], + }), ['A Coruña (La Coruña)', 'Aachen', 'Aalborg']); + + // Verify that undo/redo works with renaming + await gu.undo(); + await assert.isPresent(gu.getColumnHeader('Name'), true); + assert.deepEqual(await gu.getGridValues({ + cols: ['Name'], + rowNums: [3, 4, 5], + }), ['A Coruña (La Coruña)', 'Aachen', 'Aalborg']); + await gu.redo(); + await assert.isPresent(gu.getColumnHeader('Renamed'), true); + assert.deepEqual(await gu.getGridValues({ + cols: ['Renamed'], + rowNums: [3, 4, 5], + }), ['A Coruña (La Coruña)', 'Aachen', 'Aalborg']); + + // Refresh the page and check that the rename sticks. + await driver.navigate().refresh(); + assert.equal(await $('.active_section .test-viewsection-title').wait().text(), 'CITY'); + await gu.waitForServer(5000); + await gu.clickCellRC(1, 4); + await assert.isPresent(gu.getColumnHeader('Renamed'), true); + + // Check that it is renamed in the sidepane + await gu.openSidePane('field'); + assert.equal(await $(".test-field-label").val(), "Renamed"); + + // Check that both the label and the Id are changed when label and Id are linked + let deriveIdCheckbox = $(".test-field-derive-id"); + assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]')); + await deriveIdCheckbox.click(); + await gu.waitForServer(); + assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]')); + assert(await $(".test-field-col-id").val(), "Renamed"); + + // Check that just the label is changed when label and Id are unlinked + await gu.getColumnHeader('Renamed').scrollIntoView(colHeaderScrollOpts); + await gu.clickColumnMenuItem('Renamed', 'Rename column'); + await gu.getOpenEditingLabel(await gu.getColumnHeader('Renamed')).wait().sendKeys('foo', $.ENTER); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('foo'), true); + await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader('foo')), false); + assert.equal(await $(".test-field-label").val(), "foo"); + assert(await $(".test-field-col-id").val(), "Renamed"); + + // Saving an identical column label should still close the input. + await gu.getColumnHeader('foo').scrollIntoView(colHeaderScrollOpts); + await gu.clickColumnMenuItem('foo', 'Rename column'); + await gu.userActionsCollect(true); + await gu.getOpenEditingLabel(await gu.getColumnHeader('foo')).wait().sendKeys('foo', $.ENTER); + await gu.waitForServer(); + await gu.userActionsVerify([]); + await gu.userActionsCollect(false); + await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader('foo')), false); + await gu.waitAppFocus(true); + + // Bug T384: Should save the column name after cancelling a rename earlier. + await gu.getColumnHeader('A').scrollIntoView(colHeaderScrollOpts); + await gu.clickColumnMenuItem('A', 'Rename column'); + await gu.getOpenEditingLabel(await gu.getColumnHeader('A')).wait().sendKeys('C', $.ESCAPE); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('A'), true); + await gu.clickColumnMenuItem('A', 'Rename column'); + await gu.getOpenEditingLabel(await gu.getColumnHeader('A')).sendKeys('C', $.TAB); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('C'), true); + }); + + it("should allow renaming columns with a click", async function() { + // Go to a non-target column first + await gu.getColumnHeader('Population').scrollIntoView(colHeaderScrollOpts); + await gu.getColumnHeader('Population').click(); + // Now select the column of interest + await gu.getColumnHeader('foo').scrollIntoView(colHeaderScrollOpts); + await gu.getColumnHeader('foo').click(); + // And click one more time to rename + await gu.getColumnHeader('foo').click(); + await gu.getOpenEditingLabel(await gu.getColumnHeader('foo')).sendKeys('foot', $.ENTER); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('foo'), false); + await assert.isPresent(gu.getColumnHeader('foot'), true); + // Click to rename back again + await gu.getColumnHeader('foot').click(); + await gu.getOpenEditingLabel(await gu.getColumnHeader('foot')).sendKeys('foo', $.ENTER); + await gu.waitForServer(); + await assert.isPresent(gu.getColumnHeader('foo'), true); + await assert.isPresent(gu.getColumnHeader('foot'), false); + }); + + it("should allow deleting multiple columns", async function() { + await gu.actions.selectTabView('Country'); + + // delete shortcut should delete all selected columns + await gu.selectGridArea([1, 0], [1, 1]); + await gu.sendKeys([$.ALT, '-']); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridLabels('Country'), + ["Continent", "Region", "SurfaceArea", "IndepYear", "Population", "LifeExpectancy", + "GNP", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", "Capital", "Code2"]); + // Undo to restore changes + await gu.undo(1, 5000); + + // delete menu item should delete all selected columns + await gu.selectGridArea([1, 2], [1, 8]); + await gu.clickColumnMenuItem('SurfaceArea', 'Delete', true); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridLabels('Country'), + ["Code", "Name", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", "Capital", "Code2"]); + // Undo to restore changes + await gu.undo(1, 5000); + + // the delete shortcut should delete all columns in a cell selection as well + await gu.clickCellRC(2, 5); + await gu.sendKeys([$.SHIFT, $.RIGHT, $.RIGHT]); + await gu.sendKeys([$.ALT, '-']); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridLabels('Country'), + ["Code", "Name", "Continent", "Region", "SurfaceArea", "GNP", "GNPOld", "LocalName", + "GovernmentForm", "HeadOfState", "Capital", "Code2"]); + // Undo to restore changes + await gu.undo(1, 5000); + + // Nudge first few columns back into view if they've drifted out of it. + await gu.toggleSidePanel('right', 'close'); + await gu.sendKeys($.LEFT, $.LEFT, $.LEFT); + + // opening a column menu outside the selection should move the selection + await gu.clickCellRC(2, 2); + await gu.sendKeys([$.SHIFT, $.RIGHT]); + await gu.clickColumnMenuItem('IndepYear', 'Delete', true); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridLabels('Country'), + ["Code", "Name", "Continent", "Region", "SurfaceArea", "Population", + "LifeExpectancy", "GNP", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", + "Capital", "Code2"]); + // Undo to restore changes + await gu.undo(); + }); + + it("should allow hiding multiple columns", async function() { + await gu.actions.selectTabView('Country'); + await gu.openSidePane('view'); + + // hide menu item should hide all selected columns + await gu.selectGridArea([1, 2], [1, 8]); + await gu.clickColumnMenuItem('SurfaceArea', 'Hide 7 columns', true); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridLabels('Country'), + ["Code", "Name", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", "Capital", "Code2"]); + assert.deepEqual(await $('.test-vfc-visible-fields .kf_draggable_content').array().text(), + ["Code", "Name", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", "Capital", "Code2"]); + assert.deepEqual(await $('.test-vfc-hidden-fields .kf_draggable_content').array().text(), + ["Name", "Continent", "Region", "SurfaceArea", "IndepYear", "Population", + "LifeExpectancy", "GNP", "Self"]); + // Undo to restore changes + await gu.undo(1, 5000); + + assert.deepEqual(await gu.getGridLabels('Country'), + ["Code", "Name", "Continent", "Region", "SurfaceArea", "IndepYear", "Population", + "LifeExpectancy", "GNP", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", + "Capital", "Code2"]); + + }); +}); diff --git a/test/nbrowser/CustomWidgetsConfig.ts b/test/nbrowser/CustomWidgetsConfig.ts index 965dd303..43ae72fe 100644 --- a/test/nbrowser/CustomWidgetsConfig.ts +++ b/test/nbrowser/CustomWidgetsConfig.ts @@ -158,7 +158,7 @@ async function checkSortMenu(state: 'empty' | 'modified' | 'customized' | 'empty } describe('CustomWidgetsConfig', function () { - this.timeout(30000); // almost 20 second on dev machine. + this.timeout(60000); const cleanup = setupTestSuite(); let mainSession: gu.Session; gu.bigScreen(); diff --git a/test/nbrowser/Dates.ntest.js b/test/nbrowser/Dates.ntest.js new file mode 100644 index 00000000..6c013004 --- /dev/null +++ b/test/nbrowser/Dates.ntest.js @@ -0,0 +1,504 @@ +import { assert, driver } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('Dates.ntest', function() { + const cleanup = test.setupTestSuite(this); + let doc; + before(async function() { + await gu.supportOldTimeyTestCode(); + doc = await gu.useFixtureDoc(cleanup, "Hello.grist", true); + await gu.toggleSidePanel("left", "close"); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should allow correct datetime reformatting', async function() { + await gu.openSidePane('field'); + + var cell = await gu.getCellRC(0, 0); + + // Move to the first column + await cell.click(); + await gu.sendKeys('2008-01-10 9:20pm', $.ENTER); + + // Change type to 'DateTime' + await gu.setType('DateTime'); + await $('.test-tz-autocomplete').wait().click(); + await gu.sendKeys($.DELETE, 'UTC', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), '2008-01-10 9:20pm'); + await $('.test-type-transform-apply').wait().click(); + await gu.waitForServer(); + + // Change timezone to 'America/Los_Angeles' and check that the date is correct + await $('.test-tz-autocomplete').wait().click(); + await gu.sendKeys('Los An', $.ENTER); + await gu.waitForServer(); + assert.equal(await $('.test-tz-autocomplete input').val(), 'America/Los_Angeles'); + assert.equal(await cell.text(), '2008-01-10 1:20pm'); + + // Change format and check that date is reformatted + await gu.dateFormat('MMMM Do, YYYY'); + await gu.timeFormat('HH:mm:ss z'); + assert.equal(await gu.getCellRC(0, 0).text(), 'January 10th, 2008 13:20:00 PST'); + + // Change to custom format and check that the date is reformatted + await gu.dateFormat('Custom'); + + await $('$Widget_dateCustomFormat .kf_text').click(); + await gu.sendKeys($.SELECT_ALL, 'dddd', $.ENTER); + await gu.timeFormat("Custom"); + await $('$Widget_timeCustomFormat .kf_text').click(); + await gu.sendKeys($.SELECT_ALL, 'Hmm', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'Thursday 1320'); + }); + + it('should include a functioning datetime editor', async function() { + var cell = await gu.getCellRC(0, 0); + + // DateTime editor should open, separate date and time, and replace incomplete format + // with YYYY-MM-DD + await cell.click(); + await gu.sendKeys($.ENTER); + assert.equal(await $('.celleditor_text_editor').first().val(), '2008-01-10'); + + // Date should be changable by clicking the calendar dates + await $('.celleditor_text_editor').first().sendKeys($.DOWN); // Opens date picker even if window has no focus. + await $('.datepicker .day:contains(19)').wait().click(); + await gu.sendKeys($.ENTER); + assert.equal(await cell.text(), 'Saturday 1320'); + + // Date editor should convert Moment formats to datepicker safe formats + // Date editor should allow tabbing between date and time entry boxes + await gu.dateFormat('MMMM Do, YYYY'); + await gu.timeFormat('h:mma'); + await cell.click(); + await gu.sendKeys($.ENTER); + assert.deepEqual(await $('.celleditor_text_editor').array().val(), + ['January 19th, 2008', '1:20pm']); + await gu.sendKeys($.SELECT_ALL, 'February 20th, 2009', $.TAB, '8:15am', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'February 20th, 2009 8:15am'); + + // DateTime editor should close and save value when the user clicks away + await cell.click(); + await gu.sendKeys($.ENTER, $.SELECT_ALL, $.DELETE); + await gu.getCellRC(0, 3).click(); // click away + await gu.waitForServer(); + // Since only the date value was removed, the cell should give AltText of the time value + assert.equal(await cell.text(), '8:15am'); + assert.hasClass(await cell.find('.field_clip'), 'invalid'); + + // DateTime editor should close and revert value when the user presses escape + await cell.click(); + await gu.sendKeys($.ENTER, 'April 2, 1993', $.ESCAPE); + assert.equal(await cell.text(), '8:15am'); + }); + + it('should allow correct date reformatting', async function() { + var cell = await gu.getCellRC(0, 1); + + // Move to the first column + await cell.click(); + await gu.sendKeys('2016-01-08', $.ENTER); + + // Change type to 'Date' + await gu.setType('Date'); + await $('.test-type-transform-apply').wait().click(); + await gu.waitForServer(); // Make sure type is set + + // Check that the date is correct + await $('$Widget_dateFormat').wait(); + assert.equal(await cell.text(), '2016-01-08'); + + // Change format and check that date is reformatted + await gu.dateFormat('MMMM Do, YYYY'); + await gu.waitForServer(); + assert.equal(await cell.text(), 'January 8th, 2016'); + + // Try another format + await gu.dateFormat('DD MMM YYYY'); + await gu.waitForServer(); + assert.equal(await cell.text(), '08 Jan 2016'); + + // Change to custom format and check that the date is reformatted + await gu.dateFormat('Custom'); + await $('$Widget_dateCustomFormat .kf_text').click(); + await gu.sendKeys($.SELECT_ALL, 'dddd', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'Friday'); + }); + + it('should include a functioning date editor', async function() { + var cell = await gu.getCellRC(0, 1); + + // Date editor should open and replace incomplete format with YYYY-MM-DD + await cell.click(); + await gu.sendKeys($.ENTER); + assert.equal(await $('.celleditor_text_editor').val(), '2016-01-08'); + + // Date should be changable by clicking the calendar dates + await $('.celleditor_text_editor').sendKeys($.DOWN); // Opens date picker even if window has no focus. + await $('.datepicker .day:contains(19)').wait().click(); + await gu.sendKeys($.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'Tuesday'); + + // Date editor should convert Moment formats to datepicker safe formats + // Date editor should save the date on enter press + await gu.dateFormat('MMMM Do, YYYY'); + await cell.click(); + await gu.sendKeys($.ENTER); + assert.equal(await $('.celleditor_text_editor').val(), 'January 19th, 2016'); + await gu.sendKeys($.SELECT_ALL, 'February 20th, 2016', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'February 20th, 2016'); + + // Date editor should close and save value when the user clicks away + await cell.click(); + await gu.sendKeys($.ENTER, $.SELECT_ALL, $.DELETE); + await gu.getCellRC(0, 3).click(); // click away + await gu.waitForServer(); + assert.equal(await cell.text(), ''); + + // Date editor should close and revert value when the user presses escape + await cell.click(); + await gu.sendKeys($.ENTER, 'April 2, 1993', $.ESCAPE); + assert.equal(await cell.text(), ''); + }); + + it('should reload values correctly after reopen', async function() { + await gu.getCellRC(0, 0).click(); + await gu.sendKeys('February 20th, 2009', $.TAB, '8:15am', $.ENTER); + await gu.getCellRC(0, 1).click(); + await gu.sendKeys('January 19th, 1968', $.ENTER); + await gu.getCellRC(1, 1).click(); + await gu.sendKeys($.DELETE); + await gu.waitForServer(); + await gu.getCellRC(0, 2).click(); + await gu.waitAppFocus(true); + await gu.sendKeys('='); + await $('.test-editor-tooltip-convert').click(); + await gu.sendKeys('$A', $.ENTER); + await gu.waitForServer(); + await gu.waitAppFocus(true); + await gu.getCellRC(0, 3).click(); + await gu.sendKeys('='); + await gu.waitAppFocus(false); + await gu.sendKeys('$B', $.ENTER); + await gu.waitForServer(); + + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: ['A', 'B', 'C', 'D']}), [ + 'February 20th, 2009 8:15am', + 'January 19th, 1968', + '2009-02-20 08:15:00-08:00', + '1968-01-19', + '', '', '', '' + ]); + + // We don't have a quick way to shutdown a document and reopen from scratch. So instead, we'll + // make a copy of the document, and open that to test that values got saved correctly. + // TODO: it would be good to add a way to reload document from scratch, perhaps by reloading + // with a special URL fragment. + await gu.copyDoc(doc.id, true); + + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: ['A', 'B', 'C', 'D']}), [ + 'February 20th, 2009 8:15am', + 'January 19th, 1968', + '2009-02-20 08:15:00-08:00', + '1968-01-19', + '', '', '', '' + ]); + }); + + it('should support shortcuts to insert date/time', async function() { + await gu.openSidePane('field'); + // Check the types of the first two columns. + await gu.clickCellRC(0, 0); + await gu.assertType('DateTime'); + await gu.clickCellRC(0, 1); + await gu.assertType('Date'); + // Insert a few more columns: empty, Text, Numeric. + await addColumn(); + await addColumn(); + await addColumn(); + await gu.clickCellRC(0, 3); + await gu.setType('Numeric'); + await gu.clickCellRC(0, 4); + await gu.setType('Text'); + + // Override Date.now() and timezone in the current browser page to return a consistent value, + // used e.g. for the default for the year and month. + await driver.executeScript( + "Date.now = () => 1477548296087; " + // This value is 2016-10-27 02:04:56.087 EST + "exposeModulesForTests().then(() => { " + + "window.exposedModules.moment.tz.setDefault('America/New_York');" + + "});" + ); + + async function fillWithShortcuts() { + await gu.toggleSidePanel('right', 'close'); + + // Type the Date-only shortcut into each cell in the second row. + await gu.clickCellRC(1, 0); + for (var i = 0; i < 6; i++) { + await gu.sendKeys([$.MOD, ';'], $.TAB); + } + + // Type the Date-Time shortcut into each cell in the third row. + await gu.clickCellRC(2, 0); + for (i = 0; i < 6; i++) { + await gu.sendKeys([$.MOD, $.SHIFT, ';'], $.TAB); + } + } + + // Change document timezone to US/Hawaii (3 hours behind LA, which is TZ of the first column). + // We check that shortcuts for Text/Any columns use the document timezone. + await setGlobalTimezone('US/Hawaii'); + await fillWithShortcuts(); + // Compare the values. NOTE: this assumes EST timezone for the browser's local time. + assert.deepEqual(await gu.getGridValues({rowNums: [2, 3], cols: [0, 1, 2, 3, 4]}), [ + // Note that column A has Los_Angeles timezone set, so time differs from Hawaii. + // Note that Date column gets the date in Hawaii, not local or UTC (both 2016-10-27). + // The originally empty column had its type guessed as Date when the current date was first entered, + // hence "2016-10-26" appears in both rows. + "October 26th, 2016 11:04pm", "October 26th, 2016", "2016-10-26", "0", "2016-10-26", + "October 26th, 2016 11:04pm", "October 26th, 2016", "2016-10-26", "0", "2016-10-26 20:04:56", + ]); + + // Undo the 8 cells we actually filled in, and check that the empty column reverted to Any + await gu.undo(8); + await gu.clickCellRC(1, 2); + await gu.assertType('Any'); + + // Change document timezone back to America/New_York. + await setGlobalTimezone('America/New_York'); + await fillWithShortcuts(); + // Compare the values. NOTE: this assumes EST timezone for the browser's local time. + assert.deepEqual(await gu.getGridValues({rowNums: [2, 3], cols: [0, 1, 2, 3, 4]}), [ + // Note that column A has Los_Angeles timezone set, so date differs by one from New_York. + "October 26th, 2016 11:04pm", "October 27th, 2016", "2016-10-27", "0", "2016-10-27", + "October 26th, 2016 11:04pm", "October 27th, 2016", "2016-10-27", "0", "2016-10-27 02:04:56", + ]); + }); + + it('should allow navigating the datepicker with the keyboard', async function() { + // Change the date using the datepicker. + let cell = await gu.getCellRC(0, 1); + await cell.scrollIntoView({inline: "end"}).click(); + await gu.sendKeys($.ENTER); + await gu.waitAppFocus(false); + await gu.sendKeys($.UP, $.UP, $.LEFT, $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'January 11th, 1968'); + + // Do the same in the datetime editor. + cell = await gu.getCellRC(1, 0); + await cell.click(); + await gu.sendKeys($.ENTER); + await gu.waitAppFocus(false); + await gu.sendKeys($.UP, $.RIGHT, $.RIGHT, $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'October 28th, 2016 11:04pm'); + + // Start navigating the datepicker, then start typing to return to using the cell editor. + cell = await gu.getCellRC(1, 1); + await cell.click(); + // The first backspace should return to cell edit mode, then the following keys should + // change the year to 2009. + await gu.sendKeys($.ENTER); + await gu.waitAppFocus(false); + await gu.sendKeys($.DOWN, $.RIGHT, $.BACK_SPACE, '9', $.LEFT, $.BACK_SPACE, '0', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'October 27th, 2009'); + }); + + // NOTE: This addresses a bug where typical date entry formats were not recognized. + // See https://phab.getgrist.com/T308 + it('should allow using common formats to enter the date', async function() { + let cell = await gu.getCellRC(2, 1); + await cell.click(); + await gu.sendKeys('April 2 1993', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'April 2nd, 1993'); + + cell = await gu.getCellRC(1, 0); + await cell.click(); + await gu.sendKeys('December', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), `December 1st, 2016 11:04pm`); + + cell = await gu.getCellRC(0, 1); + await cell.click(); + await gu.sendKeys('7-Sep', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), `September 7th, 2016`); + + await cell.click(); + await gu.sendKeys('6/8', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), `June 8th, 2016`); + + // The selected format should take precedence over the default format when + // parsing the date. Entering the same thing as before (6/8) will yield a different + // result after changing the format. + await gu.openSidePane('field'); + cell = await gu.getCellRC(1, 1); + await cell.click(); + await gu.dateFormat('DD-MM-YYYY'); + await cell.click(); + await gu.sendKeys('6/8', $.ENTER); + await gu.waitForServer(); + await gu.dateFormat('MMMM Do, YYYY'); + assert.equal(await cell.text(), `August 6th, 2016`); + + cell = await gu.getCellRC(2, 1); + await cell.click(); + await gu.sendKeys('1937', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), `January 1st, 1937`); + }); + + it('should not attempt to parse non-dates', async function() { + // Should allow AltText + let cell = await gu.getCellRC(2, 1); + await cell.click(); + await gu.sendKeys('Applesauce', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), 'Applesauce'); + await assert.hasClass(cell.find('.field_clip'), 'invalid'); + // Should allow AltText even of numbers that cannot be parsed as dates. + // Manually entered numbers should not be read as timestamps. + cell = await gu.getCellRC(1, 0); + await cell.click(); + await gu.sendKeys('100000', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), '100000 11:04pm'); + await assert.hasClass(cell.find('.field_clip'), 'invalid'); + // Should give AltText if just the time is entered but not the date. + cell = await gu.getCellRC(1, 0); + await cell.click(); + await gu.sendKeys($.ENTER, $.TAB, '3', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), '100000 11:04pm 3'); + await assert.hasClass(cell.find('.field_clip'), 'invalid'); + }); + + it("should allow working with naive date object", async function() { + await gu.clickCellRC(0, 1); + await gu.sendKeys([$.ALT, '=']); + await gu.waitForServer(); + await gu.sendKeys("Diff", $.ENTER); + await gu.waitForServer(); + await gu.sendKeys('='); + await gu.waitAppFocus(false); + await gu.sendKeys('($A-DTIME($B)).total_seconds()', $.ENTER); + await gu.waitForServer(); + await gu.waitAppFocus(); + assert.deepEqual(await gu.getCellRC(0, 2).text(), '-230211900'); + + // change global timezone should recompute formula + await setGlobalTimezone('Paris'); + assert.deepEqual(await gu.getCellRC(0, 2).text(), '-230190300'); + }); + + // NOTE: This tests a specific bug where AltText values in a column that has been coverted + // to a date column do not respond to updates until refresh. This bug was exposed via the + // error dom in FieldBuilder not being re-evaluated after a column transform. + it('should allow deleting AltText values in a newly changed Date column', async function() { + // Change the type to text and enter a text value. + await gu.clickCellRC(0, 1); + await gu.setType('Text'); + await gu.applyTypeConversion(); + await gu.clickCellRC(2, 1); + await gu.sendKeys('banana', $.ENTER); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(2, 1).text(), 'banana'); + + // Change back to Date and try to remove the text. + await gu.setType('Date'); + await $('.test-type-transform-apply').wait().click(); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(2, 1).text(), 'banana'); + await gu.clickCellRC(2, 1); + await gu.sendKeys($.BACK_SPACE); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(2, 1).text(), ''); + await gu.undo(); + }); + + it("should report informative error when AltText is used for date", async function() { + // Enter a formula column that uses a date. + await gu.clickCellRC(0, 1); + await gu.sendKeys([$.ALT, '=']); + await gu.waitForServer(); + await gu.sendKeys("Month", $.ENTER); + await gu.waitForServer(); + await gu.sendKeys("=$B.month", $.ENTER); + await gu.waitForServer(); + + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: ['B', 'Month']}), [ + "June 8th, 2016", "6", + "August 6th, 2016", "8", + "banana", "#Invalid Date: banana", + "", "#AttributeError", + ]); + }); + + it('should default timezone to document\'s timezone', async function() { + // add a DateTime column + await addDateTimeColumn(); + await gu.timeFormat('HH:mm:ss'); + // BUG: it is required to click somewhere after setting the type of a column for the shortcut to + // work + // TODO: removes gu.getCellRC(1, 3).click() below when its fixed + await gu.getCellRC(1, 3).click(); + // get the current date + await gu.sendKeys([$.MOD, $.SHIFT, ';']); + await gu.waitForServer(); + const date1 = await gu.getCellRC(1, 3).text(); + // check default timezone + assert.equal(await $('.test-tz-autocomplete input').val(), 'Europe/Paris'); + // set global document timezone to 'Europe/Paris' + await setGlobalTimezone('America/Los_Angeles'); + // add another DateTime column + await addDateTimeColumn(); + await gu.timeFormat('HH:mm:ss'); + // todo: same as for gu.getCellRC(1, 3).click(); + await gu.getCellRC(1, 4).click(); + // get the current date + await gu.sendKeys([$.MOD, $.SHIFT, ';']); + await gu.waitForServer(); + const date2 = await gu.getCellRC(1, 4).text(); + // check default timezone + assert.equal(await $('.test-tz-autocomplete input').val(), 'America/Los_Angeles'); + // check that the delta between date1 and date2 is coherent with the delta between + // 'Europe/Paris' and 'America/Los_Angeles' timezones. + const delta = (new Date(date1) - new Date(date2)) / 1000 / 60 / 60; + assert.isAbove(delta, 6); + assert.isBelow(delta, 12); + }); +}); + +async function addDateTimeColumn() { + await addColumn(); + return gu.setType('DateTime'); +} + +async function addColumn() { + await gu.sendKeys([$.ALT, '=']); + await gu.waitForServer(); + return gu.sendKeys($.ESCAPE); +} + +async function setGlobalTimezone(name) { + await $('.test-user-icon').click(); // open the user menu + await $('.test-dm-doc-settings').click(); + await $('.test-tz-autocomplete').click(); + await $(`.test-acselect-dropdown li:contains(${name})`).click(); + await gu.waitForServer(); + await driver.navigate().back(); +} diff --git a/test/nbrowser/DetailView.ntest.js b/test/nbrowser/DetailView.ntest.js new file mode 100644 index 00000000..9c3a00ec --- /dev/null +++ b/test/nbrowser/DetailView.ntest.js @@ -0,0 +1,118 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe("DetailView.ntest", function () { + const cleanup = test.setupTestSuite(this); + gu.bigScreen(); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "Favorite_Films.grist", true); + + // Open the view tab. + await gu.openSidePane('view'); + + // Open the 'All' view + await gu.actions.selectTabView('All'); + + // close the side pane + await gu.toggleSidePanel('left', 'close'); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should allow switching between card and detail view', async function() { + await gu.actions.viewSection('Performances detail').selectSection(); + + // Check that the detail cells have the correct values. + assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tom Hanks']); + await $('.grist-single-record__menu .detail-right').click(); + // rowNum is always 1 for detail cells now. + assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tim Allen']); + + // Swap to Card List view, check values. + await $('.test-right-panel button:contains(Change Widget)').click(); + await $('.test-wselect-type:contains(Card List)').click(); + await $('.test-wselect-addBtn').click(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1, 2]), + ['Tom Hanks', 'Tim Allen']); + + // Swap back to Card view, re-check values. + await $('.test-right-panel button:contains(Change Widget)').click(); + await $('.test-wselect-type:contains(Card)').click(); + await $('.test-wselect-addBtn').click(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tim Allen']); + await $('.grist-single-record__menu .detail-left').click(); + assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tom Hanks']); + }); + + it('should allow editing cells', async function() { + // Updates should be reflected in the detail floating rowModel cell. + await gu.sendKeys('Roger Federer', $.ENTER); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Roger Federer']); + + // Undo updates should be reflected as well. + await gu.sendKeys([$.MOD, 'z']); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tom Hanks']); + }); + + // Note: This is a test of a specific bug related to the detail rowModel being resized after + // being unset. + it('should allow row resize operations after switching section type', async function() { + // Switch to Card List view and enter a formula. This should cause the scrolly to resize all rows. + // If the detail view rowModel is wrongly resized, the action will fail. + await $('.test-right-panel button:contains(Change Widget)').click(); + await $('.test-wselect-type:contains(Card List)').click(); + await $('.test-wselect-addBtn').click(); + await gu.waitForServer(); + await gu.sendKeys('='); + await $('.test-editor-tooltip-convert').click(); // Convert to a formula + await gu.sendKeys('100', $.ENTER); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1, 2, 3, 4]), + ['100', '100', '100', '100']); + }); + + it('should include an add record row', async function() { + // Should include an add record row which works in card view and detail view. + // Check that adding 'Jurassic Park' to the card view add record row adds it as a row. + // await gu.selectSectionByTitle("Performances detail"); + //await gu.sendKeys([$.MOD, $.DOWN]); + await $('.g_record_detail:nth-child(14) .field_clip').eq(1).wait().click(); + await gu.sendKeys('Jurassic Park', $.ENTER); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleDetailCells('Film', [14]), ['Jurassic Park']); + // Check that adding 'Star Wars' to the detail view add record row adds it as a row. + await $('.test-right-panel button:contains(Change Widget)').click(); + await $('.test-wselect-type:contains(Card)').click(); + await $('.test-wselect-addBtn').click(); + await gu.waitForServer(); + await $('.detail-add-btn').wait().click(); + // Card view, so rowNum is now 1 + await gu.getDetailCell('Film', 1).click(); + await gu.sendKeys('Star Wars', $.ENTER); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleDetailCells('Film', [1]), ['Star Wars']); + + // Should allow pasting into the add record row. + await gu.getDetailCell('Actor', 1).click(); + await gu.sendKeys($.COPY); + await $('.detail-add-btn').click(); + // Paste '100' into the last field of the row and check that it is added as its own row. + await gu.getDetailCell('Character', 1).click(); + await gu.sendKeys($.PASTE); + await gu.waitForServer(); + assert.deepEqual(await gu.getDetailCell('Character', 1).text(), '100'); + + // Should not throw errors when deleting the add record row. + await $('.detail-add-btn').click(); + await gu.sendKeys([$.MOD, $.DELETE]); + // Errors will be detected in afterEach. + }); +}); diff --git a/test/nbrowser/DocTutorial.ts b/test/nbrowser/DocTutorial.ts index 806ef994..86d5782d 100644 --- a/test/nbrowser/DocTutorial.ts +++ b/test/nbrowser/DocTutorial.ts @@ -6,7 +6,7 @@ import {server, setupTestSuite} from 'test/nbrowser/testUtils'; import {EnvironmentSnapshot} from 'test/server/testUtils'; describe('DocTutorial', function () { - this.timeout(30000); + this.timeout(60000); setupTestSuite(); gu.bigScreen(); @@ -578,7 +578,7 @@ describe('DocTutorial', function () { // Check that the changes we made earlier are included. assert.equal( - await driver.find('.test-doc-tutorial-popup p').getText(), + await driver.findWait('.test-doc-tutorial-popup p', 2000).getText(), 'Welcome to the Grist Basics tutorial V2.' ); }); diff --git a/test/nbrowser/Export.ntest.js b/test/nbrowser/Export.ntest.js new file mode 100644 index 00000000..a61c65fc --- /dev/null +++ b/test/nbrowser/Export.ntest.js @@ -0,0 +1,61 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +const fse = require('fs-extra'); +const path = require('path'); +const axios = require('axios'); + +// Authentication headers to include into axios requests. +const headers = {Authorization: 'Bearer api_key_for_userz'}; + +describe('Export.ntest', function() { + const cleanup = test.setupTestSuite(this); + const pathsExpected = { + base: path.resolve(gu.fixturesRoot, "export-csv", "CCTransactions.csv"), + sorted: path.resolve(gu.fixturesRoot, "export-csv", "CCTransactions-DBA-desc.csv") + }; + let dataExpected = {}; + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "CCTransactions.grist", true); + + // Read the expected contents before the test case starts, to simplify the promises there. + // (don't really need that simplification any more though). + for (const [key, fname] of Object.entries(pathsExpected)) { + dataExpected[key] = await fse.readFile(fname, {encoding: 'utf8'}); + } + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should export correct data', async function() { + await $('.test-tb-share').click(); + // Once the menu opens, get the href of the link. + await $('.grist-floating-menu').wait(); + const href = await $('.grist-floating-menu a:contains(CSV)').wait().getAttribute('href'); + // Download the data at the link and compare to expected. + const resp = await axios.get(href, {responseType: 'text', headers}); + assert.equal(resp.headers['content-disposition'], + 'attachment; filename="CCTransactions.csv"'); + assert.equal(resp.data, dataExpected.base); + await $('.test-tb-share').click(); + }); + + it('should respect active sort', async function() { + await gu.openColumnMenu('Doing Business As'); + await $('.grist-floating-menu .test-sort-dsc').click() + await $('.test-tb-share').click(); + // Once the menu opens, get the href of the link. + await $('.grist-floating-menu').wait(); + const href = await $('.grist-floating-menu a:contains(CSV)').wait().getAttribute('href'); + // Download the data at the link and compare to expected. + const resp = await axios.get(href, {responseType: 'text', headers}); + assert.equal(resp.data, dataExpected.sorted); + }); + + // TODO: We should have a test case with multiple sections on the screen, that checks that + // export runs for the currently selected section. +}); diff --git a/test/nbrowser/FieldConfigTab.ntest.js b/test/nbrowser/FieldConfigTab.ntest.js new file mode 100644 index 00000000..756e1045 --- /dev/null +++ b/test/nbrowser/FieldConfigTab.ntest.js @@ -0,0 +1,195 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('FieldConfigTab.ntest', function() { + const cleanup = test.setupTestSuite(this); + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "Hello.grist", true); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it("should stay open when switching between columns or views", async function() { + // Add another table to the document. + await gu.actions.addNewTable(); + + await gu.actions.selectTabView('Table1'); + await gu.openSidePane('field'); + var fieldLabel = await $(".test-field-label").wait(assert.isDisplayed).elem(); + + await assert.isDisplayed(fieldLabel); + assert.equal(await fieldLabel.val(), "A"); + + // Move cursor to a different column. + await $("$GridView_columnLabel:nth-child(2)").click(); + assert.equal(await fieldLabel.val(), "B"); + + // Switch to another view. The first column should be selected. + await gu.actions.selectTabView('Table2'); + + fieldLabel = $(".test-field-label").elem(); + await assert.isDisplayed(fieldLabel); + assert.equal(await fieldLabel.val(), "A"); + }); + + it("should support changing the column label and id together", async function() { + await gu.actions.selectTabView('Table1'); + var fieldLabel = await $(".test-field-label").elem(); + await gu.clickCellRC(0, 0); // Move back to the first cell. + assert.equal(await fieldLabel.val(), "A"); + await $(".test-field-label").sendNewText("foo"); + await gu.waitForServer(); + + // Check that both the label and colId changed in the side pane. + assert.equal(await fieldLabel.val(), "foo"); + await $(".test-field-col-id").wait(async function(el) { return assert.equal(await el.val(), "$foo"); }); + + // Check that the label changed among column headers. + assert.equal(await $("$GridView_columnLabel:nth-child(1)").text(), "foo"); + }); + + it("should support changing the column label and id separately", async function() { + await gu.actions.selectTabView('Table1'); + await $("$GridView_columnLabel:nth-child(2)").click(); + var fieldLabel = $(".test-field-label"); + assert.equal(await fieldLabel.val(), "B"); + + // Uncheck the "derive id" checkbox. + var deriveIdCheckbox = $(".test-field-derive-id"); + assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]')); + await deriveIdCheckbox.click(); + await gu.waitForServer(); + assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]')); + + // Check that only the label changed in the side pane. + await fieldLabel.sendNewText("bar"); + await gu.waitForServer(); + assert.equal(await fieldLabel.val(), "bar"); + await $("$GridView_columnLabel:nth-child(2)").wait(async function(el) { return assert.equal(await el.text(), "bar"); }); + + // Id should be unchanged, but we should be able to change it now. + assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2, 3, 4], cols: [1] }), + ['', 'world', '', '']); + assert(await $(".test-field-col-id").val(), "B"); + await $(".test-field-col-id").sendNewText("baz"); + assert(await $(".test-field-col-id").val(), "baz"); + assert.equal(await fieldLabel.val(), "bar"); + assert.equal(await $("$GridView_columnLabel:nth-child(1)").text(), "foo"); + assert.equal(await $("$GridView_columnLabel:nth-child(2)").text(), "bar"); + + // Make sure the changing Ids does not effect the data in the column + assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2, 3, 4], cols: [1] }), + ['', 'world', '', '']); + await assert.hasClass(gu.getCell(0, 1).find('.field_clip'), 'invalid', false); + }); + + describe('Duplicate Labels', async function() { + let fieldLabel, deriveIdCheckbox; + + beforeEach(() => { + fieldLabel = $(".test-field-label"); + deriveIdCheckbox = $(".test-field-derive-id"); + }); + + it('should allow duplicate labels with underived colIds', async function() { + // Change column 4 to have the same label as column 1 + await $("$GridView_columnLabel:nth-child(4)").click(); + assert.equal(await fieldLabel.val(), "D"); + assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]')); + await deriveIdCheckbox.click(); + await gu.waitForServer(); + assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]')); + await fieldLabel.sendNewText("foo"); + // Columns 1 and 4 should both be named foo + await $("$GridView_columnLabel:nth-child(1)").wait(async function(el) { return assert.equal(await el.text(), "foo"); }); + await $("$GridView_columnLabel:nth-child(4)").wait(async function(el) { return assert.equal(await el.text(), "foo"); }); + // But colId should be unchanged + assert(await $(".test-field-col-id").val(), "D"); + }); + + it('should allow duplicate labels with derived colIds', async function() { + // Now clicking the derive box should be leave the labels the same + // but the conflicting Id should be sanitized + await deriveIdCheckbox.click(); + await gu.waitForServer(); + assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]')); + await deriveIdCheckbox.click(); + await gu.waitForServer(); + assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]')); + await $("$GridView_columnLabel:nth-child(1)").scrollIntoView({inline: "end"}).click(); + await $("$GridView_columnLabel:nth-child(1)").wait(async function(el) { return assert.equal(await el.text(), "foo"); }); + assert(await $(".test-field-col-id").val(), "foo"); + await $("$GridView_columnLabel:nth-child(4)").click(); + await $("$GridView_columnLabel:nth-child(4)").wait(async function(el) { return assert.equal(await el.text(), "foo"); }); + assert(await $(".test-field-col-id").val(), "foo2"); + }); + + it('should not change the derived id unnecessarly', async function() { + // Toggling the box should not change the derived Id + await deriveIdCheckbox.click(); + await gu.waitForServer(); + assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]')); + await deriveIdCheckbox.click(); + await gu.waitForServer(); + assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]')); + assert(await $(".test-field-col-id").val(), "foo2"); + }); + + it('should not automatically modify the derived checkbox', async function() { + // When derived labels are changed to an existing Id, the derived box should remain checked + // even if the id and label are different + await $("$GridView_columnLabel:nth-child(1)").scrollIntoView({inline: "end"}).click(); + await $("$GridView_columnLabel:nth-child(1)").wait(async function(el) { return assert.equal(await el.text(), "foo"); }); + assert(await $(".test-field-col-id").val(), "foo"); + assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]')); + await fieldLabel.sendNewText("foo2"); + await gu.waitForServer(); + assert.equal(await fieldLabel.val(), "foo2"); + assert(await $(".test-field-col-id").val(), "foo2_2"); + assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]')); + }); + + it('should allow out of sync colIds to still derive from labels', async function() { + // Entering a new label should still sync the Id + await fieldLabel.sendNewText("foobar"); + await gu.waitForServer(); + assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]')); + await deriveIdCheckbox.click(); + assert(await $(".test-field-col-id").val(), "foobar"); + }); + }); + + it("should allow editing column data after column rename", async function() { + await gu.actions.selectTabView('Table1'); + await $("$GridView_columnLabel:nth-child(3)").click(); + assert.equal(await $(".test-field-label").val(), "C"); + + // Switch type to numeric. This makes it easier to tell whether the value actually gets + // processed by the server. + await gu.setType('Numeric'); + await $('.test-type-transform-apply').wait().click(); + await gu.waitForServer(); + var cell = await gu.getCellRC(0, 2); + await cell.click(); // row index 0, column index 2 + await gu.sendKeys('17', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), '17'); + await assert.hasClass(cell.find('.field_clip'), 'invalid', false); + + // Rename the column, make sure we can still type into it, and get results from the server. + await $(".test-field-label").sendNewText("c2"); + await gu.waitForServer(); + assert.equal(await $("$GridView_columnLabel:nth-child(3)").text(), "c2"); + await gu.waitForServer(); + cell = await gu.getCellRC(0, 2); + await cell.click(); // row index 0, column index 2 + await gu.sendKeys('23', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell.text(), '23'); + await assert.hasClass(cell.find('.field_clip'), 'invalid', false); + }); + +}); diff --git a/test/nbrowser/FieldSettings.ntest.js b/test/nbrowser/FieldSettings.ntest.js new file mode 100644 index 00000000..f5332d17 --- /dev/null +++ b/test/nbrowser/FieldSettings.ntest.js @@ -0,0 +1,324 @@ +/** + * When a field is present in multiple views, the different copies of it may use common or + * separate settings. This test verifies these behaviors and switching between them. + */ + +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('FieldSettings.ntest', function() { + const cleanup = test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "FieldSettings.grist", true); + + await gu.actions.selectTabView("Rates"); + await gu.waitForServer(); + await gu.openSidePane('field'); + }); + + afterEach(async function() { + await gu.userActionsCollect(false); + return gu.checkForErrors(); + }); + + async function checkSections(position, settingsFunc, expectedBySection) { + await gu.waitForServer(); + for (let sectionName in expectedBySection) { + let [cellText, settingsValue] = expectedBySection[sectionName]; + const cell = await gu.getCell(Object.assign({section: sectionName}, position)); + await gu.clickCell(cell); + assert.equal(await cell.text(), cellText); + assert.equal(await settingsFunc(), settingsValue); + } + } + + + it('should respect common settings for regular options', async function() { + await gu.userActionsCollect(true); + + // Sections 'A' and 'B' use common settings, and 'C' uses separate. + // Check that changing the setting in A affects B, but does not affect C. + await gu.clickCell({section: 'A', rowNum: 1, col: 1}); + assert.equal(await gu.dateFormat(), 'YYYY-MM-DD'); + await gu.dateFormat('MM/DD/YYYY'); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['01/02/2012', 'MM/DD/YYYY'], + B: ['01/02/2012', 'MM/DD/YYYY'], + C: ['2012-01-02', 'YYYY-MM-DD'], + }); + + // Check that changing C does not affect A or B. + await gu.dateFormat('MMMM Do, YYYY'); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['01/02/2012', 'MM/DD/YYYY'], + B: ['01/02/2012', 'MM/DD/YYYY'], + C: ['January 2nd, 2012', 'MMMM Do, YYYY'], + }); + + // Verify actions emitted. These are obtained from pasting the output, but the important thing + // about them is that it's one action for each change, one for the table, one for the field. + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Tables_column", 15, {"widgetOptions": + '{"widget":"TextBox","dateFormat":"MM/DD/YYYY","isCustomDateFormat":false,"alignment":"left"}'}], + ["UpdateRecord", "_grist_Views_section_field", 145, {"widgetOptions": + '{"widget":"TextBox","dateFormat":"MMMM Do, YYYY","isCustomDateFormat":false,"alignment":"left"}'}], + ]); + + // Undo, checking that the 2 actions only require 2 undos, and verify. + await gu.undo(2); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['2012-01-02', 'YYYY-MM-DD'], + B: ['2012-01-02', 'YYYY-MM-DD'], + C: ['2012-01-02', 'YYYY-MM-DD'], + }); + }); + + + it('should respect common settings for visibleCol', async function() { + // Same as above but for changing "visibleCol", which involves extra actions to update the + // display helper column. + await gu.userActionsCollect(true); + await gu.clickCell({section: 'A', rowNum: 1, col: 0}); + assert.equal(await $('.test-fbuilder-ref-col-select .test-select-row').text(), 'Full Name'); + await gu.setVisibleCol('Last Name'); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein', 'Last Name'], + B: ['Klein', 'Last Name'], + C: ['Klein, Cordelia', 'Full Name'], + }); + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Tables_column", 12, {"visibleCol":3}], + ["SetDisplayFormula", "Rates", null, 12, "$Person.Last_Name"], + ]); + + await gu.clickCell({section: 'C', rowNum: 1, col: 0}); + await gu.setVisibleCol('First Name'); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein', 'Last Name'], + B: ['Klein', 'Last Name'], + C: ['Cordelia', 'First Name'], + }); + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Views_section_field", 141, {"visibleCol":2}], + ["SetDisplayFormula", "Rates", 141, null, "$Person.First_Name"], + ]); + + // Same for changing "visibleCol" to the special "RowID" value. + await gu.clickCell({section: 'A', rowNum: 1, col: 0}); + await gu.setVisibleCol('Row ID'); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['People[14]', 'Row ID'], + B: ['People[14]', 'Row ID'], + C: ['Cordelia', 'First Name'], + }); + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Tables_column", 12, {"visibleCol":0}], + ["SetDisplayFormula", "Rates", null, 12, ""], + ]); + + // Undo here so we can verify that per-field "Row ID" choice overrides per-column choice. + await gu.undo(); + + await gu.userActionsCollect(true); + await gu.clickCell({section: 'C', rowNum: 1, col: 0}); + await gu.setVisibleCol('Row ID'); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein', 'Last Name'], + B: ['Klein', 'Last Name'], + C: ['People[14]', 'Row ID'], + }); + + // Verify actions emitted. + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Views_section_field", 141, {"visibleCol":0}], + ["SetDisplayFormula", "Rates", 141, null, ""], + ]); + + // Undo; we made 4 actions, but already ran one undo earlier. + await gu.undo(3); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein, Cordelia', 'Full Name'], + B: ['Klein, Cordelia', 'Full Name'], + C: ['Klein, Cordelia', 'Full Name'], + }); + }); + + + it('should allow switching to separate settings', async function() { + // Switch B to use separate settings. + await gu.userActionsCollect(true); + await gu.clickCell({section: 'B', rowNum: 1, col: 1}); + await gu.fieldSettingsUseSeparate(); + + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Views_section_field", 140, {"widgetOptions": + '{"widget":"TextBox","dateFormat":"YYYY-MM-DD","isCustomDateFormat":false,"alignment":"left"}'}], + ]); + + // Verify that options are preserved. + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['2012-01-02', 'YYYY-MM-DD'], + B: ['2012-01-02', 'YYYY-MM-DD'], + C: ['2012-01-02', 'YYYY-MM-DD'], + }); + + // Change option in B and see that A and C are not affected. + await gu.clickCell({section: 'B', rowNum: 1, col: 1}); + await gu.dateFormat('MM/DD/YYYY'); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['2012-01-02', 'YYYY-MM-DD'], + B: ['01/02/2012', 'MM/DD/YYYY'], + C: ['2012-01-02', 'YYYY-MM-DD'], + }); + + // Change option in A and see that B is not affected. + await gu.clickCell({section: 'A', rowNum: 1, col: 1}); + await gu.dateFormat('MMMM Do, YYYY'); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['January 2nd, 2012', 'MMMM Do, YYYY'], + B: ['01/02/2012', 'MM/DD/YYYY'], + C: ['2012-01-02', 'YYYY-MM-DD'], + }); + + await gu.undo(3); + }); + + + it('should allow switching to separate settings for visibleCol', async function() { + // Same as above for changing 'visibleCol' option; after separating, try changing B, then A. + await gu.userActionsCollect(true); + await gu.clickCell({section: 'B', rowNum: 2, col: 0}); + await gu.fieldSettingsUseSeparate(); + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Views_section_field", 136, {"widgetOptions":'{"widget":"Reference"}'}], + ["UpdateRecord", "_grist_Views_section_field", 136, {"visibleCol":4}], + ["SetDisplayFormula", "Rates", 136, null, "$Person.Full_Name"], + ]); + + await gu.setVisibleCol('First Name'); + await gu.clickCell({section: 'A', rowNum: 2, col: 0}); + await gu.setVisibleCol('Last Name'); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein', 'Last Name'], + B: ['Cordelia', 'First Name'], + C: ['Klein, Cordelia', 'Full Name'], + }); + await gu.undo(3); + }); + + + it('should allow reverting to common settings', async function() { + // Change column in C to use different settings from A. + await gu.clickCell({section: 'C', rowNum: 1, col: 1}); + await gu.dateFormat('MMMM Do, YYYY'); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['2012-01-02', 'YYYY-MM-DD'], + B: ['2012-01-02', 'YYYY-MM-DD'], + C: ['January 2nd, 2012', 'MMMM Do, YYYY'], + }); + + // Revert C to use common settings. Check that it matches A. + await gu.userActionsCollect(true); + await gu.fieldSettingsRevertToCommon(); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['2012-01-02', 'YYYY-MM-DD'], + B: ['2012-01-02', 'YYYY-MM-DD'], + C: ['2012-01-02', 'YYYY-MM-DD'], + }); + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Views_section_field", 145, {"widgetOptions":""}], + ]); + await gu.undo(2); + }); + + + it('should allow reverting to common settings for visibleCol', async function() { + // Same as above for reverting 'visiblecCol'. + await gu.clickCell({section: 'C', rowNum: 2, col: 0}); + await gu.setVisibleCol('Last Name'); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein, Cordelia', 'Full Name'], + B: ['Klein, Cordelia', 'Full Name'], + C: ['Klein', 'Last Name'], + }); + await gu.userActionsCollect(true); + await gu.fieldSettingsRevertToCommon(); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein, Cordelia', 'Full Name'], + B: ['Klein, Cordelia', 'Full Name'], + C: ['Klein, Cordelia', 'Full Name'], + }); + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Views_section_field", 141, {"widgetOptions":""}], + ["UpdateRecord", "_grist_Views_section_field", 141, {"visibleCol":0}], + ["SetDisplayFormula", "Rates", 141, null, ""], + ]); + await gu.undo(2); + }); + + + it('should allow saving separate settings as common', async function() { + // Change column C to use different settings from A. + await gu.clickCell({section: 'C', rowNum: 1, col: 1}); + await gu.dateFormat('MMMM Do, YYYY'); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['2012-01-02', 'YYYY-MM-DD'], + B: ['2012-01-02', 'YYYY-MM-DD'], + C: ['January 2nd, 2012', 'MMMM Do, YYYY'], + }); + + // Save C settings as common settings. Check that A and B now match. + await gu.userActionsCollect(true); + await gu.fieldSettingsSaveAsCommon(); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['January 2nd, 2012', 'MMMM Do, YYYY'], + B: ['January 2nd, 2012', 'MMMM Do, YYYY'], + C: ['January 2nd, 2012', 'MMMM Do, YYYY'], + }); + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Tables_column", 15, {"widgetOptions": + '{"widget":"TextBox","dateFormat":"MMMM Do, YYYY","isCustomDateFormat":false,"alignment":"left"}'}], + ["UpdateRecord", "_grist_Views_section_field", 145, {"widgetOptions":""}], + ]); + await gu.undo(2); + await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), { + A: ['2012-01-02', 'YYYY-MM-DD'], + B: ['2012-01-02', 'YYYY-MM-DD'], + C: ['2012-01-02', 'YYYY-MM-DD'], + }); + }); + + + it('should allow saving separate settings as common for visibleCol', async function() { + // Same as above for saving 'visiblecCol'. + await gu.clickCell({section: 'C', rowNum: 2, col: 0}); + await gu.setVisibleCol('Last Name'); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein, Cordelia', 'Full Name'], + B: ['Klein, Cordelia', 'Full Name'], + C: ['Klein', 'Last Name'], + }); + await gu.userActionsCollect(true); + await gu.fieldSettingsSaveAsCommon(); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein', 'Last Name'], + B: ['Klein', 'Last Name'], + C: ['Klein', 'Last Name'], + }); + await gu.userActionsVerify([ + ["UpdateRecord", "_grist_Tables_column", 12, {"visibleCol":3}], + ["SetDisplayFormula", "Rates", null, 12, "$Person.Last_Name"], + ["UpdateRecord", "_grist_Views_section_field", 141, {"widgetOptions":""}], + ["UpdateRecord", "_grist_Views_section_field", 141, {"visibleCol":0}], + ["SetDisplayFormula", "Rates", 141, null, ""], + ]); + await gu.undo(2); + await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), { + A: ['Klein, Cordelia', 'Full Name'], + B: ['Klein, Cordelia', 'Full Name'], + C: ['Klein, Cordelia', 'Full Name'], + }); + }); +}); diff --git a/test/nbrowser/FillLinkedRecords.ntest.js b/test/nbrowser/FillLinkedRecords.ntest.js new file mode 100644 index 00000000..bb124994 --- /dev/null +++ b/test/nbrowser/FillLinkedRecords.ntest.js @@ -0,0 +1,148 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +/** + * This test verifies that when a section is auto-filtered using section-linking, newly added + * records automatically get assigned the filter value. + */ +describe('FillLinkedRecords.ntest', function() { + const cleanup = test.setupTestSuite(this); + + gu.bigScreen(); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "Favorite_Films.grist", true); + await gu.toggleSidePanel("left", "close"); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should auto-fill values when typing into add-row', async function() { + await gu.openSidePane('view'); + await $('.test-config-data').click(); + await gu.actions.selectTabView('All'); + + // Link the sections first since the sample document start with no links. + // Connect Friends -> Films + await gu.getSection('Films record').click(); + await $('.test-right-select-by').click(); + await $('.test-select-row:contains(Friends record)').click(); + await gu.waitForServer(); + + // Connect Films -> Performances grid + await gu.getSection('Performances record').click(); + await $('.test-right-select-by').click(); + await $('.test-select-row:contains(Films record)').click(); + await gu.waitForServer(); + + // Connect Films -> Performances detail + await gu.getSection('Performances detail').click(); + await $('.test-right-select-by').click(); + await $('.test-select-row:contains(Films record)').click(); + await gu.waitForServer(); + + // Now pick a movie, and select the Performances grid. + await gu.clickCell({section: 'Films record', col: 0, rowNum: 2}); + await gu.actions.viewSection('Performances record').selectSection(); + + // It should have just two records initially, with an Add-New row. + assert.equal(await gu.getGridLastRowText(), '3'); + assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [2, 3]}), [ + 'Robin Wright', 'Forrest Gump', + '', '']); + + // Add a record, and ensure it shows up, and has Film auto-filled in. + await gu.userActionsCollect(true); + await gu.addRecord(['Rebecca Williams']); + await gu.userActionsVerify([ + ["AddRecord", "Performances", null, {"Actor": "Rebecca Williams", "Film": 2}] + ]); + assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [2, 3]}), [ + 'Robin Wright', 'Forrest Gump', + 'Rebecca Williams', 'Forrest Gump']); + assert.equal(await gu.getGridLastRowText(), '4'); + }); + + it('should auto-fill values when inserting records', async function() { + // Click another movie, and check the values we see. + await gu.clickCell({section: 'Films record', col: 0, rowNum: 5}); + await gu.actions.viewSection('Performances record').selectSection(); + assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [1, 2]}), [ + 'Christian Bale', 'The Dark Knight', + 'Heath Ledger', 'The Dark Knight' + ]); + assert.equal(await gu.getGridLastRowText(), '3'); + + // Add a couple of records in Performances grid using keyboard shortcuts. + await gu.clickCell({col: 0, rowNum: 3}); + await gu.sendKeys([$.MOD, $.SHIFT, $.ENTER]); + await gu.clickCell({col: 0, rowNum: 1}); + await gu.sendKeys([$.MOD, $.ENTER]); + await gu.waitForServer(); + + // Verify they are shown where expected with Film filled in. + assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [1, 2, 3, 4]}), [ + 'Christian Bale', 'The Dark Knight', + '', 'The Dark Knight', + 'Heath Ledger', 'The Dark Knight', + '', 'The Dark Knight', + ]); + assert.equal(await gu.getGridLastRowText(), '5'); + + // Add a record in Performances detail using keyboard shortcuts. + await gu.actions.viewSection('Performances detail').selectSection(); + assert.deepEqual(await gu.getDetailValues({cols: ['Actor', 'Film'], rowNums: [1]}), + ['Christian Bale', 'The Dark Knight']); + await gu.sendKeys([$.MOD, $.ENTER]); + await gu.waitForServer(); + + // Verify the record is shown with Film filled in, and added to the grid section too. + // Note: rowNum needs to be 1 now for card views without row numbers shown. + assert.deepEqual(await gu.getDetailValues({cols: ['Actor', 'Film'], rowNums: [1]}), + ['', 'The Dark Knight']); + + await gu.actions.viewSection('Performances record').selectSection(); + assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [1, 2, 3, 4, 5]}), [ + 'Christian Bale', 'The Dark Knight', + '', 'The Dark Knight', + '', 'The Dark Knight', + 'Heath Ledger', 'The Dark Knight', + '', 'The Dark Knight', + ]); + assert.equal(await gu.getGridLastRowText(), '6'); + + // Undo the record insertions. + await gu.undo(3); + }); + + it('should auto-fill when pasting data', async function() { + // Click a movie, and check the values we expect to start with. + await gu.clickCell({section: 'Films record', col: 0, rowNum: 6}); + await gu.actions.viewSection('Performances record').selectSection(); + assert.deepEqual(await gu.getGridValues({cols: [0, 1, 2], rowNums: [1, 4]}), [ + 'Chris Evans', 'The Avengers', 'Steve Rogers', + 'Scarlett Johansson', 'The Avengers', 'Natasha Romanoff', + ]); + assert.equal(await gu.getGridLastRowText(), '5'); + + // Copy a range of three values, and paste them into the Add-New row. + await gu.clickCell({col: 2, rowNum: 1}); + await gu.sendKeys([$.SHIFT, $.DOWN, $.DOWN], $.COPY); + await gu.clickCell({col: 2, rowNum: 5}); + await gu.sendKeys($.PASTE); + await gu.waitForServer(); + + // Verify that three new rows now show up, with Film auto-filled. + assert.deepEqual(await gu.getGridValues({cols: [0, 1, 2], rowNums: [1, 4, 5, 6, 7]}), [ + 'Chris Evans', 'The Avengers', 'Steve Rogers', + 'Scarlett Johansson', 'The Avengers', 'Natasha Romanoff', + '', 'The Avengers', 'Steve Rogers', + '', 'The Avengers', 'Tony Stark', + '', 'The Avengers', 'Bruce Banner', + ]); + assert.equal(await gu.getGridLastRowText(), '8'); + }); +}); diff --git a/test/nbrowser/GridOptions.ntest.js b/test/nbrowser/GridOptions.ntest.js new file mode 100644 index 00000000..de0780a0 --- /dev/null +++ b/test/nbrowser/GridOptions.ntest.js @@ -0,0 +1,126 @@ +/** + * NOTE: This test is migrated to new UI as test/nbrowser/GridOptions.ts. + * Remove this version once old UI is no longer supported. + */ + + +import { assert, driver } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe("GridOptions.ntest", function() { + const cleanup = test.setupTestSuite(this); + + + // ====== Some Helpers ====== + + let secNames = ["COUNTRY", "CITY", "COUNTRYLANGUAGE"]; + let switchTo = (i) => + gu.actions.viewSection(secNames[i]).selectSection(); + + /* Test that styles on the given section match the specified flags + * sec: index into secNames + * hor/vert/zebra: boolean flags + */ + async function assertHVZ(sec, hor, vert, zebra) { + let testClasses = + ['record-hlines', 'record-vlines', 'record-zebra']; + let flags = [hor, vert, zebra]; + + let cell = await gu.getCell({rowNum: 1, col: 0, section: secNames[sec]}); + let row = await cell.findClosest('.record'); + const rowClasses = await row.classList(); + testClasses.forEach( (cls, i) => { + if(flags[i]) { assert.include(rowClasses, cls);} + else { assert.notInclude(rowClasses, cls); } + }); + } + + + // ====== Prepare Document ====== + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "World-v10.grist", true); + await $('.test-gristdoc').wait(); + }); + + beforeEach(async function() { + //Prepare consistent view + await gu.actions.selectTabView("Country"); + await gu.openSidePane('view'); + await $(".test-grid-options").wait(assert.isDisplayed); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + + // ====== MAIN TESTS ====== + + + it('should only be visible on grid view/summary view', async function() { + + let getOptions = () => $(".test-grid-options"); + await assert.isPresent(getOptions()); + + // check that it doesnt show up in detail view + await gu.actions.viewSection("COUNTRY Card List").selectSection(); + await assert.isPresent(getOptions(), false); + + // check that it shows up on the grid-views + await gu.actions.viewSection("COUNTRY").selectSection(); + await assert.isDisplayed(getOptions()); + await gu.actions.viewSection("CITY").selectSection(); + await assert.isDisplayed(getOptions()); + await gu.actions.viewSection("COUNTRYLANGUAGE").selectSection(); + await assert.isDisplayed(getOptions()); + + }); + + it('should set and persist styles on a grid', async function() { + + // get handles on elements + let h = ".test-h-grid-button input"; + let v = ".test-v-grid-button input"; + let z = ".test-zebra-stripe-button input"; + + // should start with v+h gridlines, no zebra + await assertHVZ(0, true, true, false); + + // change values on all the sections + await switchTo(0); + await $(z).scrollIntoView().click(); + + await switchTo(1); + await $(h).click(); + await $(v).click(); + + await switchTo(2); + await $(h).click(); // turn off + await $(z).click(); // turn on + await gu.waitForServer(); + + await assertHVZ(0, true, true, true); // all on + await assertHVZ(1, false, false, false); // all off + await assertHVZ(2, false, true, true); // -h +v +z + + // ensure that values persist after reload + await driver.navigate().refresh(); + //await $.injectIntoPage(); + await gu.waitForDocToLoad(); + await assertHVZ(0, true, true, true); // all on + await assertHVZ(1, false, false, false); // all off + await assertHVZ(2, false, true, true); // -h +v +z + }); + + + it('should set .record-even on even-numbered rows', async function() { + let rowClasses = row => + gu.getCell({rowNum: row, col: 0}).closest('.record').classList(); + + await switchTo(0); + assert.notInclude(await rowClasses(1), 'record-even', "row 1 should be odd"); + assert.include(await rowClasses(2), 'record-even', "row 2 should be even"); + }); +}); diff --git a/test/nbrowser/Health.ntest.js b/test/nbrowser/Health.ntest.js new file mode 100644 index 00000000..ef03c23e --- /dev/null +++ b/test/nbrowser/Health.ntest.js @@ -0,0 +1,17 @@ +import { assert, driver } from 'mocha-webdriver'; +import { gu, server, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('Health.ntest', function() { + test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + }); + + it('make sure the health check endpoint returns something', async function() { + await driver.get(server.getHost() + "/status") + const txt = await driver.getPageSource(); + assert.match(txt, /Grist .* is alive/); + }); + +}); diff --git a/test/nbrowser/Localization.ts b/test/nbrowser/Localization.ts index d7918d50..43da5800 100644 --- a/test/nbrowser/Localization.ts +++ b/test/nbrowser/Localization.ts @@ -9,7 +9,7 @@ import os from "os"; import path from 'path'; describe("Localization", function() { - this.timeout(20000); + this.timeout(60000); setupTestSuite(); before(async function() { diff --git a/test/nbrowser/NewDocument.ntest.js b/test/nbrowser/NewDocument.ntest.js new file mode 100644 index 00000000..c81c3f72 --- /dev/null +++ b/test/nbrowser/NewDocument.ntest.js @@ -0,0 +1,161 @@ +/* global window */ + +import { assert, driver } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('NewDocument.ntest', function() { + test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should create new Untitled document', async function() { + this.timeout(10000); + await gu.actions.createNewDoc('Untitled'); + assert.equal(await gu.actions.getDocTitle(), 'Untitled'); + assert.equal(await driver.getTitle(), 'Untitled - Grist'); + assert.equal(await $('.active_section .test-viewsection-title').wait().text(), 'TABLE1'); + await gu.waitForServer(); + }); + + it('should start with a 1x3 grid', async function() { + await $('.record.record-add').wait(); + assert.lengthOf(await $('.grid_view_data .record:not(.column_names)').array(), 1, 'should have 1 row ("add" row)'); + assert.lengthOf(await $('.column_names .column_name').array(), 4, 'should have 3 columns and 1 "add" column'); + }); + + it('should have first cell selected', async function() { + assert.isDisplayed(await gu.getCellRC(0, 0).find('.active_cursor')); + }); + + it('should open notify toasts on errors', async function() { + // Verify that uncaught exceptions and errors from server cause the notifications box to open. + + // For a plain browser error, we attach an error-throwing handler to click-on-logo. + await driver.executeScript( + 'setTimeout(() => window.gristApp.testTriggerError("Our fake error"))', 0); + + // Wait for the notifications window to open and check it has the error we expect. + await $('.test-notifier-toast-message').wait(1, assert.isDisplayed); + assert.match(await $('.test-notifier-toast-message').last().text(), /Our fake error/); + + // Close the notifications window. + await $(".test-notifier-toast-close").click(); + await assert.isPresent($('.test-notifier-toast-message'), false); + + // Try a server command that should fail. We need a reasonble timeout for executeAsyncScript. + await driver.manage().setTimeouts({script: 500}); + let result = await driver.executeAsyncScript(() => { + var cb = arguments[arguments.length - 1]; + window.gristApp.comm.getDocList() + .then( + newName => cb("unexpected success"), + err => { cb(err.toString()); throw err; } + ); + }); + assert.match(result, /Unknown method getDocList/); + + // Now make sure the notifications window is open and has the error we expect. + await assert.isDisplayed($('.test-notifier-toast-message')); + assert.match(await $('.test-notifier-toast-message').last().text(), /Unknown method getDocList/); + + // Close the notifications window. + await $(".test-notifier-toast-close").click(); + await assert.isPresent($('.test-notifier-toast-message'), false); + + assert.deepEqual(await driver.executeScript(() => window.getAppErrors()), + ['Our fake error', 'Unknown method getDocList']); + await driver.executeScript( + 'setTimeout(() => window.gristApp.topAppModel.notifier.clearAppErrors())'); + }); + + describe('Cell editing', function() { + + it('should add rows on entering new data', async function() { + assert.equal(await gu.getGridRowCount(), 1); + await gu.getCellRC(0, 0).click(); + await gu.sendKeys('hello', $.ENTER); + await gu.waitForServer(); + await gu.getCellRC(1, 1).click(); + await gu.sendKeys('world', $.ENTER); + await gu.waitForServer(); + assert.equal(await gu.getGridRowCount(), 3); + }); + + it('should edit on Enter, cancel on Escape, save on Enter', async function() { + var cell_1_b = gu.getCellRC(0, 1); + assert.equal(await cell_1_b.text(), ''); + await cell_1_b.click(); + + await gu.sendKeys($.ENTER); + await $('.test-widget-text-editor').wait(); + await gu.sendKeys('foo', $.ESCAPE); + await gu.waitForServer(); + assert.equal(await cell_1_b.text(), ''); + + await gu.sendKeys($.ENTER); + await $('.test-widget-text-editor').wait(); + await gu.sendKeys('bar', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell_1_b.text(), 'bar'); + }); + + it('should append to cell with content on Enter', async function() { + var cell_1_a = gu.getCellRC(0, 0); + assert.equal(await cell_1_a.text(), 'hello'); + await cell_1_a.click(); + + await gu.sendKeys($.ENTER); + await $('.test-widget-text-editor').wait(); + assert.equal(await $('.test-widget-text-editor textarea').val(), 'hello'); + await gu.sendKeys(', world!', $.ENTER); + await gu.waitForServer(); + + assert.equal(await cell_1_a.text(), 'hello, world!'); + }); + + it('should clear data in selected cells on Backspace and Delete', async function() { + let testDelete = async function(delKey) { + // should clear a single cell + var cell_1_a = gu.getCellRC(0, 0); + await cell_1_a.click(); + await gu.sendKeys('A1', $.ENTER); + await gu.waitForServer(); + assert.equal(await cell_1_a.text(), 'A1'); + await cell_1_a.click(); + await gu.sendKeys(delKey); + await gu.waitForServer(); + assert.equal(await cell_1_a.text(), ''); + + // should clear a selection of cells + await gu.enterGridValues(0, 0, [['A1', 'A2'], ['B1', 'B2']]); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1] }), ['A1', 'B1', 'A2', 'B2']); + await cell_1_a.click(); + await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN], delKey); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1] }), ['', '', '', '']); + + // should clear a selection of cells with a formula column + await gu.enterGridValues(0, 0, [['A1', 'A2'], ['B1', 'B2']]); + await gu.clickCellRC(0, 2); + await gu.sendKeys('=', '$A', $.ENTER); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1, 2] }), + ['A1', 'B1', 'A1', 'A2', 'B2', 'A2']); + await gu.clickCellRC(0, 1); + await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN], delKey); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1, 2] }), + [ 'A1', '', 'A1', 'A2', '', 'A2' ]); + }; + await testDelete($.BACK_SPACE); + await testDelete($.DELETE); + }); + }); +}); diff --git a/test/nbrowser/Pages.ts b/test/nbrowser/Pages.ts index 1b786e9b..81d98310 100644 --- a/test/nbrowser/Pages.ts +++ b/test/nbrowser/Pages.ts @@ -7,7 +7,7 @@ import {server, setupTestSuite} from 'test/nbrowser/testUtils'; import values = require('lodash/values'); describe('Pages', function() { - this.timeout(30000); + this.timeout(60000); setupTestSuite(); let doc: DocCreationInfo; let api: UserAPI; diff --git a/test/nbrowser/Properties.ntest.js b/test/nbrowser/Properties.ntest.js new file mode 100644 index 00000000..d15b465a --- /dev/null +++ b/test/nbrowser/Properties.ntest.js @@ -0,0 +1,151 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('Properties.ntest', function() { + const cleanup = test.setupTestSuite(this); + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "Hello.grist", true); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it("webdriver should handle parens and other keys", async function() { + // This isn't a test of properties really, but a test of Selenium: speficically that the + // workaround for Selenium bugs in gu.sendKeys actually works. + await $("$GridView_columnLabel").first().click(); + + // We'll undo afterwards, and verify that we got the same text back. + var text = await gu.getCellRC(0, 0).text(); + + var specialChars = "()[]{}~!@#$%^&*-_=+/?><.,'\";:"; + await gu.sendKeys(specialChars + specialChars, $.ENTER); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(0, 0).text(), specialChars + specialChars); + + // Undo and compare to previous value. + await gu.sendKeys([$.MOD, 'z']); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(0, 0).text(), text); + }); + + it("cells should indicate when conversion fails for a value", async function() { + await $("$GridView_columnLabel:nth-child(2)").click(); + + // Fill in a column of values, some numeric, some not. + await gu.enterGridValues(0, 1, [["17", "foo", "", "-100"]]); + await gu.waitForServer(); + + assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4]), + ["17", "foo", "", "-100"]); + await $("$GridView_columnLabel:nth-child(2)").click(); + + // Convert the column to Numeric. + await gu.openSidePane('field'); + assert.equal(await $(".test-field-label").val(), 'B'); + await gu.setType('Numeric'); + await $('.test-type-transform-apply').wait().click(); + + assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4]), + ["17", "foo", "", "-100"]); + + // Undo of conversion should restore old values. + await gu.undo(); + await $(".test-fbuilder-type-select .test-select-row:contains(Text)").wait(); + assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4]), + ["17", "foo", "", "-100"]); + + // Redo should work too. + await $(".test-redo").click(); + await $(".test-fbuilder-type-select .test-select-row:contains(Numeric)").wait(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4]), + ["17", "foo", "", "-100"]); + }); + + it("cells should indicate when new value is wrong type", async function() { + // Go to column "c", and change type to Numeric. + await $("$GridView_columnLabel:nth-child(3)").click(); + assert.equal(await $(".test-field-label").val(), 'C'); + await gu.setType('Numeric'); + await $('.test-type-transform-apply').wait().click(); + await gu.waitForServer(); + + // Remove focus from FieldBuilder type dropdown, so that sentKeys go to the main app. + await $("body").click(); + + // Fill in a column of values, some numeric, some not. + await gu.enterGridValues(0, 2, [["25", "", "bar", "-123"]]); + await gu.waitForServer(); + + // TODO: The 0.00 might be wrong behavior: we probably want an empty cell here, although when + // converting an empty text cell to numeric, we want it to become 0. In other words, not all + // conversions are the same. + assert.deepEqual(await gu.getVisibleGridCells(2, [1, 2, 3, 4]), + ["25", "", "bar", "-123"]); + assert.deepEqual(await gu.getVisibleGridCells({ + col: 2, + rowNums: [1, 2, 3, 4], + mapper: e => e.find('.field_clip').hasClass('invalid'), + }), [false, false, true, false]); + + // Select the column again, and type in values in a different order. Ensure the cells change + // appropriately. + await $("$GridView_columnLabel:nth-child(3)").click(); + await gu.enterGridValues(0, 2, [["", "bar", "-123", "25"]]); + await gu.waitForServer(); + + // TODO: The first cell might be wrong behavior; we probably want an empty cell after DELETE. + assert.deepEqual(await gu.getVisibleGridCells(2, [1, 2, 3, 4]), + ["", "bar", "-123", "25"]); + assert.deepEqual(await gu.getVisibleGridCells({ + col: 2, + rowNums: [1, 2, 3, 4], + mapper: e => e.find('.field_clip').hasClass('invalid') + }), [false, true, false, false]); + }); + + it("formula errors should be indicated", async function() { + // Go to column "E", and change formula to eval column "D". + await $("$GridView_columnLabel:nth-child(5)").click(); + await gu.sendKeys("eval($D)", $.ENTER); + // Fill in a bunch of formula text for the "eval" formula to try. This is a way to get a whole + // bunch of different errors in one columns. + await $("$GridView_columnLabel:nth-child(4)").click(); + assert.equal(await $(".test-field-label").val(), 'D'); + await gu.setType('Text'); + + await gu.enterGridValues(0, 3, [[ + "25", + "", + "asdf", + "ValueError()", + "__import__('sys').exit(3)", + 'u"résumé 三"', + "12/(2-1-1)", + "[1,2,3]"]]); + await gu.waitForServer(); + + assert.deepEqual(await gu.getVisibleGridCells(4, [1, 2, 3, 4, 5, 6, 7, 8]), [ + "25", + "#SyntaxError", + "#NameError", + "ValueError()", + "#SystemExit", + 'résumé 三', + "#DIV/0!", + "1, 2, 3", + ]); + + assert.deepEqual( + await gu.getVisibleGridCells({ + col: 4, + rowNums: [1, 2, 3, 4, 5, 6, 7, 8], + mapper: e => e.find('.field_clip').hasClass("invalid") + }), + // Last one (list) is valid because lists are a supported type of value. + [false, true, true, true, true, false, true, false]); + }); +}); diff --git a/test/nbrowser/ReferenceList.ts b/test/nbrowser/ReferenceList.ts index ce5ce710..7c14f7c8 100644 --- a/test/nbrowser/ReferenceList.ts +++ b/test/nbrowser/ReferenceList.ts @@ -4,7 +4,7 @@ import {server, setupTestSuite} from 'test/nbrowser/testUtils'; import {Session} from 'test/nbrowser/gristUtils'; describe('ReferenceList', function() { - this.timeout(20000); + this.timeout(60000); setupTestSuite(); let session: Session; const cleanup = setupTestSuite({team: true}); diff --git a/test/nbrowser/SavePosition.ntest.js b/test/nbrowser/SavePosition.ntest.js new file mode 100644 index 00000000..bce9d9bb --- /dev/null +++ b/test/nbrowser/SavePosition.ntest.js @@ -0,0 +1,106 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('SavePosition.ntest', function() { + const cleanup = test.setupTestSuite(this); + + before(async function() { + this.timeout(Math.max(this.timeout(), 20000)); // Long-running test, unfortunately + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "World.grist", true); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should maintain cursor and scroll positions when switching between views', async function() { + var recordSection = await gu.actions.viewSection('City'); + var cardSection = await gu.actions.viewSection('City Card List'); + var cardScrollPane = $('.detailview_scroll_pane'); + + // Set up scroll linking between the two sections. + await gu.openSidePane('view'); + await $('.test-config-data').click(); + + // Connect CITY -> CITY Card List + await gu.getSection('CITY Card List').click(); + await $('.test-right-select-by').click(); + await $('.test-select-row:contains(CITY)').click(); + await gu.waitForServer(); + await gu.closeSidePane(); + + await recordSection.selectSection(); + + // Click on the District cell with row number 8. + await gu.clickCellRC(7, 2); + // Scroll to the Population cell with row number 22. + await gu.getCellRC(21, 3).scrollIntoView(); + + // Switch to card section, make a cursor selection and scroll selection. + await cardSection.selectSection(); + + var desiredCard = await cardScrollPane.findOldTimey('.g_record_detail .detail_row_num:contains(3150)').parent().elem(); + var desiredField = await desiredCard.findOldTimey('.g_record_detail_label:contains(Country)').parent().parent(); + await desiredField.click(); + await cardScrollPane.findOldTimey('.g_record_detail .detail_row_num:contains(3142)').scrollIntoView(); + + // Switch tabs back and forth. + await gu.actions.selectTabView('Country'); + await gu.actions.selectTabView('City'); + + // Assert that the cursor position in the card section is the same. + desiredCard = await cardScrollPane.findOldTimey('.g_record_detail .detail_row_num:contains(3150)').parent().elem(); + desiredField = await desiredCard.findOldTimey('.g_record_detail_label:contains(Country)').parent().parent(); + await assert.hasClass(desiredField.find('.selected_cursor'), 'active_cursor'); + + // Assert that the element that was scrolled into view is still displayed. + await assert.isDisplayed(cardScrollPane.findOldTimey('.g_record_detail .detail_row_num:contains(3142)')); + + await recordSection.selectSection(); + + // Assert that the scroll position in the grid section is unchanged. + await assert.isDisplayed(gu.getCellRC(21, 3)); + + // Assert that the cursor position in the grid section is the same. + await gu.scrollActiveViewTop(); + await gu.getCellRC(0, 0).wait(assert.isDisplayed); + assert.deepEqual(await gu.getCursorPosition(), { rowNum: 8, col: 2 }); + }); + + it('should maintain cursor with linked sections', async function() { + // Switch to view 'Country' (which has several linked sections). + await gu.actions.selectTabView('Country'); + + // Select a position to the cursor in each section. + await gu.getCell({col: 1, rowNum: 9, section: 'Country'}).click(); + await gu.getCell({col: 0, rowNum: 6, section: 'City'}).click(); + await gu.getCell({col: 2, rowNum: 2, section: 'CountryLanguage'}).click(); + await gu.getDetailCell({col: 'IndepYear', rowNum: 1, section: 'Country Card List'}).click(); + + // Switch tabs back and forth. + await gu.actions.selectTabView('City'); + await gu.actions.selectTabView('Country'); + + // Verify the cursor positions. + assert.deepEqual(await gu.getCursorPosition('Country'), + {rowNum: 9, col: 1}); + assert.deepEqual(await gu.getCursorPosition('City'), + {rowNum: 6, col: 0}); + assert.deepEqual(await gu.getCursorPosition('CountryLanguage'), + {rowNum: 2, col: 2}); + assert.deepEqual(await gu.getCursorPosition('Country Card List'), + {rowNum: 1, col: 'IndepYear'}); + }); + + it('should paste into saved position', async function() { + await gu.getCell({col: 1, rowNum: 9, section: 'Country'}).click(); + await gu.actions.selectTabView('City'); + await gu.sendKeys($.COPY); + await gu.actions.selectTabView('Country'); + await gu.sendKeys($.PASTE); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells(1, [8, 9, 10]), + ['United Arab Emirates', 'Pará', 'Armenia']); + }); +}); diff --git a/test/nbrowser/SortDates.ntest.js b/test/nbrowser/SortDates.ntest.js new file mode 100644 index 00000000..bc233a93 --- /dev/null +++ b/test/nbrowser/SortDates.ntest.js @@ -0,0 +1,141 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +// Helper that returns the cell text prefixed by "!" if the cell is invalid. +async function valText(cell) { + const isInvalid = await cell.find('.field_clip').hasClass("invalid"); + const text = await cell.getText(); + return (isInvalid ? "!" : "") + text; +} + +async function clickColumnMenuSort(colName, itemText) { + // Scroll into view doesn't work on Grid because the first column + // will always be hidden behind row number element. So we will always + // move to the first column before opening menu, as scrolling right + // does work (there are no absolute positioned elements there). + await gu.sendKeys($.HOME); + await gu.openColumnMenu(colName); + const dir = (itemText === 'Sort ascending') ? 'asc' : 'dsc'; + return $(`.grist-floating-menu .test-sort-${dir}`).click(); +} + +describe('SortDates.ntest', function() { + const cleanup = test.setupTestSuite(this); + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "SortDates.grist", true); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it("should display calculated DateTimes as valid", async function() { + // Check that Dates and DateTimes returned from 'Any' formulas are displayed + // as valid (rather than pink error values). + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [ + '2017-04-11', '2017-04-11 9:30am', '2017-04-12', '2017-04-12 09:30:00-04:00', + '2017-07-13', '2017-07-13 4:00am', '2017-07-14', '2017-07-14 04:00:00-04:00', + '!invalid1', '!invalid2', '!#TypeError', '!#TypeError', + '2017-05-01', '2017-05-01 7:00am', '2017-05-02', '2017-05-02 07:00:00-04:00', + '2017-04-21', '2017-04-21 12:00pm', '2017-04-22', '2017-04-22 12:00:00-04:00', + '', '', '', '', + '2017-03-16', '2017-03-16 4:00pm', '2017-03-17', '2017-03-17 16:00:00-04:00', + ]); + }); + + it('should sort correctly by Date or DateTime', async function() { + // Check that we sort by the Date and DateTime column works as expected, even + // when blanks or AltText is present. + await gu.openSidePane('view'); + await gu.toggleSidePanel('left', 'close'); + await $('.test-config-sortAndFilter').click(); + + // Sort by a special column first to rearrange. It's specially chosen to trigger some + // previously incorrect comparisons that may cause wrong order. (The actual bug only existed + // at the time of writing in the test case for Any formula columns returning Dates/DateTimes.) + await clickColumnMenuSort('Order', 'Sort ascending'); + let orderRow = await $(".test-sort-config-row:contains(Order)").wait().elem(); + await assert.isPresent(orderRow); + await assert.isPresent(orderRow.find(".test-sort-config-sort-order-asc")); + await gu.getColumnHeader('Date').scrollIntoView({inline: "end"}); + await clickColumnMenuSort('Date', 'Sort ascending'); + const dateRow = await $(".test-sort-config-row:contains(Date)").wait().elem(); + await assert.isPresent(dateRow); + await assert.isPresent(dateRow.find(".test-sort-config-sort-order-asc")); + + // Check that the data is now sorted. + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [ + '2017-03-16', '2017-03-16 4:00pm', '2017-03-17', '2017-03-17 16:00:00-04:00', + '2017-04-11', '2017-04-11 9:30am', '2017-04-12', '2017-04-12 09:30:00-04:00', + '2017-04-21', '2017-04-21 12:00pm', '2017-04-22', '2017-04-22 12:00:00-04:00', + '2017-05-01', '2017-05-01 7:00am', '2017-05-02', '2017-05-02 07:00:00-04:00', + '2017-07-13', '2017-07-13 4:00am', '2017-07-14', '2017-07-14 04:00:00-04:00', + '', '', '', '', + '!invalid1', '!invalid2', '!#TypeError', '!#TypeError', + ]); + + await clickColumnMenuSort('Order', 'Sort ascending'); + orderRow = await $(".test-sort-config-row:contains(Order)").wait().elem(); + await assert.isPresent(orderRow); + await assert.isPresent(orderRow.find(".test-sort-config-sort-order-asc")); + await clickColumnMenuSort('DTime', 'Sort descending'); + const dtimeRow = await $(".test-sort-config-row:contains(DTime)").wait().elem(); + await assert.isPresent(dtimeRow); + await assert.isPresent(dtimeRow.find(".test-sort-config-sort-order-desc")); + + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [ + '!invalid1', '!invalid2', '!#TypeError', '!#TypeError', + '', '', '', '', + '2017-07-13', '2017-07-13 4:00am', '2017-07-14', '2017-07-14 04:00:00-04:00', + '2017-05-01', '2017-05-01 7:00am', '2017-05-02', '2017-05-02 07:00:00-04:00', + '2017-04-21', '2017-04-21 12:00pm', '2017-04-22', '2017-04-22 12:00:00-04:00', + '2017-04-11', '2017-04-11 9:30am', '2017-04-12', '2017-04-12 09:30:00-04:00', + '2017-03-16', '2017-03-16 4:00pm', '2017-03-17', '2017-03-17 16:00:00-04:00', + ]); + }); + + it('should sort correctly by Any returning Date or DateTime', async function() { + // Formulas of type 'Any' returning a Date or DateTime involve comparison of complex values + // (arrays) when sorting. Check that it works even in the presence of error values. + + await clickColumnMenuSort('Order', 'Sort ascending'); + let orderRow = await $(".test-sort-config-row:contains(Order)").wait().elem(); + await assert.isPresent(orderRow); + await assert.isPresent(orderRow.find(".test-sort-config-sort-order-asc")); + await clickColumnMenuSort('CalcDate', 'Sort ascending'); + let calcDateRow = await $(".test-sort-config-row:contains(CalcDate)").wait().elem(); + await assert.isPresent(calcDateRow); + await assert.isPresent(calcDateRow.find(".test-sort-config-sort-order-asc")); + + // Check that the data is now sorted. + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [ + '', '', '', '', + '2017-03-16', '2017-03-16 4:00pm', '2017-03-17', '2017-03-17 16:00:00-04:00', + '2017-04-11', '2017-04-11 9:30am', '2017-04-12', '2017-04-12 09:30:00-04:00', + '2017-04-21', '2017-04-21 12:00pm', '2017-04-22', '2017-04-22 12:00:00-04:00', + '2017-05-01', '2017-05-01 7:00am', '2017-05-02', '2017-05-02 07:00:00-04:00', + '2017-07-13', '2017-07-13 4:00am', '2017-07-14', '2017-07-14 04:00:00-04:00', + '!invalid1', '!invalid2', '!#TypeError', '!#TypeError', + ]); + + await clickColumnMenuSort('Order', 'Sort ascending'); + orderRow = await $(".test-sort-config-row:contains(Order)").wait().elem(); + await assert.isPresent(orderRow); + await assert.isPresent(orderRow.find(".test-sort-config-sort-order-asc")); + await clickColumnMenuSort('CalcDTime', 'Sort descending'); + calcDateRow = await $(".test-sort-config-row:contains(CalcDTime)").wait().elem(); + await assert.isPresent(calcDateRow); + await assert.isPresent(calcDateRow.find(".test-sort-config-sort-order-desc")); + + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [ + '!invalid1', '!invalid2', '!#TypeError', '!#TypeError', + '2017-07-13', '2017-07-13 4:00am', '2017-07-14', '2017-07-14 04:00:00-04:00', + '2017-05-01', '2017-05-01 7:00am', '2017-05-02', '2017-05-02 07:00:00-04:00', + '2017-04-21', '2017-04-21 12:00pm', '2017-04-22', '2017-04-22 12:00:00-04:00', + '2017-04-11', '2017-04-11 9:30am', '2017-04-12', '2017-04-12 09:30:00-04:00', + '2017-03-16', '2017-03-16 4:00pm', '2017-03-17', '2017-03-17 16:00:00-04:00', + '', '', '', '', + ]); + }); +}); diff --git a/test/nbrowser/SortEditSave.ntest.js b/test/nbrowser/SortEditSave.ntest.js new file mode 100644 index 00000000..08cd01c0 --- /dev/null +++ b/test/nbrowser/SortEditSave.ntest.js @@ -0,0 +1,63 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('SortEditSave.ntest', function() { + const cleanup = test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "Hello.grist", true); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should not jump to next row when an updated field jumps in a sorted section', async function() { + // Enter numbers and sort by them + await gu.enterGridValues(0, 1, [['1', '2', '3', '4']]); + await gu.clickCellRC(0, 1); + await gu.setType('Numeric'); + await $('.test-type-transform-apply').click(); + await gu.openColumnMenu('B'); + await $('.grist-floating-menu .test-sort-asc').click(); + + // Edit one of the numbers so that it doesn't get re-sorted. Assert that the cursor + // moves down one cell + await gu.clickCellRC(1, 1); + await gu.sendKeys("2.5", $.ENTER); + await gu.waitForServer(); + assert.equal(await $('.field_clip.has_cursor').text(), "3"); + + // Edit one of the numbers so that it gets re-sorted. Assert that the cursor stays + // on the cell + await gu.clickCellRC(1, 1); + await gu.sendKeys("3.5", $.ENTER); + await gu.waitForServer(); + assert.equal(await $('.field_clip.has_cursor').text(), "3.5"); + }); + + it('should not jump to next row when a formula update causes the field to jump', async function() { + // Enter a formula in the next column, and sort by the column + await gu.clickCellRC(0, 2); + await gu.sendKeys("="); + await $('.test-editor-tooltip-convert').click(); // Convert to a formula + await gu.sendKeys("$B", $.ENTER); + await gu.openColumnMenu('C'); + await $('.grist-floating-menu .test-sort-asc').click(); + + // Edit the formula so that the row stays in the same place. Assert that the cursor + // does NOT move down (since editing a column-wide formula, not doing data entry). + await gu.clickCellRC(0, 2); + await gu.sendKeys($.ENTER, [$.MOD, 'a'], "$B+5", $.ENTER); + await gu.waitForServer(); + assert.equal(await $('.field_clip.has_cursor').text(), "6"); + + // Edit the formula so that the row moves. Assert that the cursor says on the cell + // in this case too. + await gu.clickCellRC(0, 2); + await gu.sendKeys($.ENTER, [$.MOD, 'a'], "10-$B", $.ENTER); + await gu.waitForServer(); + assert.equal(await $('.field_clip.has_cursor').text(), "9"); + }); +}); diff --git a/test/nbrowser/Summaries.ntest.js b/test/nbrowser/Summaries.ntest.js new file mode 100644 index 00000000..db72c9ad --- /dev/null +++ b/test/nbrowser/Summaries.ntest.js @@ -0,0 +1,282 @@ +/** + * This test suite is partially duplicated as `test/nbrowser/Summaries.ts`. + */ + +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('Summaries.ntest', function() { + const cleanup = test.setupTestSuite(this); + + gu.bigScreen(); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "CC_Summaries.grist", true); + await gu.toggleSidePanel('left', 'open'); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should contain two summary tables', async function() { + // Switch to Summaries view. + await gu.actions.selectTabView('Summaries'); + await gu.getVisibleGridCells(0, [1]); + + // Check a few numbers from 'By Category' section. + assert.deepEqual(await gu.getGridValues({section: 'By Category', rowNums: [2, 10], cols: [0, 2]}), + [ 'Business Services-Mailing & Shipping', '341.84', + 'Merchandise & Supplies-Internet Purchase', '1023.47' ]); + + // Check a few numbers from 'Credit/Debit By Category' section. + assert.deepEqual(await gu.getGridValues({section: 'Credit/Debit By Category', rowNums: [3, 9], + cols: [1, 3]}), + [ 'Fees & Adjustments-Fees & Adjustments', '-472.03', + 'Business Services-Office Supplies', '526.45' ]); + assert.deepEqual(await gu.getGridValues({section: 'Credit/Debit By Category', rowNums: [3, 9], + cols: [0], mapper: e => e.find('.widget_checkmark').getCssValue('display') }), + [ 'block', + 'none' ]); + }); + + + it('should allow updating summary group-by columns', async function() { + // Open side-pane. + await gu.openSidePane('view'); + await $('.test-config-data').click(); + await gu.actions.viewSection('By Category').selectSection(); + + // Check some values in the data. + assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2]}), + [ 'Business Services-Mailing & Shipping', '6', '341.84', + 'Merchandise & Supplies-Pharmacies', '4', '42.19' ]); + + // Verify that multiselect only shows "Category". + assert.deepEqual(await $('.test-pwc-groupedBy-col').array().text(), ["Category"]); + + // Add another field, "Date". + await $('.test-pwc-editDataSelection').click(); + await $(`.test-wselect-column:contains(Date)`).click(); + + // Cancel, and verify contents of multiselect. + await gu.sendKeys($.ESCAPE); + assert.deepEqual(await $('.test-pwc-groupedBy-col').array().text(), ["Category"]); + + // Add another field, "Date", again. + await $('.test-pwc-editDataSelection').click(); + await $(`.test-wselect-column:contains(Date)`).click(); + + // Save, and verify contents of multiselect. + await $('.test-wselect-addBtn').click(); + await gu.waitForServer(); + + // Verify contents of multiselect. + assert.deepEqual(await $('.test-pwc-groupedBy-col').array().text(), ["Date", "Category"]); + + // Wait for data to load, and verify the data. + assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2, 3]}), + [ '2015-02-12', '', '1', '-4462.48', + '2015-02-13', 'Business Services-Mailing & Shipping', '1', '147.00' ]); + + // Remove both "Date" and "Category", and save. + await $('.test-pwc-editDataSelection').click(); + await $('.test-wselect-column[class*=-selected]:contains(Date)').click(); + await $('.test-wselect-column[class*=-selected]:contains(Category)').click(); + await $('.test-wselect-addBtn').click(); + await gu.waitForServer(); + + // Verify contents of multiselect. + assert.deepEqual(await $('.test-pwc-groupedBy-col').array().text(), []); + + // Wait for data to load, and verify the data (a single line of totals). + assert.deepEqual(await gu.getGridValues({rowNums: [1], cols: [0, 1]}), + ['208', '3540.60']); + + // Undo, and verify contents of multiselect. + await gu.undo(); + assert.deepEqual(await $('.test-pwc-groupedBy-col').array().text(), ["Date", "Category"]); + + // Undo, and verify contents of multiselect. + await gu.undo(); + assert.deepEqual(await $('.test-pwc-groupedBy-col').array().text(), ["Category"]); + + // Verify that contents is what we started with. + assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2]}), + [ 'Business Services-Mailing & Shipping', '6', '341.84', + 'Merchandise & Supplies-Pharmacies', '4', '42.19' ]); + }); + + // This test has been migrated to `test/nbrowser/Summaries.ts` + it('should allow detaching a summary table', async function() { + // Detach a summary section, make sure it shows correct data, and has live formulas, but + // doesn't auto-add rows. Then undo and make sure we go back to a summary table. + + await gu.actions.viewSection('By Category').selectSection(); + assert.deepEqual(await gu.actions.getTabs().array().text(), ['Summaries', 'Sheet1']); + + await $('.test-detach-button').click() + await gu.waitForServer(); + await assert.equal(await $(".test-pwc-groupedBy").isDisplayed(), false); + + // Verify that the title of the section has changed. + assert.equal(await $('.active_section .test-viewsection-title').parent().text(), + 'By Category'); + assert.deepEqual(await gu.actions.getTabs().array().text(), ['Summaries', 'Sheet1', 'Table1']); + + // Verify that contents of the section. + assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2]}), + [ 'Business Services-Mailing & Shipping', '6', '341.84', + 'Merchandise & Supplies-Pharmacies', '4', '42.19' ]); + + // See what the last row number is. + await gu.sendKeys([$.MOD, $.DOWN]); + assert.equal(await $('.active_section .gridview_data_row_num').last().text(), '19'); + + // Change a category in Transactions; it should affect formulas in existing rows of the + // detached table, but should not produce new rows. + await gu.clickCell({rowNum: 9, col: 2, section: 'Transactions'}); + await gu.sendKeys('Hello', $.ENTER); + await gu.waitForServer(); + + // Check that number of rows is unchanged, but that formulas got updated in the affected row. + await gu.actions.viewSection('By Category').selectSection(); + assert.equal(await $('.active_section .gridview_data_row_num').last().text(), '19'); + await gu.sendKeys([$.MOD, $.UP]); + assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2]}), + [ 'Business Services-Mailing & Shipping', '5', '194.84', + 'Merchandise & Supplies-Pharmacies', '4', '42.19' ]); + + // Undo everything. Make sure we have our summary table back. + await gu.undo(3); + assert.equal(await $('.active_section .test-viewsection-title').parent().text(), + 'By Category'); + assert.deepEqual(await $('.test-pwc-groupedBy-col').array().text(), ["Category"]); + assert.deepEqual(await gu.actions.getTabs().array().text(), ['Summaries', 'Sheet1']); + }); + + + it('should allow adding summaries by date', async function() { + // Add Summary table by Date column. + await gu.actions.viewSection('By Category').selectSection(); + await gu.actions.addNewSummarySection('Sheet1', ['Date', 'Category'], 'Table', 'By Date/Category'); + + // Check a couple of values. + await gu.actions.viewSection('By Date/Category').selectSection(); + await gu.sendKeys([$.MOD, $.DOWN]); // Go to the end. + assert.deepEqual(await gu.getGridValues({section: 'By Date/Category', rowNums:[151], cols:[0, 1, 3]}), + [ '2015-12-04', 'Travel-Lodging', '3021.54' ]); + }); + + + it('should update summary values when values change', async function() { + // Change a value in Transactions, and check that numbers changed. + await gu.actions.viewSection('Transactions').selectSection(); + await gu.getCell(1, 9).click(); + await gu.waitAppFocus(); + await gu.sendKeys('947.00', $.ENTER); // Change 147.00 -> 947.00 + assert.equal(await gu.getCell(1, 9).text(), '947.00'); + await gu.sendKeys([$.MOD, $.DOWN], $.UP); // Go to the last row (but not the "add row"). + await gu.sendKeys('677.40', $.ENTER); // Change 177.40 -> 677.40 + await gu.waitForServer(); + + // Check changes in the two affected sections. + assert.deepEqual(await gu.getGridValues({section: 'By Category', rowNums: [2, 10], cols: [0, 2]}), + [ 'Business Services-Mailing & Shipping', '1141.84', // <--- this changes + 'Merchandise & Supplies-Internet Purchase', '1023.47' ]); + assert.deepEqual(await gu.getGridValues({section: 'By Date/Category', rowNums:[151], cols:[0, 1, 3]}), + [ '2015-12-04', 'Travel-Lodging', '3521.54' ]); + + // Undo both changes, and check that summarized values got restored. + await $(".test-undo").click(); + await $(".test-undo").click(); + await gu.waitForServer(); + + assert.deepEqual(await gu.getGridValues({section: 'By Category', rowNums: [2, 10], cols: [0, 2]}), + [ 'Business Services-Mailing & Shipping', '341.84', + 'Merchandise & Supplies-Internet Purchase', '1023.47' ]); + assert.deepEqual(await gu.getGridValues({section: 'By Date/Category', rowNums:[151], cols:[0, 1, 3]}), + [ '2015-12-04', 'Travel-Lodging', '3021.54' ]); + }); + + + it('should update summary values when key columns change', async function() { + // Change a category in Transactions, and check that numbers changed. + await gu.actions.viewSection('Transactions').selectSection(); + await gu.sendKeys([$.MOD, $.DOWN]); // Go to the end. + await gu.getCell(2, 208).click(); + await gu.waitAppFocus(); + await gu.sendKeys('Merchandise & Supplies-Internet Purchase', $.ENTER); + assert.equal(await gu.getCell(2, 208).text(), 'Merchandise & Supplies-Internet Purchase'); + await gu.waitForServer(); + + // Check that numbers changed in two affected summary tables. + assert.deepEqual(await gu.getGridValues({section: 'By Category', rowNums: [2, 10], cols: [0, 2]}), + [ 'Business Services-Mailing & Shipping', '341.84', + 'Merchandise & Supplies-Internet Purchase', '1200.87' ]); // Up by 177.40 + assert.deepEqual(await gu.getGridValues({section: 'By Date/Category', + rowNums:[151, 152], cols:[0, 1, 3]}), + [ '2015-12-04', 'Travel-Lodging', '2844.14', // Down by 177.40 + '2015-12-04', 'Merchandise & Supplies-Internet Purchase', '177.40' ]); // New row + + // Undo and check that summarized values got restored. + await $(".test-undo").click(); + await gu.waitForServer(); + + assert.deepEqual(await gu.getGridValues({section: 'By Category', rowNums: [2, 10], cols: [0, 2]}), + [ 'Business Services-Mailing & Shipping', '341.84', + 'Merchandise & Supplies-Internet Purchase', '1023.47' ]); + assert.deepEqual(await gu.getGridValues({section: 'By Date/Category', rowNums:[151], cols:[0, 1, 3]}), + [ '2015-12-04', 'Travel-Lodging', '3021.54' ]); + + // Check that the newly-added row is gone. + await gu.actions.viewSection('By Date/Category').selectSection(); + await assert.equal(await gu.getGridLastRowText(), '151'); + }); + + + it('should update summary values when records get added', async function() { + // Add a record. + await gu.actions.viewSection('Transactions').selectSection(); + await gu.addRecord(['2016-01-01', '100', 'Business Services-Office Supplies']); + await gu.waitForServer(); + assert.equal(await gu.getGridLastRowText(), '210'); + assert.deepEqual(await gu.getGridValues({cols: [0, 1, 2], rowNums: [209]}), + ['2016-01-01', '100.00', 'Business Services-Office Supplies']); + + // Check that numbers have changed. + assert.deepEqual(await gu.getGridValues({section: 'Credit/Debit By Category', rowNums: [2, 3, 9], + cols: [1, 3]}), + [ 'Business Services-Office Supplies', '-4.56', // <-- no change + 'Fees & Adjustments-Fees & Adjustments', '-472.03', + 'Business Services-Office Supplies', '626.45' ]); // <-- does change + assert.deepEqual(await gu.getGridValues({section: 'Credit/Debit By Category', rowNums: [2, 3, 9], + cols: [0], mapper: e => e.find('.widget_checkmark').getCssValue('display')}), + [ 'block', + 'block', + 'none']); // <-- does change + + // Go to last data record. + await gu.sendKeys([$.MOD, $.UP]); + await gu.sendKeys([$.MOD, $.DOWN]); + await gu.sendKeys([$.UP]); + + // Delete the new record, and check that values are the same as before. + await gu.sendKeys([$.MOD, $.DELETE]); + + await gu.confirm(true, true); // confirm and remember. + await gu.waitForServer(); + + assert.deepEqual(await gu.getGridValues({section: 'Credit/Debit By Category', rowNums: [2, 3, 9], + cols: [1, 3]}), + [ 'Business Services-Office Supplies', '-4.56', + 'Fees & Adjustments-Fees & Adjustments', '-472.03', + 'Business Services-Office Supplies', '526.45' ]); + assert.deepEqual(await gu.getGridValues({section: 'Credit/Debit By Category', rowNums: [2, 3, 9], + cols: [0], mapper: e => e.find('.widget_checkmark').getCssValue('display')}), + [ 'block', + 'block', + 'none' ]); + }); +}); diff --git a/test/nbrowser/TextEditor.ntest.js b/test/nbrowser/TextEditor.ntest.js new file mode 100644 index 00000000..a6ab36db --- /dev/null +++ b/test/nbrowser/TextEditor.ntest.js @@ -0,0 +1,280 @@ +import { assert, driver } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('TextEditor.ntest', function() { + test.setupTestSuite(this); + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.actions.createNewDoc(); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + async function autoCompleteSelect(options) { + await gu.sendKeys(options.input); + const values = await $('.test-ref-editor-item').array().text(); + if (options.keys) { + await gu.sendKeys(...options.keys); + await $('.test-ref-editor-item.selected').wait(assert.isPresent, true); + } else if (options.click) { + await driver.findContent('.test-ref-editor-item', gu.exactMatch(options.click)).click(); + } + return values; + } + + async function autoCompleteWaitForSelection(text, selected) { + await $('.test-ref-editor-item:contains('+ text +')').wait(assert.hasClass, 'selected', selected); + } + + it('should allow saving values into new Reference column', async function() { + await gu.getCellRC(0, 0).wait().click(); + await gu.sendKeys("foo", $.ENTER); + await gu.waitForServer(); + await gu.sendKeys("bar", $.ENTER); + await gu.waitForServer(); + await gu.sendKeys("baz", $.ENTER); + await gu.waitForServer(); + + // Add a new section and switch to it. + await gu.actions.addNewSection('New', 'Table'); + await gu.toggleSidePanel('left', 'close'); + await $(".viewsection_title:contains(TABLE2)").click(); + await gu.getCellRC(0, 0).click(); + await gu.setType('Reference'); + await gu.setRefTable('Table1'); + await gu.setVisibleCol('A'); + + // Populate some of the reference column. + await gu.getCellRC(0, 0).click(); + + // Select "foo" from autocomplete dropdown with keyboard. + await autoCompleteSelect({input: 'f'}); + await gu.sendKeys($.ENTER); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(0, 0).text(), "foo"); + + // Select "bar" from autocomplete dropdown with the mouse. + await autoCompleteSelect({input: 'b', click: 'bar'}); + await gu.waitForServer(); + await gu.sendKeys($.DOWN); // Selecting with the mouse saves without moving the cursor + assert.equal(await gu.getCellRC(1, 0).text(), "bar"); + + // Entering an existing value should reference it + await autoCompleteSelect({input: 'baz'}); + await gu.sendKeys($.ENTER); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(2, 0).text(), "baz"); + + // Select "foo" from autocomplete dropdown with tab. + await autoCompleteSelect({input: 'foo'}); + await gu.sendKeys($.TAB); // Select "foo" from autocomplete dropdown with tab. + await gu.waitForServer(); + assert.equal(await gu.getCellRC(3, 0).text(), "foo"); + + // Esc should Cancel. + await gu.getCellRC(4, 0).click(); + await autoCompleteSelect({input: 'baz'}); + await gu.sendKeys($.ESCAPE); + assert.equal(await gu.getCellRC(4, 0).text(), ""); + }); + + it('should allow adding new values from Reference column', async function() { + // Select add new from autocomplete dropdown. + await autoCompleteSelect({input: 'foobar', keys: [$.UP]}); + await gu.sendKeys($.ENTER); + await gu.waitForServer(); + await $(".viewsection_title:contains(TABLE1)").click(); + assert.equal(await gu.getCellRC(3, 0).text(), "foobar"); + + // Add new by tab + await $(".viewsection_title:contains(TABLE2)").click(); + await gu.getCellRC(4, 0).click(); + await autoCompleteSelect({input: 'foobar1', keys: [$.UP]}); + await gu.sendKeys($.TAB); + await gu.waitForServer(); + await $(".viewsection_title:contains(TABLE1)").click(); + assert.equal(await gu.getCellRC(4, 0).text(), "foobar1"); + + // Add new by click + await $(".viewsection_title:contains(TABLE2)").click(); + await gu.getCellRC(5, 0).click(); + await autoCompleteSelect({input: 'foobar2', click: 'foobar2'}); + await gu.waitForServer(); + await $(".viewsection_title:contains(TABLE1)").click(); + assert.equal(await gu.getCellRC(5, 0).text(), "foobar2"); + + // Cancel with escape + await $(".viewsection_title:contains(TABLE2)").click(); + await gu.getCellRC(5, 0).click(); + await autoCompleteSelect({input: 'foobar3', keys: [$.UP]}); + await gu.sendKeys($.ESCAPE); + await gu.waitForServer(); + await gu.waitAppFocus(true); + await $(".viewsection_title:contains(TABLE1)").click(); + assert.equal(await gu.getCellRC(6, 0).text(), ""); + + // Once add new is selected it should not be possible to change the input. + await $(".viewsection_title:contains(TABLE2)").click(); + await gu.getCellRC(6, 0).click(); + await autoCompleteSelect({input: 'foobar4', keys: [$.UP]}); + await gu.sendKeys("567"); + // Make sure add item loses selection + await autoCompleteWaitForSelection('foobar4', false); + await gu.sendKeys($.ENTER); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(6, 0).text(), "foobar4567"); + await assert.hasClass(gu.getCellRC(6, 0).find('.field_clip'), 'invalid'); + await $(".viewsection_title:contains(TABLE1)").click(); + assert.equal(await gu.getCellRC(6, 0).text(), ""); + }); + + async function addColumnRightOf(index) { + // Add a column. We have to hover over the column header first. + await gu.openColumnMenu({col: index}, 'Insert column to the right'); + await gu.waitForServer(); + await gu.sendKeys($.ESCAPE); + } + + it('should allow saving values into new Date column', async function() { + // Add another column. We have to hover over the column header first. + await addColumnRightOf(0); + await gu.getCellRC(0, 1).click(); + + // Convert to Date. No need to "Apply conversion" since it's a new empty column. + await gu.setType('Date'); + + // Enter a new value and check that it's parsed and shows correctly. + await gu.getCellRC(0, 1).click(); + await gu.sendKeys("2016/04/20", $.ENTER); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(0, 1).text(), "2016-04-20"); + }); + + it('should show formatted values for ReferenceEditor autocomplete', async function() { + // Set a Reference column to use a displayCol that's a Date, and ensure that properly + // formatted dates show in its autocomplete. + + // First, fill in a few more dates into Table1.D + await gu.enterGridValues(1, 1, [['2014-03-14', '2017-05-01', '2016-12-31', '', '2011-07-15']]); + + // Now switch to the section with the Reference column and switch its displayCol to Table1.D. + await gu.actions.viewSection('TABLE2').selectSection(); + await gu.clickCell({rowNum: 1, col: 0}); + await gu.setVisibleCol('D'); + + // Check that the values displayed are properly formatted. + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0]}), + ['2016-04-20', '2014-03-14', '2017-05-01', '2016-04-20', '[Blank]', '2011-07-15', 'foobar4567']); + + // Check that formatted values are shown in the auto-complete dropdown. + await gu.clickCell({rowNum: 3, col: 0}); + assert.deepEqual(await autoCompleteSelect({input: '2016', keys: [$.DOWN]}), + ['2016-04-20', '2016-12-31', '2011-07-15', '2014-03-14', '2017-05-01', '2016']); + await gu.sendKeys($.ENTER); + await gu.waitForServer(); + + // Check that after selection, the right value is saved, and that it's valid (not AltText). + let cell = await gu.getCell({rowNum: 3, col: 0}); + assert.equal(await cell.text(), '2016-12-31'); + await assert.hasClass(cell.find('.field_clip'), 'invalid', false); + + // Check that the formatted value is used to start the autocomplete lookup. + await gu.clickCell({rowNum: 3, col: 0}); + assert.deepEqual(await autoCompleteSelect({input: $.ENTER}), + ['2016-12-31', '2016-04-20', '2011-07-15', '2014-03-14', '2017-05-01']); + await gu.sendKeys($.SELECT_ALL, '2017-05-01', $.ENTER); + await gu.waitForServer(); + + // Check that after typing, the right value is saved, and that it's valid (not AltText). + cell = await gu.getCell({rowNum: 3, col: 0}); + assert.equal(await cell.text(), '2017-05-01'); + await assert.hasClass(cell.find('.field_clip'), 'invalid', false); + + // Switch back to the view section we started from. + await gu.actions.viewSection('TABLE1').selectSection(); + }); + + + it('should allow saving values into new Checkbox column', async function() { + await addColumnRightOf(1); + await gu.getCellRC(0, 2).click(); + + // Convert to Toggle. No need to "Apply conversion" since it's a new empty column. + await gu.setType('Toggle'); + + // Toggle a value in the new column. + await gu.getCellRC(1, 2).find('.widget_checkbox').click(); + await gu.waitForServer(); + + // To ensure it got saved to the server, convert to text, and check the text. + await gu.setType('Text'); + await $('.test-type-transform-apply').wait().click(); + await gu.waitForServer(); + assert.deepEqual(await gu.getVisibleGridCells(2, [1, 2, 3]), + ["false", "true", "false"]); + }); + + it('should allow saving values into a new row of a new column', async function() { + await gu.getColumnHeader('A').scrollIntoView({inline: "end"}); + await addColumnRightOf(0); + await gu.getCellRC(0, 1).click(); + await gu.setType('Date'); + + assert.equal(await gu.getCellRC(6, 1).text(), ""); // Last "add new" row. + await assert.isPresent(gu.getCellRC(7, 1), false); // Check that there is no next row. + + await gu.getCellRC(0, 1).click(); + await gu.sendKeys([$.MOD, $.DOWN]); // Jump to last row. + await gu.sendKeys("2001/11/23", $.ENTER); + await gu.waitForServer(); + + assert.equal(await gu.getCellRC(6, 1).text(), "2001-11-23"); + await assert.isPresent(gu.getCellRC(7, 1), true); // Check that there is now one more row. + }); + + it('should allow changing a Date column to/from formula', async function() { + // What column D (index 1) start off with. + assert.equal(await gu.getCellRC(0, 1).text(), ""); + assert.equal(await gu.getCellRC(6, 1).text(), "2001-11-23"); + + // Replace it with a formula that uses another date column B. + await gu.getCellRC(0, 1).click(); + await gu.sendKeys('='); + await $('.test-editor-tooltip-convert').click(); // Convert to a formula + await gu.sendKeys('$D and $D.replace(day=2)', $.ENTER); + await gu.waitForServer(); + + // Check that it worked. + assert.equal(await gu.getCellRC(0, 1).text(), "2016-04-02"); + assert.equal(await gu.getCellRC(6, 1).text(), ""); + + // Converting it to a data column. + await gu.clickColumnMenuItem('F', 'Convert formula to data'); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(0, 1).text(), "2016-04-02"); + assert.equal(await gu.getCellRC(6, 1).text(), ""); + + // Enter a new value, make sure that works. + await gu.getCellRC(6, 1).click(); + await gu.sendKeys("2016/05/01", $.ENTER); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(0, 1).text(), "2016-04-02"); + assert.equal(await gu.getCellRC(6, 1).text(), "2016-05-01"); + }); + + // NOTE: This tests a specific bug which prevented moving the editor cursor via clicking. + // See https://phab.getgrist.com/T326 + it('should allow moving cursor inside the editor via clicking', async function() { + await gu.clickCellRC(0, 0); + await gu.sendKeys($.ENTER); + await gu.waitAppFocus(false); + // Double click the cell to select all the text. This will fail if the bug is active. + await driver.withActions(a => a.doubleClick($('.celleditor_text_editor').elem())); + // Since the text was selected, the new text will replace the old text. + await gu.sendKeys('abcd', $.ENTER); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(0, 0).text(), "abcd"); + }); +}); diff --git a/test/nbrowser/TypeChange.ntest.js b/test/nbrowser/TypeChange.ntest.js new file mode 100644 index 00000000..9819e13e --- /dev/null +++ b/test/nbrowser/TypeChange.ntest.js @@ -0,0 +1,463 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +// Helper that returns the cell text prefixed by "!" if the cell is invalid. +async function valText(cell) { + const isInvalid = await cell.find('.field_clip').hasClass("invalid"); + const text = await cell.getText(); + return (isInvalid ? "!" : "") + text; +} + +describe('TypeChange.ntest', function() { + const cleanup = test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "Hello.grist", true); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should not use transform to convert type for an empty column', async function() { + await gu.openSidePane('field'); + await gu.getCellRC(0, 0).click(); + await gu.sendKeys([$.ALT, '=']); + await gu.waitForServer(); + gu.sendKeys($.ESCAPE); + + // Click on new column + await gu.getCellRC(0, 1).click(); + + // Change type + await gu.userActionsCollect(); + await gu.setType('Numeric'); + await gu.userActionsVerify([["UpdateRecord", "_grist_Tables_column", 7, {"type": "Numeric"}]]); + + // Errors should not be present in converted column + assert.isFalse(await gu.getCellRC(0, 1).find('.field_clip').hasClass('invalid')); + + // Ensure that column transform is not occurring. + assert.isFalse(await $('.type_transform_prompt').isPresent()); + }); + + it('should use transform to convert type for non-empty columns', async function() { + // Enter text into numeric column + await gu.getCellRC(0, 1).click(); + await gu.sendKeys('one', $.ENTER); + await gu.waitForServer(); + assert.hasClass(await gu.getCellRC(0, 1).find('.field_clip'), 'invalid', true); + + // Change numeric to text + await gu.userActionsCollect(); + await gu.setType('Text'); + + // Accept, check that column is text and has no errors + await gu.applyTypeConversion(); + assert.hasClass(await gu.getCellRC(0, 1).find('.field_clip'), 'invalid', false); + await gu.userActionsVerify([ + ["AddColumn", "Table1", "gristHelper_Converted", { type: 'Any' }], + ["AddColumn", "Table1", "gristHelper_Transform", { type: 'Any' }], + ["ModifyColumn", "Table1", "gristHelper_Converted", { + "formula": "", + "isFormula": false, + "type": "Text", + "visibleCol": 0 + }], + ["ModifyColumn", "Table1", "gristHelper_Transform", { + "formula": "rec.gristHelper_Converted", + "isFormula": true, + "type": "Text", + "visibleCol": 0 + }], + ["ConvertFromColumn", "Table1", "F", "gristHelper_Converted", "Text", "", 0], + + // Repeated conversion just before applying + ["ConvertFromColumn", "Table1", "F", "gristHelper_Converted", "Text", "", 0], + + ["CopyFromColumn", "Table1", "gristHelper_Transform", "F", + "{\"widget\":\"TextBox\",\"alignment\":\"left\"}"], + ["RemoveColumn", "Table1", "gristHelper_Transform"], + ["RemoveColumn", "Table1", "gristHelper_Converted"], + ]); + + // Check that selected reads text + await gu.assertType('Text'); + }); + + it('should allow cancelling type changes', async function() { + // Enter bools into text column + await gu.getCellRC(0, 1).click(); + await gu.sendKeys('false', $.ENTER); + await gu.getCellRC(1, 1).click(); + await gu.sendKeys('true', $.ENTER); + + // Change text to bool + await gu.setType('Toggle'); + + // Check that column appears bool during transform + assert.isDisplayed(await gu.getCellRC(1, 1).find('.widget_checkmark').wait(), true); + assert.isDisplayed(await gu.getCellRC(0, 1).find('.widget_checkmark'), false); + assert.hasClass(await gu.getCellRC(0, 1).find('.field_clip'), 'invalid', false); + + // Cancel transform, check that column is still text + await $('.test-type-transform-cancel').wait().click(); + assert.equal(await gu.getCellRC(0, 1).find('.field_clip').text(), 'false'); + + // Check that selected reads text + await gu.assertType('Text'); + }); + + it('should allow revising type changes', async function() { + // Change text to integer + await gu.setType('Integer'); + + // Revise formula to get text length and accept + await $('.test-type-transform-revise').wait().click(); + await $('.test-type-transform-formula').click(); + await gu.sendKeys($.SELECT_ALL, $.DELETE, 'return len($F) + 1'); + + // Check that updating the type conversion works + await $('.test-type-transform-update').click(); + await gu.waitForServer(); + assert.equal(await gu.getCellRC(0, 1).find('.field_clip').text(), '6'); + + // Check that applying the type conversion without first updating works + // (the weird formula keeps other tests consistent with past behaviour) + await $('.test-type-transform-formula').click(); + await gu.sendKeys($.SELECT_ALL, $.DELETE, 'return len($F.replace("0", "0.0"))'); + await gu.waitForServer(); + await gu.applyTypeConversion(); + + // Check that column is integer and has no errors + assert.equal(await gu.getCellRC(0, 1).find('.field_clip').text(), '5'); + assert.isFalse(await gu.getCellRC(0, 1).find('.field_clip').hasClass('invalid')); + }); + + it('should allow configuring reference changes', async function() { + // Prepare new table and section + await gu.actions.addNewSection('New', 'Table'); + await gu.waitForServer(); + await $('.test-viewlayout-section-4').click(); + await gu.addRecord(['green']); + await gu.addRecord(['blue']); + + // Change type to reference column + await gu.actions.viewSection('Table1').selectSection(); + await gu.getCellRC(0, 3).click(); + await gu.waitAppFocus(true); + await gu.sendKeys('blue', $.ENTER); + await gu.getCellRC(1, 3).click(); + await gu.sendKeys('green', $.ENTER); + await gu.waitForServer(); + await gu.userActionsCollect(); + await gu.setType('Reference'); + + // Assert the correct column is selected and that the formula matches the selected + assert.equal(await $('.test-fbuilder-ref-table-select .test-select-row').getText(), 'Table2'); + assert.equal(await $('.test-fbuilder-ref-col-select .test-select-row').getText(), 'A'); + await $('.test-type-transform-revise').click(); + var aceText = await gu.getAceText($('.test-type-transform-formula').elem()); + assert.equal(aceText, "rec.gristHelper_Converted"); + + // Apply transform and check that field is a reference + await gu.applyTypeConversion(); + await gu.userActionsVerify([ + ["AddColumn", "Table1", "gristHelper_Converted", { type: 'Any' }], + ["AddColumn", "Table1", "gristHelper_Transform", { type: 'Any' }], + ["ModifyColumn", "Table1", "gristHelper_Converted", { + "formula": "", + "isFormula": false, + "type": "Ref:Table2", + "visibleCol": 9 + }], + ["ModifyColumn", "Table1", "gristHelper_Transform", { + "formula": "rec.gristHelper_Converted", + "isFormula": true, + "type": "Ref:Table2", + "visibleCol": 9 + }], + ["ConvertFromColumn", "Table1", "C", "gristHelper_Converted", "Ref:Table2", "", 9], + // Set display formula for transform column. + ["SetDisplayFormula", "Table1", null, 13, "$gristHelper_Transform.A"], + + // Repeated conversion just before applying + ["ConvertFromColumn", "Table1", "C", "gristHelper_Converted", "Ref:Table2", "", 9], + + ["CopyFromColumn", "Table1", "gristHelper_Transform", "C", "{\"widget\":\"Reference\",\"alignment\":\"left\"}"], + // We used to unset field display formula, but we don't actually use it during transforms. + ["RemoveColumn", "Table1", "gristHelper_Transform"], + ["RemoveColumn", "Table1", "gristHelper_Converted"], + ]); + + assert.hasClass(await gu.getCellRC(0, 3).find('.field_clip div'), 'test-ref-link-icon'); + + // Check conversion back to text + await gu.setType('Text'); + await $('.test-type-transform-revise').click(); + aceText = await gu.getAceText($('.test-type-transform-formula').elem()); + assert.equal(aceText, 'rec.gristHelper_Converted'); + await gu.applyTypeConversion(); + assert.equal(await gu.getCellRC(0, 3).find('.field_clip').getText(), 'blue'); + }); + + it('should allow configuring date and datetime changes', async function() { + await gu.toggleSidePanel("left", "close"); + await gu.getCellRC(0, 2).scrollIntoView({inline: "end"}).click(); + await gu.sendKeys('4/2/93', $.ENTER); + await gu.getCellRC(1, 2).click(); + await gu.sendKeys('4/26/16', $.ENTER); + + // Convert to Date + await gu.setType('Date'); + // Guessed date format M/D/YY + assert.equal(await gu.dateFormat(), 'Custom'); + assert.equal(await $('$Widget_dateCustomFormat input').val(), 'M/D/YY'); + // Change manually to a more formal date format + await gu.dateFormat('MM/DD/YYYY'); + assert.equal(await gu.dateFormat(), 'MM/DD/YYYY'); + await gu.waitForServer(); + // Check formula + await $('.test-type-transform-revise').wait().click(); + var aceText = await gu.getAceText($('.test-type-transform-formula').elem()); + assert.equal(aceText, "rec.gristHelper_Converted"); + + // Apply transform and check that field has correct value + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [ + '04/02/1993', '04/26/2016' + ]); + + // Convert back to text + await gu.setType('Text'); + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [ + '04/02/1993', '04/26/2016' + ]); + + await gu.getCellRC(0, 2).click(); + await gu.sendKeys($.ENTER, ' 12:00am'); + await gu.getCellRC(1, 2).click(); + await gu.sendKeys($.ENTER, ' 4:00am', $.ENTER); + + // Convert to DateTime and assert formula matches options + await gu.setType('DateTime'); + await $('.test-tz-autocomplete').click(); + await gu.sendKeys('Los_Ang', $.ENTER); + await gu.waitForServer(); + assert.equal(await $('.test-tz-autocomplete input').val(), 'America/Los_Angeles'); + assert.equal(await gu.dateFormat(), 'MM/DD/YYYY'); + assert.equal(await gu.timeFormat(), 'h:mma'); + await $('.test-type-transform-revise').click(); + aceText = await gu.getAceText($('.test-type-transform-formula').elem()); + assert.equal(aceText, "rec.gristHelper_Converted"); + + // Apply transform and check that field has correct value + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [ + '04/02/1993 12:00am', '04/26/2016 4:00am', + ]); + assert.equal(await $('.test-tz-autocomplete input').val(), 'America/Los_Angeles'); + + // Convert DateTime to Date and check that we are getting the right date. + await gu.setType('Date'); + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [ + '04/02/1993', '04/26/2016' + ]); + + // Convert Date to DateTime and check that we are getting midnight in selected timezone. + await gu.setType('DateTime'); + await gu.timeFormat('HH:mm z'); + await gu.waitForServer(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [ + '04/02/1993 00:00 EST', '04/26/2016 00:00 EDT' + ]); + await $('.test-tz-autocomplete').click(); + await gu.sendKeys('Los_Ang', $.ENTER); + await gu.waitForServer(); + assert.equal(await $('.test-tz-autocomplete input').val(), 'America/Los_Angeles'); + assert.equal(await gu.dateFormat(), 'MM/DD/YYYY'); + assert.equal(await gu.timeFormat(), 'HH:mm z'); + await gu.waitForServer(); + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [ + '04/02/1993 00:00 PST', '04/26/2016 00:00 PDT' + ]); + }); + + it('should trigger a transform when reference table is changed', async function() { + // Set up conditions for the test + await gu.getSection('Table1').click(); + await gu.enterGridValues(2, 3, [['red', 'yellow']]); + await gu.actions.addNewSection('New', 'Table'); + await gu.getSection('TABLE3').click(); + await gu.enterGridValues(0, 1, [['yellow', 'red', 'green', 'blue']]); + await gu.getSection('Table1').click(); + await gu.clickCellRC(0, 3); + await gu.openSidePane('field'); + await gu.setType('Reference'); + await gu.setRefTable('Table2'); + await gu.waitForServer(); + await gu.setVisibleCol('A'); + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3], mapper: valText}), [ + 'blue', 'green', '!red', '!yellow' + ]); + + // Check that row ids shows 2, 1, (AltText), (AltText) + await gu.setVisibleCol('Row ID'); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}), + ['Table2[2]', 'Table2[1]', 'red', 'yellow']); + await gu.setVisibleCol('A'); + + // Should trigger the transform + await gu.setRefTable('Table3'); + await gu.waitForServer(); + await gu.setVisibleCol('B'); + await assert.isPresent($('.type_transform_prompt')); + + // Transform should follow the format Ref: -> Text -> Ref: + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}), + ['blue', 'green', 'red', 'yellow']); + // Check that the cells are no longer invalid. + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3], mapper: valText}), [ + 'blue', 'green', 'red', 'yellow' + ]); + + // Check that row ids have changed, despite text remaining the same. + await gu.setVisibleCol('Row ID'); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}), + ['Table3[4]', 'Table3[3]', 'Table3[2]', 'Table3[1]']); + }); + + it('should allow undoing a reference transform in one step', async function() { + await gu.setVisibleCol('B'); + await gu.setType('Text'); + await gu.applyTypeConversion(); + await gu.setType('Reference'); + await gu.applyTypeConversion(); + await gu.undo(); + // Undoing once should return the column to Text with the correct values. + await gu.assertType('Text'); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}), + ['blue', 'green', 'red', 'yellow']); + }); + + it('should cancel an in-progress transformation on undo', async function() { + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}), + ['blue', 'green', 'red', 'yellow']); + await gu.setType('Reference'); + await gu.assertType('Reference'); + await assert.isPresent($('.test-type-transform-top'), true); + await gu.undo(); + await assert.isPresent($('.test-type-transform-top'), false); + await gu.assertType('Text'); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}), + ['blue', 'green', 'red', 'yellow']); + }); + + // NOTE: This tests a bug fix where integer values not present in the reference + // column were mistaken for row ids and converted to row values instead of AltText values. + it('should properly convert from integer to reference', async function() { + // Set up conditions for the test + await gu.getSection('TABLE3').click(); + await gu.enterGridValues(0, 2, [['3', '3', '4', '1']]); + await gu.waitForServer(); + await gu.setType('Integer'); + await gu.applyTypeConversion(); + + // Begin convert to reference. + await gu.setType('Reference'); + await gu.assertType('Reference'); + await assert.isPresent($('.test-type-transform-top'), true); + + // Convert to a reference and check that the values are valid and as expected + // before and after the conversion. The last row should be invalid since there + // is no matching record in the destination col. + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [ + '3', '3', '4', '!1' + ]); + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [ + '3', '3', '4', '!1' + ]); + }); + + // NOTE: This tests a bug fix where reference transforms to numeric types gave + // error values by default. + it('should properly convert from reference to integer/numeric', async function() { + await gu.clickCellRC(0, 2); + + // Convert to an integer and check that the values are valid and as expected before + // and after the conversion. This ensures that AltText values can be cast back into ints. + await gu.setType('Integer'); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [ + '3', '3', '4', '1' + ]); + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [ + '3', '3', '4', '1' + ]); + + // Switch back to a reference column + await gu.setType('Reference'); + await gu.assertType('Reference'); + await assert.isPresent($('.test-type-transform-top'), true); + await gu.applyTypeConversion(); + + // Convert to numeric and check the values are valid and as expected. + // This ensures that AltText values can be cast back into floats. + await gu.setType('Numeric'); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [ + '3', '3', '4', '1' + ]); + await gu.applyTypeConversion(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [ + '3', '3', '4', '1' + ]); + }); + + // NOTE: This tests a bug fix where numeric types were not properly converted to + // boolean values. + it('should properly convert from integer/numeric to boolean', async function() { + // Update the Numeric column to include some falsy/truthy numbers and alttext. + await gu.clickCellRC(0, 2); + await gu.sendKeys('0'); + await gu.clickCellRC(4, 2); + await gu.sendKeys('False', $.ENTER); + await gu.waitForServer(); + await gu.sendKeys('true', $.ENTER); + await gu.waitForServer(); + + // Assert that the values are set up properly. + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6], cols: [2], mapper: valText}), [ + '0', '3', '4', '1', '!False', '!true' + ]); + + // Convert the column to boolean. Assert all the values are valid and as expected. + await gu.setType('Toggle'); + await gu.applyTypeConversion(); + await gu.setWidget('TextBox'); + + // Check that the values have transformed without errors, and are as expected. + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6], cols: [2], mapper: valText}), [ + 'false', '!3', '!4', 'true', 'false', 'true' + ]); + // Check that sorting by the column has the expected effect. + await gu.openColumnMenu('C'); + await $(`.grist-floating-menu .test-sort-asc`).click(); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6], cols: [2], mapper: valText}), [ + 'false', 'false', 'true', 'true', '!3', '!4' + ]); + + // Undo the widget option and type conversion and assert that the values are properly restored. + // (but still sorted) + await gu.undo(2); + assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6], cols: [2], mapper: valText}), [ + '0', '1', '3', '4', '!False', '!true' + ]); + }); +}); diff --git a/test/nbrowser/UndoJumps.ntest.js b/test/nbrowser/UndoJumps.ntest.js new file mode 100644 index 00000000..87f1cdc3 --- /dev/null +++ b/test/nbrowser/UndoJumps.ntest.js @@ -0,0 +1,160 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('UndoJumps.ntest', function() { + const cleanup = test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "WorldUndo.grist", true); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + async function clickCellAndCheck(pos, text) { + let cell = gu.getCell(pos); + await cell.click(); + assert.equal(await cell.text(), text); + } + + async function getSectionAndCursor() { + let section = await $('.active_section .test-viewsection-title').wait().text(); + let cursorPos = await gu.getCursorPosition(); + let text = await gu.getActiveCell().text(); + return Object.assign({section, text}, cursorPos); + } + + function beforePos(pos) { return Object.assign({}, pos, {text: pos.text[0]}); } + function afterPos(pos) { return Object.assign({}, pos, {text: pos.text[1]}); } + + let positions = []; + + it("test setup", async function() { + // In this pseudo-testcase, we do a bunch of actions, whose undo will require jumping around. + // We store expected positions along the way, to make it easier to know what to expect. + this.timeout(Math.max(this.timeout(), 20000)); // Long-running test, unfortunately + + // In City view, record section, change a cell on screen (row 3, col 1). + await gu.actions.selectTabView('City'); + positions.push({section: 'CITY', rowNum: 3, col: 0, text: ['Aalborg', 'rec-update1']}); + await clickCellAndCheck({section: 'City', rowNum: 3, col: 0}, 'Aalborg'); + await gu.sendKeys('rec-update1', $.ENTER); + await gu.waitForServer(); + + // Then scroll and change another cell off screen, near the bottom (row 4070, col 3). + await gu.sendKeys([$.MOD, $.DOWN]); + positions.push({section: 'CITY', rowNum: 4070, col: 2, text: ['Çorum', 'upd-2']}); + await clickCellAndCheck({section: 'CITY', rowNum: 4070, col: 2}, 'Çorum'); + await gu.sendKeys('upd-2', $.ENTER); + await gu.waitForServer(); + + // In City view, detail section, change a cell too (row 20, col 'Name'). + await gu.actions.viewSection('CITY Card List').selectSection(); + await gu.sendKeys([$.MOD, $.UP]); + await gu.sendKeys([$.MOD, 'F'], 'bogotá', $.ESCAPE); + // discard notification + await $(".test-notifier-toast-close").wait(100).click(); + positions.push({section: 'CITY Card List', rowNum: 20, col: 'Name', + text: ['Santafé de Bogotá', 'det-update']}); + let cell = gu.getDetailCell('Name', 20); + await cell.click(); + assert.equal(await cell.text(), 'Santafé de Bogotá'); + await gu.sendKeys('det-update', $.ENTER); + await gu.waitForServer(); + + // Switch to different view (Country), scroll to bottom and add a record (row 240, col 2). + await gu.actions.selectTabView('Country'); + await gu.sendKeys([$.MOD, $.DOWN], $.RIGHT); + positions.push({section: 'COUNTRY', rowNum: 240, col: 1, text: ['', 'country-add']}); + await gu.sendKeys('country-add', $.ENTER); + await gu.waitForServer(); + + // Switch back to City view, and add a record by inserting before row 10. + await gu.actions.selectTabView('City'); + await gu.actions.viewSection('City').selectSection(); + await gu.sendKeys([$.MOD, $.UP]); + positions.push({section: 'CITY', rowNum: 10, col: 1, text: ['United Kingdom', '']}); + await gu.clickCell({section: 'City', rowNum: 10, col: 1}); + await gu.sendKeys([$.MOD, $.SHIFT, $.ENTER]); + await gu.waitForServer(); + + // Switch back to Country view, delete a record (row 6) + await gu.actions.selectTabView('Country'); + await gu.sendKeys([$.MOD, $.UP]); + positions.push({section: 'COUNTRY', rowNum: 5, col: 1, text: ['Albania', 'Andorra']}); + await clickCellAndCheck({section: 'Country', rowNum: 5, col: 1}, 'Albania'); + await gu.sendKeys([$.MOD, $.DELETE]); + await gu.confirm(true, true); // confirm and remember + await gu.waitForServer(); + + // Switch back to City view, place cursor onto (row 8, col 'District'), delete column. + await gu.actions.selectTabView('City'); + await gu.sendKeys([$.MOD, $.UP]); + positions.push({section: 'CITY', rowNum: 7, col: 2, text: ['Hakassia', '169200']}); + await clickCellAndCheck({section: 'City', rowNum: 7, col: 2}, 'Hakassia'); + await gu.sendKeys([$.ALT, '-']); + await gu.waitForServer(); + + // Switch to Country view, and add a column. + await gu.actions.selectTabView('Country'); + positions.push({section: 'COUNTRY', rowNum: 4, col: 2, text: ['North America', '']}); + await clickCellAndCheck({section: 'Country', rowNum: 4, col: 2}, 'North America'); + await gu.sendKeys([$.ALT, $.SHIFT, '=']); + await gu.waitForServer(); + await gu.sendKeys($.ENTER); + }); + + async function check_undos() { + // Initial position, at the end of the setup (on a newly-added column). + assert.deepEqual(await getSectionAndCursor(), + {section: 'COUNTRY', rowNum: 4, col: 2, text: ''}); + + // Move to a different place. + await gu.clickCell({section: 'CountryLanguage', rowNum: 1, col: 'Percentage'}); + + // Now call undo repeatedly, comparing positions recorded in the `positions` list. + for (let i = positions.length - 1; i >= 0; i--) { + await gu.undo(); + assert.deepEqual(await getSectionAndCursor(), beforePos(positions[i]), + `Undo position #${i} doesn't match`); + } + + // Just to make sure these checks actually ran, verify where we are. + assert.deepEqual(await getSectionAndCursor(), + {section: 'CITY', rowNum: 3, col: 0, text: 'Aalborg'}); + assert.equal(positions.length, 8); + } + + it("should jump to position of last action on undo", async function() { + // Undo each action, verifying cursor position each time. + await check_undos(); + }); + + it("should jump to position of last action on redo", async function() { + // Redo each action, verifying cursor position each time. + + // Move to a different view/place. + await gu.actions.selectTabView('Country'); + await gu.clickCell({section: 'Country', rowNum: 239, col: 'Name'}); + await gu.clickCell({section: 'CountryLanguage', rowNum: 1, col: 'Percentage'}); + + // Now call redo repeatedly, verifying recorded positions. + for (let i = 0; i < positions.length; i++) { + await gu.redo(); + assert.deepEqual(await getSectionAndCursor(), afterPos(positions[i]), + `Redo position #${i} doesn't match`); + } + + // To make sure checks ran, verify where we are. + assert.deepEqual(await getSectionAndCursor(), + {section: 'COUNTRY', rowNum: 4, col: 2, text: ''}); + assert.equal(positions.length, 8); + }); + + it("should jump again on second undo after redo", async function() { + // Undo again, it should work the same way. + await check_undos(); + }); +}); diff --git a/test/nbrowser/Validations.ntest.js b/test/nbrowser/Validations.ntest.js new file mode 100644 index 00000000..a2c0fb77 --- /dev/null +++ b/test/nbrowser/Validations.ntest.js @@ -0,0 +1,101 @@ +import { removePrefix } from 'app/common/gutil'; +import { assert, driver } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('Validations.ntest', function() { + const cleanup = test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.useFixtureDoc(cleanup, "Hello.grist", true); + await driver.executeScript(`window.gristApp.enableFeature('validationsTool', true)`); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it("can add empty validation, which should not show indicators", async function() { + // Open the validations config pane (lives in the Document side pane). + await gu.openSidePane('validate'); + await $('$Validation_addRule').wait(assert.isDisplayed); + + // There should be no validation lines to begin with. + assert.lengthOf(await $("$Validation_rules > .kf_row").array(), 0); + + // Create one, and wait for something to appear. + await $("$Validation_addRule").click(); + await $("$Validation_rules > .validation").wait(); + + // Now there should be one validation rule. + assert.lengthOf(await $("$Validation_rules > .validation").array(), 1); + + // Make sure there are no "validation failure" badgets. + assert.lengthOf(await $(".gridview_row .validation_error_number").array(), 0); + + // Change rule to fail always, and ensure there is a validation failure for each cell. + var formula = $("$Validation_rules .validation_formula").eq(0).find(".ace_editor").wait().elem(); + await formula.click(); + await gu.sendKeys('False'); + await $("$Validation_rules .kf_button:contains(Apply)").click(); + await gu.waitForServer(); + assert.lengthOf(await $(".gridview_row .validation_error_number").array(), 4); + + // Empty out the rule, and see that it now passes. + await driver.withActions(a => a.doubleClick(formula)); + await gu.sendKeys($.DELETE); + await $("$Validation_rules .kf_button:contains(Apply)").click(); + await gu.waitForServer(); + assert.lengthOf(await $(".gridview_row .validation_error_number").array(), 0); + }); + + /** + * Helper to fetch information about a validation failure badge. Returns an object with .text + * being the badge's text (count of failures), and .title being the title attribute. + */ + async function getRowValidation(rowIndex) { + try { + const elem = $(".gridview_data_row_num").eq(rowIndex).find(".validation_error_number"); + const text = await elem.getText(); + const title = await elem.getAttribute('title'); + return { text: text, title: removePrefix(title, "Validation failed: ") }; + } catch (e) { + if (/NoSuchElement/.test(String(e))) { return null; } + throw e; + } + } + + it("should show correct failure counts and messages", async function() { + // Enter some data into first column. + await gu.enterGridValues(0, 1, [["foo", "BAR", "17", ""]]); + await gu.waitForServer(); + + // Change rule to something non-trivial. + await $("$Validation_rules .validation_formula").eq(0).find(".ace_editor").click(); + await gu.sendKeys("$B.lower() == $B"); + await $(".validation").eq(0).findOldTimey(".kf_button:contains(Apply)").click(); // 2nd row should fail. + + // Add a rule that raises an exception. + await $("$Validation_addRule").click(); + await gu.waitForServer(); + await $("$Validation_rules .validation_formula").eq(1).find(".ace_editor").click(); + await gu.sendKeys("int($B) > 0"); + await $(".validation").eq(1).findOldTimey(".kf_button:contains(Apply)").click(); // Rows 1,2,3 should fail. + await gu.waitForServer(); + + // Assert correct number of badges, and correct numbers in them. + await gu.waitForServer(2000); + assert.lengthOf(await $(".gridview_row .validation_error_number").array(), 3); + assert.deepEqual(await getRowValidation(0), { text: "1", title: "Rule 2" }); + assert.deepEqual(await getRowValidation(1), { text: "2", title: "Rule 1, Rule 2" }); + assert.deepEqual(await getRowValidation(2), null); + assert.deepEqual(await getRowValidation(3), { text: "1", title: "Rule 2" }); + + // Now change some data and ensure badges and titles changed appropriately. + await gu.enterGridValues(0, 1, [["FOO", "100", "-17"]]); + assert.deepEqual(await getRowValidation(0), { text: "2", title: "Rule 1, Rule 2" }); + assert.deepEqual(await getRowValidation(1), null); + assert.deepEqual(await getRowValidation(2), { text: "1", title: "Rule 2" }); + assert.deepEqual(await getRowValidation(3), { text: "1", title: "Rule 2" }); + }); +}); diff --git a/test/nbrowser/ViewConfigTab.ntest.js b/test/nbrowser/ViewConfigTab.ntest.js new file mode 100644 index 00000000..2f44a568 --- /dev/null +++ b/test/nbrowser/ViewConfigTab.ntest.js @@ -0,0 +1,71 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('ViewConfigTab.ntest', function() { + test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + + await gu.actions.createNewDoc(); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should set up a new Untitled document with one table', async function() { + // Add another table to the document: click dropdown, select "New Table". + assert.deepEqual(await $(`.test-docpage-label`).array().text(), ['Table1']); + await gu.actions.addNewTable(); + assert.deepEqual(await $(`.test-docpage-label`).array().text(), ['Table1', 'Table2']); + }); + + it("should allow opening and closing view config pane", async function() { + var viewNameInput = $('.test-right-widget-title'); + + // Open the tab, and check it becomes visible. + await gu.openSidePane('view'); + await assert.isDisplayed(viewNameInput.wait(assert.isDisplayed), true); + + // Close the tab again. + await gu.toggleSidePanel('right', 'toggle'); + await assert.isPresent(viewNameInput, false); + }); + + it('should keep view config pane open across views, with correct view name', async function() { + await gu.openSidePane('view'); + + var viewNameInput = $('.test-right-widget-title'); + await assert.isDisplayed(viewNameInput.wait(assert.isDisplayed), true); + assert.equal(await viewNameInput.val(), 'TABLE2'); + + // Switch to another view, and make sure the view config side pane is still visible. + await gu.actions.selectTabView('Table1'); + await assert.isDisplayed(viewNameInput); + assert.equal(await viewNameInput.val(), 'TABLE1'); + + // Switch to a third view. + await gu.actions.selectTabView('Table2'); + await assert.isDisplayed(viewNameInput); + assert.equal(await viewNameInput.val(), 'TABLE2'); + }); + + it('should allow renaming the view from view config pane', async function() { + await gu.actions.selectTabView('Table2'); + + // Select the view name text box, select the text and replace with "Hello". + var viewNameInput = $('.test-right-widget-title'); + await gu.renameTable('Table2', 'Hello'); // I think renaming happens differently now? + + // Make sure there is now a view in Table named "Hello" + let tabs = await $(`.test-docpage-label`).array().text(); + assert.deepEqual(tabs, [ 'Table1', 'Hello' ]); + + // Switch to another view, and back to Hello, and see that the view name textbox is correct. + await gu.actions.selectTabView('Table1'); + assert.equal(await viewNameInput.val(), 'TABLE1'); + await gu.actions.selectTabView('Hello'); + assert.equal(await viewNameInput.val(), 'HELLO'); + }); +}); diff --git a/test/nbrowser/Views.ntest.js b/test/nbrowser/Views.ntest.js new file mode 100644 index 00000000..d59c31c0 --- /dev/null +++ b/test/nbrowser/Views.ntest.js @@ -0,0 +1,143 @@ +import { assert } from 'mocha-webdriver'; +import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser'; + +describe('Views.ntest', function() { + test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await gu.actions.createNewDoc(); + }); + + afterEach(function() { + return gu.checkForErrors(); + }); + + it('should allow adding and removing viewsections', async function() { + // Create two new viewsections + await gu.actions.addNewSection('Table1', 'Table'); + var recordSection = $('.test-gristdoc .view_leaf').eq(0); + var gridSection = $('.test-gristdoc .view_leaf').eq(1); + + // Check that the newest viewsection has focus + await assert.hasClass(gridSection, 'active_section'); + + await gu.actions.addNewSection('Table1', 'Card'); + var cardSection = $('.test-gristdoc .view_leaf').eq(2); + assert.lengthOf(await $('.test-gristdoc .view_leaf').array(), 3); + + // Check that the newest viewsection has focus + await assert.hasClass(cardSection, 'active_section'); + + // Click the second viewsection and check that it has focus + await gridSection.click(); + await assert.hasClass(gridSection, 'active_section'); + + // Check that viewsection titles are correct and editable + var recordTitle = recordSection.find('.test-viewsection-title'); + assert.equal(await recordTitle.text(), 'TABLE1'); + await recordTitle.click(); + await gu.renameActiveSection('foo'); + assert.equal(await recordTitle.text(), 'foo'); + + // Delete the first viewsection and check that it`s gone + await gu.actions.viewSection('foo').selectMenuOption('viewLayout', 'Delete widget'); + await gu.waitForServer(); + + assert.lengthOf(await $('.test-gristdoc .view_leaf').array(), 2); + }); + + it('should allow creating a view section for a new table', async function() { + assert.lengthOf(await $('.test-gristdoc .view_leaf').array(), 2); + await gu.actions.addNewSection('New', 'Card List'); + var newTitle = $('.test-gristdoc .test-viewsection-title').eq(2).wait(); + assert.equal(await newTitle.text(), 'TABLE2 Card List'); + }); + + it('should not create two views when creating a new view for a new table', async function() { + // This is probably test for a bug. Currently adding new tables to an existing view + // doesn't produce new views. + assert.deepEqual(await $(`.test-docpage-label`).array().text(), ['Table1']); + await gu.actions.addNewView('New', 'Table'); + assert.deepEqual(await $(`.test-docpage-label`).array().text(), ['Table1', 'Table3']); + }); + + it('should switch to a valid default view when the active view is deleted', async function() { + // This confirms a bug fix where the default view should change when the current + // default view is deleted + await gu.actions.selectTabView('Table1'); + assert.equal(await gu.actions.getActiveTab().text(), 'Table1'); + assert.deepEqual(await $(`.test-docpage-label`).array().text(), ['Table1', 'Table3']); + await gu.actions.tableView('Table1').selectOption('Remove'); + await $(".test-removepage-option-page").click(); + await $(".test-modal-confirm").click(); + await gu.waitForServer(); + assert.equal(await gu.actions.getActiveTab().text(), 'Table3'); + assert.deepEqual(await $(`.test-docpage-label`).array().text(), ['Table3']); + }); + + it('should allow adding and removing summary view sections', async function() { + await gu.removeTable("Table1"); + await gu.actions.addNewTable(); + assert.equal(await gu.actions.getActiveTab().text(), 'Table1'); + + await gu.enterGridValues(0, 0, [ + ['a', 'a', 'b'], + ['c', 'd', 'd'], + ['1', '2', '3'] + ]); + + await gu.actions.addNewSummarySection('Table1', ['A'], 'Table', 'Section Foo'); + assert.deepEqual(await gu.getGridValues({ + section: 'Section Foo', + rowNums: [1, 2], + cols: [0, 1, 2] + }), ['a', '2', '3', 'b', '1', '3']); + + await gu.actions.viewSection('Section Foo').selectMenuOption('viewLayout', 'Delete widget'); + await gu.waitForServer(); + + await gu.actions.addNewSummarySection('Table1', ['B'], 'Table', 'Section Foo'); + assert.deepEqual(await gu.getGridValues({ + section: 'Section Foo', + rowNums: [1, 2], + cols: [0, 1, 2] + }), ['c', '1', '1', 'd', '2', '5']); + }); + + it('should switch to a valid default section when the active section is deleted', async function() { + // This confirms a bug fix where the section should change when the active section is + // deleted, either directly or via the section's table being deleted. + // Reference: https://phab.getgrist.com/T327 + await gu.actions.addNewSection('New', 'Table'); + await gu.waitForServer(); + await gu.getSection('TABLE4').click(); + // Delete the section + await gu.actions.viewSection('TABLE4').selectMenuOption('viewLayout', 'Delete widget'); + await gu.waitForServer(); + // Assert that the default section (Table1 record) is now active. + assert.equal(await $('.active_section > .viewsection_title').text(), 'TABLE1'); + // Assert that focus is returned to the deleted section on undo. + await gu.undo(); + assert.equal(await $('.active_section > .viewsection_title').text(), 'TABLE4'); + // Add a sorted column to the new table. The reported bug shows a symptom of the problem is + // errors thrown when a sorted column exists in the deleted table. + await gu.clickCellRC(0, 0); + await gu.sendKeys('b', $.ENTER); + await gu.waitForServer(); + await gu.sendKeys('a', $.ENTER); + await gu.waitForServer(); + await gu.sendKeys('c', $.ENTER); + await gu.waitForServer(); + await gu.openColumnMenu('A'); + await $(`.grist-floating-menu .test-sort-asc`).click(); + // Delete the table. + await gu.removeTable("Table4"); + await gu.actions.selectTabView('Table1'); + // Assert that the default section (Table1 record) is now active. + assert.equal(await $('.active_section > .viewsection_title').text(), 'TABLE1'); + // Again, assert that focus is returned to the deleted section on undo. + await gu.undo(); + assert.equal(await $('.active_section > .viewsection_title').text(), 'TABLE4'); + }); +}); diff --git a/test/nbrowser/WebhookPage.ts b/test/nbrowser/WebhookPage.ts index c438d49d..a236674b 100644 --- a/test/nbrowser/WebhookPage.ts +++ b/test/nbrowser/WebhookPage.ts @@ -8,7 +8,7 @@ import { server } from 'test/nbrowser/testUtils'; //import { Deps as TriggersDeps } from 'app/server/lib/Triggers'; describe('WebhookPage', function () { - this.timeout(30000); + this.timeout(60000); const cleanup = setupTestSuite(); let session: gu.Session; diff --git a/test/nbrowser/gristUtil-nbrowser.js b/test/nbrowser/gristUtil-nbrowser.js new file mode 100644 index 00000000..4187c25d --- /dev/null +++ b/test/nbrowser/gristUtil-nbrowser.js @@ -0,0 +1,679 @@ +import * as _ from 'lodash'; +import { assert, driver, Key, stackWrapFunc, WebElement, + WebElementPromise } from 'mocha-webdriver'; +import { driverCompanion, findOldTimey, waitImpl, + webdriverjqWrapper } from 'test/nbrowser/webdriverjq-nbrowser'; +import * as guBase from 'test/nbrowser/gristUtils'; +import { setupTestSuite } from 'test/nbrowser/testUtils'; +import { server } from 'test/nbrowser/testServer'; + +// Simulate the old "$" object. + +const _webdriverjqFactory = webdriverjqWrapper(driver); +function $(key) { + if (typeof key !== 'string') { + return key; + } + return _webdriverjqFactory(key); +} + +function rep(n, value) { + return _.times(n, () => value); +} + +// The "$" object needs some setup done asynchronously. +// We do that later, during test initialization. +async function applyPatchesToJquerylikeObject($) { + $.MOD = await guBase.modKey(); + $.COPY = await guBase.copyKey(); + $.CUT = await guBase.cutKey(); + $.PASTE = await guBase.pasteKey(); + $.SELECT_ALL = await guBase.selectAllKey(); + const capabilities = await driver.getCapabilities(); + if (capabilities.getBrowserName() === 'chrome' && await guBase.isMac()) { + $.SELECT_ALL_LINES = (n) => [...rep(n, $.UP), $.HOME, $.SHIFT, ...rep(n, $.DOWN), $.END] + } else { + $.SELECT_ALL_LINES = () => $.SELECT_ALL; + } + $.getPage = async (url) => { + return driver.get(url); + }; + $.wait = (timeoutMs, conditionFunc) => { + return waitImpl(timeoutMs, conditionFunc); + } + for (const key of Object.keys(Key)) { + $[key] = Key[key]; + } + // We need to tweak driver object a bit too (really?) + driverCompanion.$ = $; + // driver.testHost = server.getHost(); + // driver.waitImpl = waitImpl; +} + +// Adapt common old setup. +const test = { + setupTestSuite(self, ...args) { + self.timeout(20000); + return setupTestSuite(...args); + }, +}; + +// Add some methods to the grist utils that are used by old tests. +// This could be cleaned up further, but translating to newer ways +// of doing things or, if the method is really useful, adding the +// method to the new grist utils. + +const waitForServer = guBase.waitForServer; +let patchesApplied = false; +let session; + +const gu = { + ...guBase, + + // Apply all needed patches, async initialization, and log in. + async supportOldTimeyTestCode() { + if (!patchesApplied) { + applyPatchesToWebElements(); + applyPatchesToAssert(); + applyPatchesToJquerylikeObject($); + } + patchesApplied = true; + // Login as someone so old code doesn't have to be upgraded to do it. + session = await gu.session().user('userz'); + const dbManager = await server.getDatabase(); + const profile = {email: session.email, name: session.name}; + await dbManager.getUserByLogin(session.email, {profile}); + await gu.setApiKey(session.name); + await session.login(); + }, + + // getCell with old-timey arguments. + getCellRC(r, c) { + return gu.getCell(c, r + 1); + }, + + // clickCell with old-timey arguments. + async clickCellRC(r, c) { + const cell = gu.getCell(c, r + 1); + await cell.click(); + return cell; + }, + + // sendKeys variant that accepts arrays in place of Key.chord. + sendKeys(...args) { + return guBase.sendKeys(...args.map( + a => Array.isArray(a) ? Key.chord(...a) : a + )); + }, + + /** + * When doing type conversion in the side pane, this clicks the 'Apply' button and waits for the + * conversion to complete. + */ + async applyTypeConversion() { + await $('.test-type-transform-apply').wait().scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }).click(); + await $('.test-type-transform-apply').waitDrop(assert.isPresent, false); + return gu.waitForServer(); + }, + + async clickColumnMenuItem(colName, itemText, optRightClick) { + await gu.openColumnMenu(colName); + return gu.actions.selectFloatingOption(itemText); + }, + + getOpenEditingLabel(parentElem) { + return driver.find('.test-column-title-label'); + }, + + enterGridValues(startRowIndex, startColIndex, dataMatrix) { + const transpose = dataMatrix[0].map( + (_, colIndex) => dataMatrix.map(row => row[colIndex]) + ); + return gu.enterGridRows({col: startColIndex, + rowNum: startRowIndex + 1}, transpose); + }, + + async openSidePane(tabName) { + if (['log', 'validate', 'repl', 'code'].includes(tabName)) { + await guBase.toggleSidePanel('right', 'open'); + return $(`.test-tools-${tabName}`).wait().click(); + } else if (tabName === 'field') { + await guBase.toggleSidePanel('right', 'open'); + return $('.test-right-tab-field').click(); + } else if (tabName === 'view') { + await guBase.toggleSidePanel('right', 'open'); + return $('.test-right-tab-pagewidget').wait().click(); + } + }, + + getGridValues(...options) { + return gu.getVisibleGridCells(...options); + }, + + /** + * Returns the text in the row header of the last row in a Grid section, scrolling to the + * bottom, but not moving the cursor. + * @param {String} options.section: Optional section name to use instead of the active section. + */ + async getGridLastRowText(options) { + if (options?.section) { + await gu.actions.viewSection(options.section).selectSection(); + } + return String(await gu.getGridRowCount()); + }, + + getAddRowNumber() { + return gu.getGridRowCount(); + }, + + async getGridLabels(sectionName) { + return gu.getSection(sectionName).findAll('[data-test-id="GridView_columnLabel"] .kf_elabel_text', + label => label.getText()); + }, + + /** + * Given a cell in grist GridView, returns whether it contains the cursor. You may use it as + * hasCursor(getCell(...)) or getCell(...).waitFor(hasCursor, optTimeout). + */ + hasCursor(cellElem) { + return cellElem.find('.selected_cursor').isDisplayed(); + }, + + async useFixtureDoc(cleanup, fname, flag) { + return session.tempDoc(cleanup, fname); + }, + + async copyDoc(docId, flag) { + const result = await guBase.copyDoc(session.name, 'docs', 'Home', docId); + await session.loadDoc(`/doc/${result.id}`); + return result; + }, + + async clickCell(rowIndexOrPosOrCell, colIndex) { + if (typeof rowIndexOrPosOrCell === 'object' && 'driver_' in rowIndexOrPosOrCell) { + return rowIndexOrPosOrCell.click(); + } + // Best just to force a rewrite of clickCell, e.g. to clickCellRC, + // since newer gristUtils interprets arguments entirely differently. + if (typeof rowIndexOrPosOrCell === 'number') { + throw new Error("ambiguous row/col"); + } + const cell = guBase.getCell(rowIndexOrPosOrCell, colIndex); + await cell.click(); + }, + + /** + * Sets the visibleCol of the currently selected field to value. + */ + async setVisibleCol(value) { + await gu.openSidePane('field'); + await $('.test-fbuilder-ref-col-select').click(); + await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click(); + return waitForServer(); + }, + + /** + * Asserts the type of the currently selected field. + */ + async assertType(value) { + await gu.openSidePane('field'); + assert.equal(await $('.test-fbuilder-type-select .test-select-row').getText(), value); + }, + + closeSidePane() { + return gu.toggleSidePanel('right', 'close'); + }, + + clickVisibleDetailCells(column, rowNums) { + return gu.getDetailCell(column, rowNums[0]).click(); + }, + + async clickRowMenuItem(rowNum, item) { + await (await gu.openRowMenu(rowNum)).findContent('li', item).click(); + }, + + /** + * Selects rows starting from rowStart and ending at rowEnd (1-based) by clicking and dragging or + * shift clicking (Defaults to dragging) + * @param {int} rowStart: 1-based row number + * @param {int} rowEnd: 1-based row number. + * @param {String} optMethod: if 'shift' then shift clicking is used to select rows otherwise it + * defaults to drag to select. + */ + async selectRows(rowStart, rowEnd, optMethod) { + let start = await driver.findContent('.active_section .gridview_data_row_num', gu.exactMatch(rowStart.toString())); + let end = await driver.findContent('.active_section .gridview_data_row_num', gu.exactMatch(rowEnd.toString())); + if (optMethod === 'shift') { + await driver.withActions(a => a.click(start).keyDown($.SHIFT).click(end).keyUp($.SHIFT)); + } else { + await driver.withActions(a => a.move({origin: start}).press().move({origin: end}).release()); + } + }, + + async _fieldSettingsClickOption(isCommonToSeparate, optionSubstring) { + assert.include(await $('.fieldbuilder_settings_button').text(), isCommonToSeparate ? 'Common' : 'Separate'); + await $('.fieldbuilder_settings_button').click(); + await gu.actions.selectFloatingOption(optionSubstring); + await waitForServer(); + assert.include(await $('.fieldbuilder_settings_button').text(), isCommonToSeparate ? 'Separate' : 'Common'); + }, + fieldSettingsUseSeparate: () => gu._fieldSettingsClickOption(true, 'Use separate'), + fieldSettingsSaveAsCommon: () => gu._fieldSettingsClickOption(false, 'Save as common'), + fieldSettingsRevertToCommon: () => gu._fieldSettingsClickOption(false, 'Revert to common'), + + /** + * Changes date format for date and datetime editor or returns current format + * @param {string} value Date format + */ + async dateFormat(value) { + if (!value) { + return $('$Widget_dateFormat .test-select-row').text(); + } + await $('$Widget_dateFormat').wait().click(); + await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click(); + }, + + /** + * Changes time format for datetime editor or returns current format + * @param {string} value Time format + */ + async timeFormat(value) { + if (!value) { + return $('$Widget_timeFormat .test-select-row').getText(); + } + await $('$Widget_timeFormat').wait().click(); + await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click(); + }, + + async getDetailValues(...options) { + return gu.getVisibleDetailCells(...options); + }, + + /** + * Selects all cells in a GridView between and including startCell and endCell + * @param {Array} startCell: + * startCell[0]: 1-based row index. + * startCell[1]: 0-based column index. + * @param {Array} endCell: + * endCell[0]: 1-based row index. + * endCell[1]: 0-based column index. + **/ + async selectGridArea(startCell, endCell) { + let start = await gu.getCell({rowNum: startCell[0], col: startCell[1]}); + let end = await gu.getCell({rowNum: endCell[0], col: endCell[1]}); + await driver.withActions(a => a.click(start).keyDown($.SHIFT).click(end).keyUp($.SHIFT)); + }, + + /** + * Returns text of the cells for the given rows and columns of a viewSection. + * @param {String} option.section: Optional section name instead of active. + * @param {Array} option.rowNums: Array of row numbers (1-based) + * @param {Array} option.cols: Array of column indices (0-based) or labels. + * @param [Number: Function] option.cellFunc: a function that returns cells given an array of + * columns, rows and optionally a viewsection (defaults to the currently active section) + * @param [Number: Function] option.valueFunc: Optional function, or an object mapping column + * index or label (as in options.cols) to function, with the function mapping a cell to + * its value (by default, cell => cell.text()). + * @returns {Promise} Returns array of values for each requested cell, as all values from + * the first row, followed by values from the second, etc. + */ + async getSectionValues(options) { + var opts = { section: options.section }; + var defaultValueFunc = (cell => cell.text()); + var valueFunc; + if (options.valueFunc && !_.isFunction(options.valueFunc)) { + valueFunc = (col => options.valueFunc[col] || defaultValueFunc); + } else { + valueFunc = _.constant(options.valueFunc || defaultValueFunc); + } + const colValues = []; + for (const col of options.cols) { + const colValue = await valueFunc(col)(options.cellFunc(col, options.rowNums, opts).array()); + colValues.push(colValue); + } + return _.flatten(_.zip.apply(_, colValues), true); + }, + + /** + * Asserts the widget of the currently selected field. + */ + async assertWidget(value) { + await gu.openSidePane('field'); + assert.equal(await $('.test-fbuilder-widget-select .test-select-row').getText(), value); + }, + + /** + * Sets the widget of the currently selected field to value. + */ + async setWidget(value) { + await gu.openSidePane('field'); + const selector = $('.test-fbuilder-widget-select'); + const btnChildren = await selector.elem().findAll('.test-select-button'); + if (btnChildren.length > 0) { + // This is a button select. + await selector.findOldTimey(`.test-select-button:contains(${value})`).click(); + } else { + // This is a dropdown select. + await selector.click(); + await $(`.test-select-menu .test-select-row:contains(${value})`).click(); + } + await gu.waitForServer(); + }, + + /** + * Adds a new record to the grid. Takes an array of values that matches column positions. + */ + async addRecord(values) { + await gu.sendKeys([$.MOD, $.UP]); + await gu.sendKeys([$.MOD, $.DOWN]); + await gu.sendKeys([$.LEFT]); + await gu.sendKeys([$.LEFT]); + await gu.sendKeys([$.LEFT]); + await gu.sendKeys([$.LEFT]); + await gu.sendKeys([$.LEFT]); + await driver.sleep(1000); + // For each value, type it, followed by Tab. + for (const [i, value] of values.entries()) { + await gu.waitAppFocus(true); + await gu.sendKeys(value, $.TAB); + await gu.waitForServer(); + if (i === 0) { + // The very first value triggers add-record, but the creation of the new row isn't + // immediate, so give it a moment. + await driver.sleep(250); + } + } + // Return a promise that can be awaited; it will wait for all the previously queued ones. + return driver.sleep(0); + }, + + actions: { + createNewDoc: async (optDocName) => { + await gu.simulateLogin("Chimpy", "chimpy@getgrist.com", "nasa"); + const docId = await gu.createNewDoc('chimpy', 'nasa', 'Horizon', optDocName || 'Untitled'); + await gu.loadDoc(`/o/nasa/doc/${docId}`); + }, + getDocTitle: () => { + return $('.test-bc-doc').val(); + }, + getActiveTab: () => { + return $('.test-treeview-itemHeader.selected .test-docpage-label').wait(); + }, + getTabs: () => { + return $('.test-docpage-label'); + }, + renameDoc: (newName) => { + $('.test-bc-doc').click(); + $.driver.sendKeys(newName, $.ENTER); + return $.wait(1000, () => $.driver.getTitle().startsWith(newName + ' - ')); + }, + selectTabView: async (viewTitle) => { + const isOpen = await gu.isSidePanelOpen('left'); + if (!isOpen) { + await gu.toggleSidePanel('left', 'open'); + } + await gu.openPage(viewTitle); + if (!isOpen) { + await gu.toggleSidePanel('left', 'close'); + } + }, + addNewTable: async () => { + await $('.test-dp-add-new').wait().click(); + await $('.test-dp-empty-table').click(); + // if we selected a new table, there will be a popup for a name + const prompts = await $(".test-modal-prompt"); + const prompt = prompts[0]; + if (prompt) { + await await $(".test-modal-confirm").click(); + } + return gu.waitForServer(); + }, + addNewSection: (tableId, sectionType) => { + return gu.addNewSection(sectionType, tableId); + }, + addNewSummarySection: async (tableId, groupByArr, sectionType, sectionName) => { + await gu.addNewSection(sectionType, tableId, {summarize: groupByArr}); + await gu.waitForServer(); + await gu.renameActiveSection(sectionName); + await gu.waitForServer(); + }, + addNewView: (tableId, sectionType) => { + return gu.addNewPage(sectionType, tableId); + }, + selectFloatingOption: async (optionName) => { + // Sometimes the element is there but "not interactable". Work around that. + await gu.waitToPass(async () => { + await $(`.grist-floating-menu li:contains(${optionName})`).click(); + }); + }, + /** + * Actions related to view section. To use, pass in the section name. + * @param {string} sectionName - Title of the view section + * @return Object} Collection of methods for the view section. + * + * @example + * gu.actions.viewSection('Table1 record').selectMenuOption('Insert section'); + */ + viewSection: (sectionName) => { + let section = gu.getSection(sectionName); + return { + /** + * Clicks inside to make the current section active. + */ + selectSection: function () { + return gu.selectSectionByTitle(sectionName); + }, + /** + * Opens the view section drop-down menu. + * @param {string} which - Which menu to open, coud be: 'sortAndFilter' or 'viewLayout' + */ + openMenu: async function (which) { + await driver.withActions(a => a.move({origin: section.find('.viewsection_title')})); // to display menu buttons on hover + const item = section.find(`.test-section-menu-${which}`); + await gu.waitToPass(() => item.click()); + }, + /** + * Opens the section drop-down menu and select option matching param. + * @param {string} which - Which menu to open, coud be: 'sortAndFilter' or 'viewLayout' + * @param {string} optionName + */ + selectMenuOption: function (which, optionName) { + this.openMenu(which); + return gu.actions.selectFloatingOption(optionName); + } + }; + }, + tableView: (tableName, viewName) => { + return { + select: () => { + return gu.getPageItem(tableName).click(); + }, + selectOption: async optionName => { + await gu.openPageMenu(tableName); + return gu.actions.selectFloatingOption(optionName); + } + }; + } + } +}; + +/** + * This monkey-patches the WebElement class to make it look enough like + * jquery that a lot of old test code can be used without modification. + */ +function applyPatchesToWebElements() { + + WebElement.prototype.wait = function(fn, ...args) { + if (fn) { + return gu.waitToPass(async () => { + return fn.apply(null, [this, ...args]); + }).then(() => true); + } else { + return new WebElementPromise( + driver, + gu.waitToPass(async () => { + if (!(await this.isPresent())) { + throw new Error('not present'); + } + }).then(() => this)); + } + } + + WebElement.prototype.selected = function(val) { + return driver.executeScript((elem, val) => { + elem.selected = val; + }, this, val); + } + + WebElement.prototype.attr = function(key, val) { + if (val !== undefined) { + return driver.executeScript((elem, key, val) => { + elem.setAttribute(key, val); + }, this, key, val); + } + return this.getAttribute(key); + } + + WebElement.prototype.classList = async function() { + return (await this.getAttribute('className')).split(' '); + } + + // Lists of WebElements work differently - if we did a find() we + // already have just the first match. + WebElement.prototype.first = function() { + return this; + } + + WebElement.prototype.text = function() { + return this.getText(); + } + + WebElement.prototype.val = function(newVal) { + if (newVal === undefined) { + return this.getAttribute('value'); + } + return gu.setValue(this, newVal); + } + + WebElement.prototype.css = function(key, val) { + if (val === undefined) { + return this.getCssValue(key); + } + return new WebElementPromise( + driver, + driver.executeScript(elem => { + elem.style[key] = val; + }, this) + ); + } + + WebElement.prototype.is = function(selector) { + return this.matches(selector); + } + + WebElement.prototype.hasClass = async function(className) { + return (await this.classList()).includes(className); + } + + WebElement.prototype.scrollIntoView = function(opts) { + opts = opts || {behavior: 'auto'}; + return new WebElementPromise( + driver, + driver.executeScript((elem, opts) => elem.scrollIntoView(opts), + this, opts).then(() => this)); + } + + WebElement.prototype.parent = function() { + return new WebElementPromise( + driver, + driver.executeScript(elem => { + return elem.parentNode.closest('*'); + }, this) + ); + } + + WebElement.prototype.closest = function(key) { + return this.findClosest(key); + } + + WebElement.prototype.children = async function(mapper) { + // Collect children. + let result = await driver.executeScript(elem => { + return [...elem.children].map(c => c.closest('*')); + }, this); + // Fix up type. + result = result.map(v => new WebElementPromise( + driver, + Promise.resolve(v), + )); + // Apply mapper if available. + if (mapper) { + result = result.map(mapper); + } + // Result is a single promise. + return Promise.all(result); + } + + WebElement.prototype.trimmedText = async function() { + const text = await this.getText(); + return text.trim(); + } + + // A version of find() that supports some old timey syntax. + WebElement.prototype.findOldTimey = function(key) { + return findOldTimey(this, key); + } + + WebElement.prototype.findAllOldTimey = function(key, mapper) { + return findOldTimey(this, key, true, mapper); + } +} + +/** + * This monkey-patches assert to add some methods that are very + * commonly used. + */ +function applyPatchesToAssert() { + + assert.hasClass = stackWrapFunc(async function(elem, className, present) { + if (present === undefined) { + present = true; + } + const c = await elem.getAttribute('class'); + if (present) { + await assert.include(c.split(' '), className); + } else { + await assert.notInclude(c.split(' '), className); + } + }); + + assert.isPresent = stackWrapFunc(async function(elem, present) { + if (present === undefined) { + present = true; + } + let current = false; + try { + current = await elem.isPresent(); + } catch (e) { + // $ object may fail if elem is non-existent. + } + await assert.equal(current, present); + return true; + }); + + assert.isDisplayed = stackWrapFunc(async function(elem, displayed) { + if (displayed === undefined) { + displayed = true; + } + await assert.equal(await elem.isDisplayed(), displayed); + return true; + }); +} + +exports.$ = $; +exports.gu = gu; +exports.server = server; +exports.test = test; diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index d8c8423b..c78a2475 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1,5 +1,5 @@ /** - * Replicates functionality of test/browser/gristUtils.ts for new-style tests. + * Replicates functionality of test/nbrowser/gristUtils.ts for new-style tests. * * The helpers are themselves tested in TestGristUtils.ts. */ @@ -33,15 +33,20 @@ import type { AssertionError } from 'assert'; namespace gristUtils { // Allow overriding the global 'driver' to use in gristUtil. -let driver: WebDriver; +let _driver: WebDriver|undefined; +const driver: WebDriver = new Proxy({} as any, { + get(_, prop) { + if (!_driver) { + return (driverOrig as any)[prop]; + } + return (_driver as any)[prop]; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion + } +}); export function currentDriver() { return driver; } // Substitute a custom driver to use with gristUtils functions. Omit argument to restore to default. -export function setDriver(customDriver: WebDriver = driverOrig) { driver = customDriver; } - -// Set the 'driver' to use here in before() callback, because driverOrig isn't set until then. -before(() => setDriver()); +export function setDriver(customDriver?: WebDriver) { _driver = customDriver; } const homeUtil = new HomeUtil(testUtils.fixturesRoot, server); diff --git a/test/nbrowser/homeUtil.ts b/test/nbrowser/homeUtil.ts index cb7e1653..82da0369 100644 --- a/test/nbrowser/homeUtil.ts +++ b/test/nbrowser/homeUtil.ts @@ -15,8 +15,9 @@ import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import log from 'app/server/lib/log'; import {TestingHooksClient} from 'app/server/lib/TestingHooks'; +import EventEmitter = require('events'); -export interface Server { +export interface Server extends EventEmitter { driver: WebDriver; getTestingHooks(): Promise; getHost(): string; @@ -53,7 +54,11 @@ export class HomeUtil { // of the home api available while making browser tests. private _apiKey = new Map(); - constructor(public fixturesRoot: string, public server: Server) {} + constructor(public fixturesRoot: string, public server: Server) { + server.on('stop', () => { + this._apiKey.clear(); + }); + } public get driver(): WebDriver { return this.server.driver; } @@ -134,7 +139,8 @@ export class HomeUtil { // Take this opportunity to cache access info. if (!this._apiKey.has(email)) { await this.driver.get(this.server.getUrl(org || 'docs', '')); - this._apiKey.set(email, await this._getApiKey()); + const apiKey = await this._getApiKey(); + this._apiKey.set(email, apiKey); } } } diff --git a/test/nbrowser/testServer.ts b/test/nbrowser/testServer.ts index 54b91da1..8bcce502 100644 --- a/test/nbrowser/testServer.ts +++ b/test/nbrowser/testServer.ts @@ -18,6 +18,7 @@ import {makeGristConfig} from 'app/server/lib/sendAppPage'; import {exitPromise} from 'app/server/lib/serverUtils'; import {connectTestingHooks, TestingHooksClient} from 'app/server/lib/TestingHooks'; import {ChildProcess, execFileSync, spawn} from 'child_process'; +import EventEmitter from 'events'; import * as fse from 'fs-extra'; import {driver, IMochaServer, WebDriver} from 'mocha-webdriver'; import fetch from 'node-fetch'; @@ -27,7 +28,7 @@ import {removeConnection} from 'test/gen-server/seed'; import {HomeUtil} from 'test/nbrowser/homeUtil'; import {getDatabase} from 'test/testUtils'; -export class TestServerMerged implements IMochaServer { +export class TestServerMerged extends EventEmitter implements IMochaServer { public testDir: string; public testDocDir: string; public testingHooks: TestingHooksClient; @@ -42,10 +43,12 @@ export class TestServerMerged implements IMochaServer { private _exitPromise: Promise; private _starts: number = 0; private _dbManager?: HomeDBManager; - private _driver: WebDriver; + private _driver?: WebDriver; // The name is used to name the directory for server logs and data. - constructor(private _name: string) {} + constructor(private _name: string) { + super(); + } public async start() { await this.restart(true); @@ -66,9 +69,10 @@ export class TestServerMerged implements IMochaServer { if (process.env.TESTDIR) { this.testDir = process.env.TESTDIR; } else { - // Create a testDir of the form grist_test_{USER}_{SERVER_NAME}, removing any previous one. + const workerId = process.env.MOCHA_WORKER_ID || '0'; + // Create a testDir of the form grist_test_{USER}_{SERVER_NAME}_{WORKER_ID}, removing any previous one. const username = process.env.USER || "nobody"; - this.testDir = path.join(tmpdir(), `grist_test_${username}_${this._name}`); + this.testDir = path.join(tmpdir(), `grist_test_${username}_${this._name}_${workerId}`); await fse.remove(this.testDir); } } @@ -95,6 +99,9 @@ export class TestServerMerged implements IMochaServer { // logging. Server code uses a global logger, so it's hard to separate out (especially so if // we ever run different servers for different tests). const serverLog = process.env.VERBOSE ? 'inherit' : nodeLogFd; + const workerId = parseInt(process.env.MOCHA_WORKER_ID || '0', 10); + const corePort = String(8295 + workerId * 2); + const untrustedPort = String(8295 + workerId * 2 + 1); const env: Record = { TYPEORM_DATABASE: this._getDatabaseFile(), GRIST_DATA_DIR: this.testDocDir, @@ -107,23 +114,25 @@ export class TestServerMerged implements IMochaServer { GRIST_MAX_UPLOAD_ATTACHMENT_MB: '2', // The following line only matters for testing with non-localhost URLs, which some tests do. GRIST_SERVE_SAME_ORIGIN: 'true', - APP_UNTRUSTED_URL : "http://localhost:18096", // Run with HOME_PORT, STATIC_PORT, DOC_PORT, DOC_WORKER_COUNT in the environment to override. ...(useSinglePort ? { APP_HOME_URL: this.getHost(), GRIST_SINGLE_PORT: 'true', } : (isCore ? { - HOME_PORT: '8095', - STATIC_PORT: '8095', - DOC_PORT: '8095', + HOME_PORT: corePort, + STATIC_PORT: corePort, + DOC_PORT: corePort, DOC_WORKER_COUNT: '1', - PORT: '8095', + PORT: corePort, + APP_UNTRUSTED_URL: `http://localhost:${untrustedPort}`, + GRIST_SERVE_PLUGINS_PORT: untrustedPort, } : { HOME_PORT: '8095', STATIC_PORT: '8096', DOC_PORT: '8100', DOC_WORKER_COUNT: '5', PORT: '0', + APP_UNTRUSTED_URL : "http://localhost:18096", })), // This skips type-checking when running server, but reduces startup time a lot. TS_NODE_TRANSPILE_ONLY: 'true', @@ -158,6 +167,7 @@ export class TestServerMerged implements IMochaServer { // Prepare testingHooks for certain behind-the-scenes interactions with the server. this.testingHooks = await connectTestingHooks(testingSocket); + this.emit('start'); } public async stop() { @@ -168,6 +178,7 @@ export class TestServerMerged implements IMochaServer { this.testingHooks.close(); } await this._exitPromise; + this.emit('stop'); } /** @@ -261,7 +272,7 @@ export class TestServerMerged implements IMochaServer { } // substitute a custom driver - public setDriver(customDriver: WebDriver = driver) { + public setDriver(customDriver?: WebDriver) { this._driver = customDriver; } diff --git a/test/nbrowser/webdriverjq-nbrowser.js b/test/nbrowser/webdriverjq-nbrowser.js new file mode 100644 index 00000000..695f4daf --- /dev/null +++ b/test/nbrowser/webdriverjq-nbrowser.js @@ -0,0 +1,554 @@ +/** + * This is webdriverjq, modified to in fact no longer + * use jquery, but rather to work in conjunction with + * mocha-webdriver. Works in conjunction with + * gristUtils-nbrowser. + * + * Not everything webdriverjq could do is supported, + * just enough to make porting tests easier. The + * promise manager mechanism selenium used to have + * that old tests depends upon is gone (and good + * riddance). + * https://github.com/SeleniumHQ/selenium/blob/trunk/javascript/node/selenium-webdriver/CHANGES.md#api-changes-2 + * + * Changes are minimized here, no modernization unless + * strictly needed, so diff with webdriverjq.js is + * easier to read. + */ + +var _ = require('underscore'); +var util = require('util'); + +import { driver, error, stackWrapFunc, + WebElement, WebElementPromise } from 'mocha-webdriver'; + +export const driverCompanion = { + $: null, +}; + +export function webdriverjqWrapper(driver) { + + /** + * Returns a new WebdriverJQ instance. + */ + function $(selector) { + return new WebdriverJQ($, selector); + } + + $.driver = driver; + + $.getPage = function(url) { + return $.driver.get(url); + }; + + return $; +} + +/** + * The object obtained by $(...) calls. It supports nearly all the JQuery and WebElement methods, + * and allows chaining whenever it makes sense. + * @param {JQ} $: The $ object used to create this instance. + * @param {String|WebElement} selector: A string selector, or a WebElement (or promise for one). + */ +function WebdriverJQ($, selector) { + this.$ = $; + this.driver = $.driver; + this.selector = selector; + this._selectorDesc = selector; + this._callChain = []; +} + +/** + * $(...).method(...) invokes the corresponding method on the client side. Methods may be chained. + * In reality, method calls aren't performed until the first non-chainable call. For example, + * $(".foo").parent().toggleClass("active").text() is translated to a single call. + * + * Note that a few methods are overridden later: click() and submit() are performed on the server + * as WebDriver actions instead of on the client. + * + * The methods listed below are all the methods from http://api.jquery.com/. + */ +var JQueryMethodNames = [ + "add", "addBack", "addClass", "after", "ajaxComplete", "ajaxError", "ajaxSend", "ajaxStart", + "ajaxStop", "ajaxSuccess", "andSelf", "animate", "append", "appendTo", "attr", "before", "bind", + "blur", "change", "children", "clearQueue", "click", "clone", "closest", "contents", + "contextmenu", "css", "data", "dblclick", "delay", "delegate", "dequeue", "detach", "die", + "each", "empty", "end", "eq", "error", "fadeIn", "fadeOut", "fadeTo", "fadeToggle", "filter", + "find", "finish", "first", "focus", "focusin", "focusout", "get", "has", "hasClass", "height", + "hide", "hover", "html", "index", "innerHeight", "innerWidth", "insertAfter", "insertBefore", + "is", "keydown", "keypress", "keyup", "last", "live", "load", "load", "map", "mousedown", + "mouseenter", "mouseleave", "mousemove", "mouseout", "mouseover", "mouseup", "next", "nextAll", + "nextUntil", "not", "off", "offset", "offsetParent", "on", "one", "outerHeight", "outerWidth", + "parent", "parents", "parentsUntil", "position", "prepend", "prependTo", "prev", "prevAll", + "prevUntil", "promise", "prop", "pushStack", "queue", "ready", "remove", "removeAttr", + "removeClass", "removeData", "removeProp", "replaceAll", "replaceWith", "resize", "scroll", + "scrollLeft", "scrollTop", "select", "serialize", "serializeArray", "show", "siblings", + "slice", "slideDown", "slideToggle", "slideUp", "stop", "submit", "text", "toArray", "toggle", + "toggle", "toggleClass", "trigger", "triggerHandler", "unbind", "undelegate", "unload", + "unwrap", "val", "width", "wrap", "wrapAll", "wrapInner", + + // Extra methods (defined below, injected into client, available to these tests only). + "trimmedText", "classList", "subset", "filterExactText", "filterText", "getAttribute", + + "findOldTimey", // nbrowser: added +]; + +// Maps method name to an array of argument types. If called with matching types, this method does +// NOT return another JQuery object (i.e. it's not a chainable call). +var nonChainableMethods = { + "attr": ["string"], + "css": ["string"], + "data": ["string"], + "get": null, + "hasClass": ["string"], + "height": [], + "html": [], + "index": null, + "innerHeight": [], + "innerWidth": [], + "is": null, + "offset": [], + "outerHeight": [], + "outerWidth": [], + "position": [], + "prop": ["string"], + "scrollLeft": [], + "scrollTop": [], + "serialize": null, + "serializeArray": null, + "text": [], + "toArray": null, + "val": [], + "width": [], + + "trimmedText": null, + "classList": null, + "getAttribute": null, +}; + +function isNonChainable(methodName, args) { + var argTypes = nonChainableMethods[methodName]; + return argTypes === null || + _.isEqual(argTypes, args.map(function(a) { return typeof a; })); +} + +JQueryMethodNames.forEach(function(methodName) { + WebdriverJQ.prototype[methodName] = function() { + var args = Array.prototype.slice.call(arguments, 0); + var methodCallInfo = [methodName].concat(args); + var newObj = this.clone(); + newObj._callChain.push(methodCallInfo); + if (isNonChainable(methodName, args)) { + return newObj.resolve(); + } else { + return newObj; + } + }; +}); + +// .length property is a special case. +Object.defineProperty(WebdriverJQ.prototype, 'length', { + configurable: false, + enumerable: false, + get: function() { + var newObj = this.clone(); + newObj._callChain.push(['length']); + return newObj.resolve(); + } +}); + + +/** + * WebdriverJQ objects also support various WebDriver.WebElement methods, namely the ones + * below. These operate on the first of the selected elements. Those without a meaningful return + * value may be chained. E.g. $(".foo").click().val() will trigger a click on the selected + * element, then return its 'value' property. + */ +// This maps each supported method name to whether or not it should be chainable. +var WebElementMethodNames = { + "getId": false, + "getRawId": false, + "click": true, + "sendKeys": true, + "getTagName": false, + "getCssValue": false, + "getText": false, + "getSize": false, + "getLocation": false, + "isEnabled": false, + "isSelected": false, + "submit": true, + "clear": true, + "isDisplayed": false, + "isPresent": false, // nbrowser: added + "getOuterHtml": false, + "getInnerHtml": false, + "scrollIntoView": true, + "sendNewText": true, // Added below. +}; + +Object.keys(WebElementMethodNames).forEach(function(methodName) { + function runMethod(self, methodName, elem, ...argList) { + var result = elem[methodName].apply(elem, argList) + .then(function(value) { + // Chrome makes some values unprintable by including a bogus .toString property. + if (value && typeof value.toString !== 'function') { delete value.toString; } + return value; + }, function(err) { + throw err; + }); + return result; + } + const runThisMethod = stackWrapFunc(runMethod.bind(null, this, methodName)); + WebdriverJQ.prototype[methodName] = function() { + const elem = this.elem(); + const result = runThisMethod(elem, ...arguments); + if (WebElementMethodNames[methodName]) { + // If chainable, create a new WebdriverJQ object (that waits for the result). + return this._chain(() => elem, result); + } else { + // If not a chainable method, then we are done. + return result; + } + }; +}); + + +/** + * Helper for chaining. Creates a new WebdriverJQ instance from the current one for the given + * element, but which resolves only when the given promise resolves. + */ +WebdriverJQ.prototype._chain = function(elemFn, optPromise) { + const getElemAndUpdateDesc = () => { + const elem = elemFn(); + // Let the chained object start with the previous object's description, but once we have + // resolved the element, update it with the resolved element. + chainable._selectorDesc = this._selectorDesc + " [pending]"; + elem.then(function(resolvedElem) { + chainable._selectorDesc = resolvedElem.toString(); + }, function(err) {}); + return elem; + }; + var chainable = new WebdriverJQ(this.$, + optPromise ? optPromise.then(getElemAndUpdateDesc) : getElemAndUpdateDesc()); + + return chainable; +}; + + +/** + * Return a friendly string representation of this WebdriverJQ instance. + * E.g. $('.foo').next() will be represented as "$('.foo').next()". + */ +WebdriverJQ.prototype.toString = function() { + var sel = this._selectorDesc; + if (typeof sel === 'string') { + sel = "'" + sel.replace(/'/g, "\\'") + "'"; + } else { + sel = sel.toString(); + } + var desc = "$(" + sel + ")"; + desc += this._callChain.map(function(methodCallInfo) { + var method = methodCallInfo[0], args = util.inspect(methodCallInfo.slice(1)); + return "." + method + "(" + args.slice(1, args.length - 1).trim() + ")"; + }).join(""); + return desc; +}; + + +/** + * Returns a copy of the WebdriverJQ object. + */ +WebdriverJQ.prototype.clone = function() { + var newObj = new WebdriverJQ(this.$, this.selector); + newObj._selectorDesc = this._selectorDesc; + newObj._callChain = this._callChain.slice(0); + return newObj; +}; + + +/** + * Convert the matched elements to an array and apply all further chained calls to each elemet of + * the array separately, so that the result will be an array. + * + * E.g. $(".foo").array().text() will return an array of text for each of the elements matching + * ".foo", and $(".foo").array().height() will return an array of heights (unlike + * $(".foo").height() which would return the height of the first matching element). + */ +WebdriverJQ.prototype.array = function() { + this._callChain.push(["array"]); + return this; +}; + + +/** + * Make the call to the browser, returning a promise for the values (typically an array) returned + * by the browser. + */ +WebdriverJQ.prototype.resolve = function() { + var self = this; + if (isPromise(this.selector)) { + // Update our selector description once we know what it resolves to. + this.selector.then(function(resolvedSelector) { + self._selectorDesc = resolvedSelector.toString(); + }, function(err) {}); + + if (this._callChain.length === 0) { + // If the selector is a promise and there are no chained calls, there is no need to execute + // anything, we just need for the promise to resolve. + return this.selector.then(function(value) { + return Array.isArray(value) ? value : [value]; + }); + } + } + + return executeChain(this.selector, this._callChain); +}; + + +/** + * Make a call to the browser now, expecting a single element returned, and returning + * WebElementPromise for that element. If no elements match, the promise will be rejected. + */ +WebdriverJQ.prototype.elem = function() { + const doElem = stackWrapFunc(() => { + var self = this; + // TODO: we could limit results to a single value for efficiency. + var result = this.resolve().then(function(elems) { + if (!elems[0]) { throw new Error(self + " matched no element"); } + return elems[0]; + }); + return result; + }); + return new WebElementPromise(driver, doElem()); +}; + + +/** + * Check if the element is considered stale by WebDriver. An element is considered stale once it + * is removed from the DOM, or a new page has loaded. + */ +WebdriverJQ.prototype.isStale = function() { + return this.getTagName().then(function() { return false; }, function(err) { + if (err instanceof error.StaleElementReferenceError) { + return true; + } + throw err; + }); +}; + +/** + * Helper that allows a WebdriverJQ to act as a promise, but really just forwarding calls to + * this.resolve().then(...) + */ +WebdriverJQ.prototype.then = function(success, failure) { + // In selenium-webdriver 2.46, it was important not to call this.resolve().then(...) directly, + // but to start a new promise chain, to avoid a deadlock in ControlFlow. In selenium-webdriver + // 2.48, starting a new promise chain is wrong since the webdriver doesn't wait for the new + // promise chain on errors, and fails without a chance to catch them. + return this.resolve().then(success, failure); +}; + +// webdriver.promise.Thenable.addImplementation(WebdriverJQ); + + +/** + * Wait for a condition, represented by func(elem) returning true. The function may use asserts to + * indicate that the condition isn't met yet. E.g. + * + * $(...).wait().click() // Wait for a matching element to be present, then click it. + * $(...).wait(assert.isPresent).click() // Equivalent to the previous line. + * $(...).wait(assert.isDisplayed) // Wait for the matching element to be displayed. + * $(...).wait(assert.isDisplayed, false) // Wait for the element to NOT be displayed. + * $(...).wait(assert.hasClass, 'foo') // Wait for the element to have class 'foo' + * $(...).wait(assert.hasClass, 'foo', false) // Wait for the element to NOT have class 'foo' + * + * @param [Number] optTimeoutMs: First numerical argument is interpreted as a timeout in seconds. + * Default is 10. You may pass in a longer timeout, but infinite wait isn't supported. + * @param [Function] func: Optional condition function called as func(wjq, args...), where `wjq` + * is a WebdriverJQ instance. If a string is given, then `wjq[func](args...)` is called + * instead. If omitted, wait until the selector matches at least one element. + * The function must not return undefined, but may throw chai.AssertionError. + * @param [Objects] args...: Optional additional arguments to pass to func. + * @returns WebdriverJQ, which may be chained further. + */ +WebdriverJQ.prototype.waitCore = function(chained, optTimeoutSec, func, ...extraArgs) { + var timeoutMs; + if (typeof optTimeoutSec === 'number') { + timeoutMs = optTimeoutSec * 1000; + if (arguments.length === 2) { func = null; } + } else { + timeoutMs = 10000; + extraArgs.unshift(func); + func = (arguments.length === 1) ? null : optTimeoutSec; + } + if (_.isUndefined(func)) { + var failed = Promise.reject( + new Error("WebdriverJQ: wait called with undefined condition")); + return this._chain(() => failed); + } + func = func || "isPresent"; + + var self = this; + async function conditionFunc() { + const result = await (typeof func === 'string' ? + self[func].apply(self, extraArgs) : + func.apply(null, [self].concat(extraArgs))); + return result === undefined ? true : result; + } + var waitPromise = waitImpl(timeoutMs, conditionFunc); + return chained ? this._chain(() => this.resolve(), waitPromise) : + waitPromise; +}; + +WebdriverJQ.prototype.wait = function(...args) { + return this.waitCore(true, ...args); +} + +WebdriverJQ.prototype.waitDrop = function(...args) { + return this.waitCore(false, ...args); +} + + +/** + * Send keyboard keys to the element as if typed by the user. This allows the use of arrays as + * arguments to scope modifier keys. The method allows chaining. + */ +WebdriverJQ.prototype.sendKeys = function(varKeys) { + var keys = processKeys(Array.prototype.slice.call(arguments, 0)); + var elem = this.elem(); + return this._chain(() => elem, elem.sendKeys.apply(elem, keys)); +}; + +/** + * Replaces the value in a text by sending the keys to select all text, type + * the given string, and hit Enter. + */ +WebdriverJQ.prototype.sendNewText = function(string) { + return this.sendKeys(this.$.SELECT_ALL || driverCompanion.$.SELECT_ALL, + string, + this.$.ENTER || driverCompanion.$.ENTER); +}; + +/** + * Transform the given array of keys to prepare for sending to the browser: + * (1) If an argument is an array, its elements are grouped using webdriver.Key.chord() + * (in other words, modifier keys present in the array are released after the array). + * (2) Transforms certain keys to work around a selenium bug where they don't get processed + * properly. + */ +function processKeys(keyArray) { + return keyArray.map(function(arg) { + if (Array.isArray(arg)) { + arg = driver.Key.chord.apply(driver.Key, arg); + } + return arg; + }); +} + +//---------------------------------------------------------------------- +// Enhancements to webdriver's own objects. +//---------------------------------------------------------------------- +WebElement.prototype.toString = function() { + if (this._description) { + return "<" + this._description + ">"; + } else { + return ""; + } +}; + +//---------------------------------------------------------------------- +// "Client"-side code. +//---------------------------------------------------------------------- + +// Run a command chain. Basically just find the initial element, and +// apply methods to it. We try to match quirks of old system, which +// are a bit hard to explain, and can't easily be matched exactly - +// but well enough to cover a lot of test code it seems (and the +// rest can just be rewritten). +async function executeChain(selector, callChain) { + const cc = callChain.map(c => c[0]); + let result = selector; + if (typeof selector === 'string') { + result = await findOldTimey(driver, selector, + cc.includes('array') || + cc.includes('length') || + cc.includes('toArray') || + cc.includes('eq') || + cc.includes('last')); + } + result = await applyCallChain(callChain, result); + if (result instanceof WebElement) { + result = [result]; + } + return result; +} + +async function applyCallChain(callChain, value) { + value = await value; + for (var i = 0; i < callChain.length; i++) { + var method = callChain[i][0], args = callChain[i].slice(1).map(translateTestId); + if (method === "toArray") { + return Promise.all(value); + } else if (method === "array") { + return Promise.all(value.map(applyCallChain.bind(null, callChain.slice(i + 1)))); + } else if (method === "last") { + return applyCallChain(callChain.slice(i + 1), value[value.length - 1]); + } else if (method === "eq") { + const idx = args[0]; + return applyCallChain(callChain.slice(i + 1), value[idx]); + } else if (method === "length") { + return value.length; + } else { + if (!value[method] && value[0]?.[method]) { + value = value[0]; + } + value = await value[method].apply(value, args); + } + } + return value; +} + +function translateTestId(selector) { + return (typeof selector === 'string' ? + selector.replace(/\$(\w+)/, '[data-test-id="$1"]', 'g') : + selector); +} + +export function findOldTimey(obj, key, multiple, multipleOptions) { + key = translateTestId(key); + const contains = key.split(':contains'); + if (contains.length === 2) { + const content = contains[1].replace(/["'()]/g, ''); + return obj.findContent(contains[0], content); + } + if (multiple) { + return obj.findAll(key, multipleOptions); + } + return obj.find(key); +} + +// Similar to gu.waitToPass (copied to simplify import structure). +export async function waitImpl(timeoutMs, conditionFunc) { + try { + await driver.wait(async () => { + try { + return await conditionFunc(); + } catch (e) { + return false; + } + }, timeoutMs); + } catch (e) { + await conditionFunc(); + } +} + +function isPromise(obj) { + if (typeof obj !== 'object') { + return false; + } + if (typeof obj['then'] !== 'function') { + return false; + } + return true; +} diff --git a/test/nbrowser/webdriverjq.ntest.js b/test/nbrowser/webdriverjq.ntest.js new file mode 100644 index 00000000..977afd71 --- /dev/null +++ b/test/nbrowser/webdriverjq.ntest.js @@ -0,0 +1,215 @@ +import { assert, driver } from 'mocha-webdriver'; +import { $, gu, server, test } from 'test/nbrowser/gristUtil-nbrowser'; + +/** + * Not much of the fancy list support of webdriverjq has been supported. + * Luckily not many of the tests needed it, and the parts that did have + * been rewritten. So most of this test is turned off, and is kept just + * for reference purposes. + */ + +describe("webdriverjq.ntest", function() { + test.setupTestSuite(this); + + before(async function() { + await gu.supportOldTimeyTestCode(); + await driver.get(server.getHost() + '/v/gtag/testWebdriverJQuery.html'); + }); + + it("should support basic jquery syntax", async function() { + // toString should work properly. + assert.equal("" + $("input[type='button']"), "$('input[type=\\'button\\']')"); + + assert.equal(await $(".foo").trimmedText(), "Hello world"); + assert.equal((await $(".bar").array()).length, 2); + assert.lengthOf(await $(".bar").toArray(), 2); + assert(await $(".foo").hasClass("bar")); + assert.equal(await $(".foo.bar .baz").parent().getAttribute('className'), "foo bar"); + // Can't quite match old property-over-list behavior. + // assert.equal(await $(".foo.bar").find(".baz").parent().prop('className'), "foo bar"); + // Parent behavior is not the same as it was. + // assert.equal(await $(".baz").parent().length, 2); + assert.equal(await $(".baz input").val(), "Go"); + // There are two bazs, in new style need to specify which. + assert.equal(await $(".baz").eq(1).find('input[type="button"]').val(), "Go"); + await $("input[type='button']").click(); + assert.equal(await $(".baz input").val(), "Goo"); + await $(".baz input").val("Go").resolve(); // Revert the value to avoid affecting other test cases. + // toggleClass not supported anymore. + // assert.notInclude(await $(".foo").toggleClass("bar").classList(), "bar"); + // assert.include(await $(".foo").toggleClass("bar").classList(), "bar"); + }); + + it("should support .array()", async function() { + assert.deepEqual(await $(".bar").getAttribute('className'), 'foo bar'); + assert.deepEqual(await $(".bar").array().getAttribute('className'), ['foo bar', 'bar']); + assert.deepEqual(await $(".bar").array().trimmedText(), ["Hello world", "Good bye"]); + assert.deepEqual(await $(".bar").eq(0).trimmedText(), "Hello world"); + assert.deepEqual(await $(".bar").eq(1).trimmedText(), "Good bye"); + }); + + it("should support WebElement methods and chaining", async function() { + assert.equal(await $(".baz").getText(), "Hello world"); + assert.equal(await $(".baz").getAttribute("class"), "baz"); + await $(".baz").click(); + + // Cannot chain with clicks anymore + // assert.equal($(".baz").click().getText(), "Hello world"); + // assert.equal($(".baz").click().trimmedText(), "Hello world"); + // assert.equal($(".baz").click().parent().prop("className"), "foo bar"); + // assert.equal($(".baz").click().parent().isDisplayed(), true); + + // Errors are different. + // assert.equal(await $(".nonexistent1").text(), ""); + await assert.isRejected($(".nonexistent2").getText(), /no such element/); + await assert.isRejected($(".nonexistent3").click(), /no such element/); + await assert.isRejected($(".nonexistent4").isDisplayed(), /no such element/); + await assert.isRejected($(".nonexistent5").click().parent().isDisplayed(), /no such element/); + + await assert.isRejected($(".foo").click().find(".bar").elem(), /no such element/); + + // cannot chain click anymore + // assert($(".foo").click().find(".baz").elem()); + // assert.lengthOf($(".foo").click().find(".baz"), 1); + // assert.lengthOf($(".foo").click().find(".bar"), 0); + assert.lengthOf(await $(".bar").array(), 2); + await $(".bar").array().resolve().then(function(elems) { assert.lengthOf(elems, 2); }); + }); + + function expectFailure(promise, regexp) { + throw new Error('not ported'); + /* + var stack = stacktrace.captureStackTrace("", expectFailure); + return stacktrace.resolveWithStack(stack, promise.then(function(value) { + throw new Error("Expected failure but got " + value); + }, function(err) { + assert.match(err.message, regexp); + // Also make sure that our filename is present in the stack trace. + assert.match(err.stack, /webdriverjq.test.js:\d+/); + })); + */ + } + + // Custom asserts work, but error messages are different and not + // very interesting to maintain. + it.skip("should work with our custom asserts", async function() { + await assert.hasClass($(".foo"), "bar"); + await expectFailure(assert.hasClass($(".foo"), "bar", false), /hasClass/); + + await assert.hasClass($(".foo"), "xbar", false); + await expectFailure(assert.hasClass($(".foo"), "xbar"), /hasClass/); + + assert.isEnabled($("#btn")); + expectFailure(assert.isEnabled($("#btn"), false), /isEnabled/); + + assert.isEnabled($("#btn").prop("disabled", true), false); + expectFailure(assert.isEnabled($("#btn"), true), /isEnabled/); + + assert.isEnabled($("#btn").prop("disabled", false), true); + + assert.isPresent($("#btn")); + expectFailure(assert.isPresent($("#btnx")), /isPresent/); + + assert.isPresent($("#btnx"), false); + expectFailure(assert.isPresent($("#btn"), false), /isPresent/); + + assert.isDisplayed($("#btn")); + expectFailure(assert.isDisplayed($("#btn"), false), /isDisplayed/); + + assert.isDisplayed($(".baz").css('display', 'none').find("#btn"), false); + expectFailure(assert.isDisplayed($("#btn")), /isDisplayed/); + expectFailure(assert.ok($("#btn").click()), /not interactable/); + + assert.isDisplayed($(".baz").css('display', '').find("#btn"), true); + expectFailure(assert.isDisplayed($("#btn"), false), /isDisplayed/); + }); + + it.skip("should report good errors", async function() { + await $(".baz").css('display', 'none').resolve(); + expectFailure(assert.ok($("#btn").click()), /not interactable/); + await $(".baz").css('display', '').resolve(); + assert.ok($("#btn").click()); + assert.equal($("#btn").val(), "Goo"); + await $("#btn").val("Go").resolve(); // Revert the value to avoid affecting other test cases. + + expectFailure($(".nonexistent1").click(), /nonexistent1.* matched no element/); + expectFailure(assert.ok($(".nonexistent2").click()), /matched no element/); + expectFailure($(".nonexistent3").getText(), /matched no element/); + expectFailure(assert.ok($(".nonexistent5").elem()), /matched no element/); + }); + + // addClass not supported anymore. + it.skip("should wait for various conditions", async function() { + assert.equal(await $(".foo").wait().trimmedText(), "Hello world"); + + // Test waits for functions of an existing element. + await driver.executeScript(function() { + setTimeout(function() { $(".foo .baz").addClass("later1"); }, 300); + setTimeout(function() { $(".foo .baz").addClass("later2"); }, 700); + }); + assert.deepEqual(await $(".foo .baz").classList(), ["baz"]); + await assert.hasClass($(".foo .baz"), "later2", false); + assert.deepEqual(await $(".foo .baz").wait(assert.hasClass, "later1").classList(), + ["baz", "later1"]); + assert.deepEqual(await $(".foo .baz").wait("hasClass", "later2").classList(), + ["baz", "later1", "later2"]); + assert.throws($(".foo .baz").wait(0.05, "hasClass", "never").classList(), + /Wait timed out/); + + // Test waits for the presence of an element. + $.driver.executeScript(async function() { + await $(".foo .baz").removeClass("later1 later2"); + setTimeout(function() { $(".foo .baz").addClass("later1"); }, 200); + setTimeout(function() { $(".foo .baz").addClass("later2"); }, 500); + setTimeout(function() { $(".foo .baz").removeClass("later1 later2"); }, 1000); + }); + assert.lengthOf($(".later1, .later2"), 0); + assert.throws($(".later1").wait(0.05, "isPresent").classList(), /Wait timed out/); + assert.deepEqual($(".later1").wait().classList(), ["baz", "later1"]); + assert.deepEqual($(".later2").wait(1, assert.isPresent).classList(), + ["baz", "later1", "later2"]); + + // The element is already present, so this should be true. + assert.equal($(".later1").wait(0.01, assert.isPresent, true).isPresent(), true); + // The following is equivalent to WebDrivers's until.stalenessOf. + assert.equal($(".later1").wait(1, assert.isPresent, false).isPresent(), false); + + // Absent argument, or null, are OK, and mean "isPresent", but 'undefined' as an argument is a + // liability, since it would be silent and wrong on misspellings. So we catch it. + assert.equal($(".foo").wait(null).isPresent(), true); + assert.throws($(".foo").wait(assert.misspelled).isPresent(), + /called with undefined condition/); + + // We should be able to chain beyond .wait() with actions and more. + $.driver.executeScript(function() { + setTimeout(function() { $("#btn").addClass("later1"); }, 200); + setTimeout(function() { $("#btn").removeClass("later1"); }, 800); + }); + await $("#btn.later1").wait().click(); + assert.equal($("#btn").val(), "Goo"); + await $("#btn").wait(assert.hasClass, "later1", false).click(); + assert.equal($("#btn").val(), "Gooo"); + await $("#btn").val("Go").resolve(); // Revert the value to avoid affecting other test cases. + }); + + // behavior around lists changed. + it.skip("should support complicated promises", async function() { + var elemA = $(".foo .baz").resolve().then(function(elem) { + return $(elem).parent(); + }).then(function(elem) { + assert.deepEqual($(elem).classList(), ["foo", "bar"]); + return elem; + }); + + assert.deepEqual($(elemA).classList(), ["foo", "bar"]); + assert.isDisplayed($(elemA)); + assert.isDisplayed($(elemA).find(".baz")); + assert.throws($(elemA).find(".nonexistent").click(), / { - if (noexit) { - console.log("report-why-tests-hang silenced with --no-exit flag"); - } else { - // If still hanging after 5s after tests finish, say something. Unref() ensures that THIS - // timeout doesn't itself keep node from exiting. - setTimeout(report, 5000).unref(); +if (process.env.MOCHA_WORKER_ID === undefined) { + exports.mochaHooks = { + afterAll(done) { + if (noexit) { + console.log("report-why-tests-hang silenced with --no-exit flag"); + } else { + // If still hanging after 5s after tests finish, say something. Unref() ensures that THIS + // timeout doesn't itself keep node from exiting. + setTimeout(report, 5000).unref(); + } + done(); + } } -}); +} diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 2c56b065..c28fc080 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -3022,10 +3022,9 @@ function testDocApi() { }); after(async function () { - if (!process.env.TEST_REDIS_URL) { - this.skip(); + if (process.env.TEST_REDIS_URL) { + await redisClient.quitAsync(); } - await redisClient.quitAsync(); }); }); @@ -3260,10 +3259,9 @@ function testDocApi() { }); after(async function () { - if (!process.env.TEST_REDIS_URL) { - this.skip(); + if (process.env.TEST_REDIS_URL) { + await redisMonitor.quitAsync(); } - await redisMonitor.quitAsync(); }); it("delivers expected payloads from combinations of changes, with retrying and batching", async function () { diff --git a/test/setupPaths.js b/test/setupPaths.js index f579a663..e1e8ba21 100644 --- a/test/setupPaths.js +++ b/test/setupPaths.js @@ -1,7 +1,17 @@ // enhance require() to support project paths and typescript. const path = require('path'); const appModulePath = require('app-module-path'); -const root = path.dirname(__dirname); -appModulePath.addPath(path.join(root, "_build")); -appModulePath.addPath(path.join(root, "_build/core")); -appModulePath.addPath(path.join(root, "_build/ext")); +// Root path can be complicated, pwd is more reliable for tests. +const root = process.cwd(); +const nodePath = (process.env.NODE_PATH || '').split(path.delimiter); +const paths = [path.join(root, "_build"), + path.join(root, "_build/core"), + path.join(root, "_build/ext"), + path.join(root, "_build/stubs")]; +for (const p of paths) { + appModulePath.addPath(p); +} +// add to path for any subprocesses also +process.env.NODE_PATH = [...nodePath, ...paths] + .filter(p => p !== '') + .join(path.delimiter); diff --git a/test/split-tests.js b/test/split-tests.js index 718875b6..144aa263 100644 --- a/test/split-tests.js +++ b/test/split-tests.js @@ -18,36 +18,38 @@ * parallel groups can be evened out as much as possible. */ -/* global before */ const fs = require('fs'); const { assert } = require('chai'); const testSuite = process.env.TEST_SUITE_FOR_TIMINGS || "unset_suite"; const timingsFile = process.env.TIMINGS_FILE || "test/timings-all.txt"; -before(function() { - const testSplits = process.env.TEST_SPLITS; - if (!testSplits) { - return; - } - const match = testSplits.match(/^(\d+)-of-(\d+)$/); - if (!match) { - assert.fail(`Invalid test split spec '${testSplits}': use format 'N-of-M'`); - } +exports.mochaHooks = { + beforeAll(done) { + const testSplits = process.env.TEST_SPLITS; + if (!testSplits) { + return done(); + } + const match = testSplits.match(/^(\d+)-of-(\d+)$/); + if (!match) { + assert.fail(`Invalid test split spec '${testSplits}': use format 'N-of-M'`); + } - const group = Number(match[1]); - const groupCount = Number(match[2]); - if (!(group >= 1 && group <= groupCount)) { - assert.fail(`Invalid test split spec '${testSplits}': index must be in range 1..{groupCount}`); - } + const group = Number(match[1]); + const groupCount = Number(match[2]); + if (!(group >= 1 && group <= groupCount)) { + assert.fail(`Invalid test split spec '${testSplits}': index must be in range 1..{groupCount}`); + } - const testParent = this.test.parent; - const timings = getTimings(); - const groups = groupSuites(testParent.suites, timings, groupCount); + const testParent = this.test.parent; + const timings = getTimings(); + const groups = groupSuites(testParent.suites, timings, groupCount); - testParent.suites = groups[group - 1]; // Convert to a 0-based index. - console.log(`Split tests groups; will run group ${group} of ${groupCount}`); -}); + testParent.suites = groups[group - 1]; // Convert to a 0-based index. + console.log(`Split tests groups; will run group ${group} of ${groupCount}`); + done(); + } +}; /** * Read timings from timingsFile into a Map mapping file-suite-title to duration. diff --git a/test/utils.js b/test/utils.js index 1489fcbc..643d6bea 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,4 +1,4 @@ -/* global location, describe, it, afterEach, after */ +/* global location */ var _ = require('underscore'); var Chance = require('chance'); diff --git a/test/xunit-file.js b/test/xunit-file.js index 0e18ba81..a6c8e3ed 100644 --- a/test/xunit-file.js +++ b/test/xunit-file.js @@ -69,7 +69,7 @@ class XUnitFile extends reporters.Base { runner.on('suite end', (suite) => { // Every time a (top-level) suite ends, add a line to the timings file. - if (suite.titlePath().length == 1) { + if (suite.titlePath?.()?.length == 1) { const duration = Date.now() - startedSuites.get(suite); appendLine(timingsFd, `${testSuite} ${suite.fullTitle()} ${duration}`); startedSuites.delete(suite); diff --git a/yarn.lock b/yarn.lock index 1ab91736..23b597a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -505,6 +505,11 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== +"@sindresorhus/is@^4.0.0": + version "4.6.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" + integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== + "@sinonjs/commons@^1", "@sinonjs/commons@^1.2.0", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz" @@ -551,6 +556,13 @@ dependencies: defer-to-connect "^1.0.1" +"@szmarczak/http-timer@^4.0.5": + version "4.0.6" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807" + integrity sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w== + dependencies: + defer-to-connect "^2.0.0" + "@tokenizer/token@^0.3.0": version "0.3.0" resolved "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz" @@ -592,6 +604,16 @@ "@types/connect" "*" "@types/node" "*" +"@types/cacheable-request@^6.0.1": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz#a430b3260466ca7b5ca5bfd735693b36e7a9d183" + integrity sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw== + dependencies: + "@types/http-cache-semantics" "*" + "@types/keyv" "^3.1.4" + "@types/node" "*" + "@types/responselike" "^1.0.0" + "@types/chai-as-promised@7.1.0": version "7.1.0" resolved "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.0.tgz" @@ -706,6 +728,11 @@ dependencies: "@types/node" "*" +"@types/http-cache-semantics@*": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812" + integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ== + "@types/http-proxy@1.17.9": version "1.17.9" resolved "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz" @@ -765,6 +792,13 @@ dependencies: "@types/node" "*" +"@types/keyv@^3.1.4": + version "3.1.4" + resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6" + integrity sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg== + dependencies: + "@types/node" "*" + "@types/lodash@4.14.117": version "4.14.117" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.117.tgz" @@ -876,6 +910,13 @@ "@types/bluebird" "*" "@types/events" "*" +"@types/responselike@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" + integrity sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA== + dependencies: + "@types/node" "*" + "@types/saml2-js@2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@types/saml2-js/-/saml2-js-2.0.1.tgz" @@ -1323,10 +1364,10 @@ acorn@^8.4.1, acorn@^8.5.0: resolved "https://registry.npmjs.org/acorn/-/acorn-8.7.1.tgz" integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== -adm-zip@0.5.3: - version "0.5.3" - resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.3.tgz" - integrity sha512-zsoTXEwRNCxBzRHLENFLuecCcwzzXiEhWo1r3GP68iwi8Q/hW2RrqgeY1nfJ/AhNQNWnZq/4v0TbfMsUkI+TYw== +adm-zip@0.5.9: + version "0.5.9" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.9.tgz#b33691028333821c0cf95c31374c5462f2905a83" + integrity sha512-s+3fXLkeeLjZ2kLjCBwQufpI5fuN+kIGBxu6530nVQZGVol0d7Y/M88/xw9HGGUcJjKf8LutN3VPRUBq6N7Ajg== agent-base@6, agent-base@^6.0.2: version "6.0.2" @@ -1374,15 +1415,10 @@ ansi-align@^3.0.0: dependencies: string-width "^3.0.0" -ansi-colors@3.2.3: - version "3.2.3" - resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz" - integrity sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw== - -ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz" - integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== ansi-regex@^4.1.0: version "4.1.0" @@ -1399,7 +1435,7 @@ ansi-regex@^5.0.1: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-styles@^3.2.0, ansi-styles@^3.2.1: +ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz" integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== @@ -1439,6 +1475,14 @@ anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + app-module-path@2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/app-module-path/-/app-module-path-2.2.0.tgz" @@ -1820,6 +1864,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + brace@0.11.1: version "0.11.1" resolved "https://registry.npmjs.org/brace/-/brace-0.11.1.tgz" @@ -2094,6 +2145,11 @@ cacache@^15.2.0: tar "^6.0.2" unique-filename "^1.1.1" +cacheable-lookup@^5.0.3: + version "5.0.4" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" + integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== + cacheable-request@^6.0.0: version "6.1.0" resolved "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz" @@ -2107,6 +2163,19 @@ cacheable-request@^6.0.0: normalize-url "^4.1.0" responselike "^1.0.2" +cacheable-request@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.4.tgz#7a33ebf08613178b403635be7b899d3e69bbe817" + integrity sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^4.0.0" + lowercase-keys "^2.0.0" + normalize-url "^6.0.1" + responselike "^2.0.0" + cached-path-relative@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.1.0.tgz" @@ -2130,6 +2199,11 @@ camelcase@^5.0.0, camelcase@^5.3.1: resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + caniuse-lite@^1.0.30001358: version "1.0.30001359" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001359.tgz" @@ -2140,11 +2214,6 @@ caniuse-lite@^1.0.30001400: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz#987437b266260b640a23cd18fbddb509d7f69f3e" integrity sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg== -capture-stack-trace@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz" - integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw== - caseless@~0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz" @@ -2203,7 +2272,7 @@ chainsaw@~0.1.0: dependencies: traverse ">=0.3.0 <0.4" -chalk@^2.0.0, chalk@^2.4.2: +chalk@^2.0.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -2246,20 +2315,20 @@ check-error@^1.0.2: resolved "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz" integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= -chokidar@3.3.0: - version "3.3.0" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.3.0.tgz" - integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A== +chokidar@3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== dependencies: - anymatch "~3.1.1" + anymatch "~3.1.2" braces "~3.0.2" - glob-parent "~5.1.0" + glob-parent "~5.1.2" is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" - readdirp "~3.2.0" + readdirp "~3.6.0" optionalDependencies: - fsevents "~2.1.1" + fsevents "~2.3.2" chokidar@^3.2.2: version "3.4.0" @@ -2334,15 +2403,6 @@ cli-highlight@^2.1.11: parse5-htmlparser2-tree-adapter "^6.0.0" yargs "^16.0.0" -cliui@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz" - integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== - dependencies: - string-width "^3.1.0" - strip-ansi "^5.2.0" - wrap-ansi "^5.1.0" - cliui@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz" @@ -2471,11 +2531,6 @@ combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@2.15.1: - version "2.15.1" - resolved "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz" - integrity sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag== - commander@9.3.0: version "9.3.0" resolved "https://registry.npmjs.org/commander/-/commander-9.3.0.tgz" @@ -2647,13 +2702,6 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" -create-error-class@^3.0.1: - version "3.0.2" - resolved "https://registry.npmjs.org/create-error-class/-/create-error-class-3.0.2.tgz" - integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y= - dependencies: - capture-stack-trace "^1.0.0" - create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz" @@ -2800,20 +2848,6 @@ debug@2.6.9, debug@^2.2.0, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" - -debug@3.2.6, debug@^3.2.6: - version "3.2.6" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - debug@4, debug@4.3.1: version "4.3.1" resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz" @@ -2821,18 +2855,30 @@ debug@4, debug@4.3.1: dependencies: ms "2.1.2" -debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: +debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== dependencies: ms "2.1.2" +debug@^3.2.6: + version "3.2.6" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + decimal.js@^10.2.1: version "10.4.0" resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.0.tgz" @@ -2850,6 +2896,13 @@ decompress-response@^3.3.0: dependencies: mimic-response "^1.0.0" +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + deep-eql@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz" @@ -2882,12 +2935,10 @@ defer-to-connect@^1.0.1: resolved "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz" integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== -define-properties@^1.1.2, define-properties@^1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz" - integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== - dependencies: - object-keys "^1.0.12" +defer-to-connect@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== define-properties@^1.1.4: version "1.1.4" @@ -2990,7 +3041,12 @@ diff-match-patch@1.0.5: resolved "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz" integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== -diff@3.5.0, diff@^3.5.0: +diff@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + +diff@^3.5.0: version "3.5.0" resolved "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz" integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== @@ -3057,7 +3113,7 @@ double-ended-queue@2.1.0-0, double-ended-queue@^2.1.0-0: resolved "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz" integrity sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw= -duplexer2@^0.1.2, duplexer2@^0.1.4, duplexer2@~0.1.0, duplexer2@~0.1.2, duplexer2@~0.1.4: +duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2, duplexer2@~0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz" integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= @@ -3209,49 +3265,11 @@ err-code@^2.0.2: resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== -error-ex@^1.2.0: - version "1.3.2" - resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -es-abstract@^1.18.0-next.2: - version "1.18.0" - resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz" - integrity sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw== - dependencies: - call-bind "^1.0.2" - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - get-intrinsic "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.2" - is-callable "^1.2.3" - is-negative-zero "^2.0.1" - is-regex "^1.1.2" - is-string "^1.0.5" - object-inspect "^1.9.0" - object-keys "^1.1.1" - object.assign "^4.1.2" - string.prototype.trimend "^1.0.4" - string.prototype.trimstart "^1.0.4" - unbox-primitive "^1.0.0" - es-module-lexer@^0.9.0: version "0.9.3" resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz" integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== -es-to-primitive@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz" - integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== - dependencies: - is-callable "^1.1.4" - is-date-object "^1.0.1" - is-symbol "^1.0.2" - es6-error@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz" @@ -3410,16 +3428,16 @@ escape-html@~1.0.3: resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= -escape-string-regexp@1.0.5, escape-string-regexp@^1.0.5: +escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - escodegen@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz" @@ -3794,12 +3812,13 @@ finalhandler@1.1.1: statuses "~1.4.0" unpipe "~1.0.0" -find-up@3.0.0, find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== +find-up@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== dependencies: - locate-path "^3.0.0" + locate-path "^6.0.0" + path-exists "^4.0.0" find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" @@ -3817,12 +3836,10 @@ flat-cache@^3.0.4: flatted "^3.1.0" rimraf "^3.0.2" -flat@^4.1.0: - version "4.1.1" - resolved "https://registry.npmjs.org/flat/-/flat-4.1.1.tgz" - integrity sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA== - dependencies: - is-buffer "~2.0.3" +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== flatted@^3.1.0: version "3.2.7" @@ -3939,11 +3956,16 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -fsevents@~2.1.1, fsevents@~2.1.2: +fsevents@~2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fstream@^1.0.12: version "1.0.12" resolved "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz" @@ -4012,16 +4034,16 @@ gcp-metadata@^4.2.0: gaxios "^4.0.0" json-bigint "^1.0.0" -geckodriver@^1.19.1: - version "1.22.2" - resolved "https://registry.npmjs.org/geckodriver/-/geckodriver-1.22.2.tgz" - integrity sha512-xcf1OLfHqNX4+wQhj4weu2gtiwtPnV8yEEKvLkC8GuFtUc5WjOGodV/2pHiYJjCSJRQfsmIgY5Xs1zaJf/OGFA== +geckodriver@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/geckodriver/-/geckodriver-3.2.0.tgz#6b0a85e2aafbce209bca30e2d53af857707b1034" + integrity sha512-p+qR2RKlI/TQoCEYrSuTaYCLqsJNni96WmEukTyXmOmLn+3FLdgPAEwMZ0sG2Cwi9hozUzGAWyT6zLuhF6cpiQ== dependencies: - adm-zip "0.5.3" + adm-zip "0.5.9" bluebird "3.7.2" - got "5.6.0" - https-proxy-agent "5.0.0" - tar "6.0.2" + got "11.8.5" + https-proxy-agent "5.0.1" + tar "6.1.11" gensync@^1.0.0-beta.2: version "1.0.0-beta.2" @@ -4090,7 +4112,7 @@ glob-parent@^3.1.0: is-glob "^3.1.0" path-dirname "^1.0.0" -glob-parent@^5.1.2, glob-parent@~5.1.0: +glob-parent@^5.1.2, glob-parent@~5.1.0, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -4137,22 +4159,10 @@ glob@*, glob@^7.0.3, glob@^7.1.0, glob@^7.1.3, glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.1.2: - version "7.1.2" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz" - integrity sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@7.1.3: - version "7.1.3" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz" - integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== +glob@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" @@ -4274,27 +4284,22 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -got@5.6.0: - version "5.6.0" - resolved "https://registry.npmjs.org/got/-/got-5.6.0.tgz" - integrity sha1-ux1+4WO3gIK7yOuDbz85UATqb78= - dependencies: - create-error-class "^3.0.1" - duplexer2 "^0.1.4" - is-plain-obj "^1.0.0" - is-redirect "^1.0.0" - is-retry-allowed "^1.0.0" - is-stream "^1.0.0" - lowercase-keys "^1.0.0" - node-status-codes "^1.0.0" - object-assign "^4.0.1" - parse-json "^2.1.0" - pinkie-promise "^2.0.0" - read-all-stream "^3.0.0" - readable-stream "^2.0.5" - timed-out "^2.0.0" - unzip-response "^1.0.0" - url-parse-lax "^1.0.0" +got@11.8.5: + version "11.8.5" + resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046" + integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ== + dependencies: + "@sindresorhus/is" "^4.0.0" + "@szmarczak/http-timer" "^4.0.5" + "@types/cacheable-request" "^6.0.1" + "@types/responselike" "^1.0.0" + cacheable-lookup "^5.0.3" + cacheable-request "^7.0.2" + decompress-response "^6.0.0" + http2-wrapper "^1.0.0-beta.5.2" + lowercase-keys "^2.0.0" + p-cancelable "^2.0.0" + responselike "^2.0.0" got@^9.6.0: version "9.6.0" @@ -4341,11 +4346,6 @@ grainjs@1.0.2, grainjs@^1.0.1: resolved "https://registry.npmjs.org/grainjs/-/grainjs-1.0.2.tgz" integrity sha512-wrj8TqpgxTGOKHpTlMBxMeX2uS3lTvXj4ROLKC+EZNM7J6RHQLGjMzMqWtiryBnMhGIBlbCicMNFppCrK1zv9w== -growl@1.10.5: - version "1.10.5" - resolved "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz" - integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== - gtoken@^5.0.4: version "5.3.0" resolved "https://registry.npmjs.org/gtoken/-/gtoken-5.3.0.tgz" @@ -4387,11 +4387,6 @@ har-validator@~5.1.3: ajv "^6.12.3" har-schema "^2.0.0" -has-bigints@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz" - integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== - has-flag@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz" @@ -4409,7 +4404,7 @@ has-property-descriptors@^1.0.0: dependencies: get-intrinsic "^1.1.1" -has-symbols@^1.0.0, has-symbols@^1.0.1, has-symbols@^1.0.2: +has-symbols@^1.0.1, has-symbols@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz" integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== @@ -4474,11 +4469,6 @@ hdr-histogram-percentiles-obj@^3.0.0: resolved "https://registry.yarnpkg.com/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz#9409f4de0c2dda78e61de2d9d78b1e9f3cba283c" integrity sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw== -he@1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/he/-/he-1.1.1.tgz" - integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0= - he@1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz" @@ -4577,19 +4567,19 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" +http2-wrapper@^1.0.0-beta.5.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" + integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg== + dependencies: + quick-lru "^5.1.1" + resolve-alpn "^1.0.0" + https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -https-proxy-agent@5.0.0, https-proxy-agent@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz" - integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== - dependencies: - agent-base "6" - debug "4" - https-proxy-agent@5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz" @@ -4598,6 +4588,14 @@ https-proxy-agent@5.0.1: agent-base "6" debug "4" +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + dependencies: + agent-base "6" + debug "4" + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" @@ -4836,16 +4834,6 @@ is-arguments@^1.0.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" - integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= - -is-bigint@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.1.tgz" - integrity sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg== - is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz" @@ -4853,33 +4841,16 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" -is-boolean-object@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.0.tgz" - integrity sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA== - dependencies: - call-bind "^1.0.0" - is-buffer@^1.1.0, is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-buffer@~2.0.3: - version "2.0.5" - resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz" - integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== - is-callable@^1.1.3: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-callable@^1.1.4, is-callable@^1.2.3: - version "1.2.3" - resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz" - integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== - is-ci@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz" @@ -4901,11 +4872,6 @@ is-core-module@^2.9.0: dependencies: has "^1.0.3" -is-date-object@^1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz" - integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== - is-extglob@^2.1.0, is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" @@ -4967,21 +4933,11 @@ is-negated-glob@^1.0.0: resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2" integrity sha512-czXVVn/QEmgvej1f50BZ648vUI+em0xqMq2Sn+QncCLN4zj1UAxlT+kw/6ggQTOaZPd1HqKQGEqbpQVtJucWug== -is-negative-zero@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz" - integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== - is-npm@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz" integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig== -is-number-object@^1.0.4: - version "1.0.4" - resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz" - integrity sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw== - is-number@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" @@ -5016,10 +4972,10 @@ is-path-inside@^3.0.1: resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz" integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg== -is-plain-obj@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz" - integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== is-plain-object@^2.0.4: version "2.0.4" @@ -5033,19 +4989,6 @@ is-potential-custom-element-name@^1.0.0: resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== -is-redirect@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz" - integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ= - -is-regex@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz" - integrity sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg== - dependencies: - call-bind "^1.0.2" - has-symbols "^1.0.1" - is-relative@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" @@ -5053,33 +4996,11 @@ is-relative@^1.0.0: dependencies: is-unc-path "^1.0.0" -is-retry-allowed@^1.0.0: - version "1.2.0" - resolved "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz" - integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg== - -is-stream@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz" - integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= - is-stream@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz" integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== -is-string@^1.0.5: - version "1.0.5" - resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz" - integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== - -is-symbol@^1.0.2, is-symbol@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz" - integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== - dependencies: - has-symbols "^1.0.1" - is-typed-array@^1.1.10, is-typed-array@^1.1.3: version "1.1.10" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.10.tgz#36a5b5cb4189b575d1a3e4b08536bfb485801e3f" @@ -5103,6 +5024,11 @@ is-unc-path@^1.0.0: dependencies: unc-path-regex "^0.1.2" +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + is-url@^1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz" @@ -5186,14 +5112,6 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@3.13.1: - version "3.13.1" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz" - integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - js-yaml@3.14.1: version "3.14.1" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz" @@ -5202,9 +5120,9 @@ js-yaml@3.14.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: +js-yaml@4.1.0, js-yaml@^4.1.0: version "4.1.0" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== dependencies: argparse "^2.0.1" @@ -5268,6 +5186,11 @@ json-buffer@3.0.0: resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz" integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + json-parse-even-better-errors@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" @@ -5420,6 +5343,13 @@ keyv@^3.0.0: dependencies: json-buffer "3.0.0" +keyv@^4.0.0: + version "4.5.2" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.2.tgz#0e310ce73bf7851ec702f2eaf46ec4e3805cce56" + integrity sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g== + dependencies: + json-buffer "3.0.1" + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz" @@ -5515,14 +5445,6 @@ locale-currency@0.0.2: resolved "https://registry.npmjs.org/locale-currency/-/locale-currency-0.0.2.tgz" integrity sha1-4skGB1Y85HpZ+VWeRacOJOTbS20= -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - locate-path@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz" @@ -5530,6 +5452,13 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + lodash-node@~2.4.1: version "2.4.1" resolved "https://registry.npmjs.org/lodash-node/-/lodash-node-2.4.1.tgz" @@ -5645,12 +5574,13 @@ lodash@4.17.21, lodash@^4.0.0, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19 resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -log-symbols@3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz" - integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== +log-symbols@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== dependencies: - chalk "^2.4.2" + chalk "^4.1.0" + is-unicode-supported "^0.1.0" lolex@^3.0.0: version "3.1.0" @@ -5823,6 +5753,11 @@ mimic-response@^1.0.0, mimic-response@^1.0.1: resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz" @@ -5833,7 +5768,7 @@ minimalistic-crypto-utils@^1.0.1: resolved "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= -minimatch@*, minimatch@3.0.4, minimatch@^3.0.4: +minimatch@*, minimatch@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== @@ -5848,6 +5783,13 @@ minimatch@0.3: lru-cache "2" sigmund "~1.0.0" +minimatch@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" + integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== + dependencies: + brace-expansion "^2.0.1" + minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" @@ -5855,11 +5797,6 @@ minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" - integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= - minimist@^1.1.0, minimist@^1.2.0, minimist@^1.2.5: version "1.2.5" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz" @@ -5951,7 +5888,7 @@ minipass@^4.0.0: resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.0.1.tgz#2b9408c6e81bb8b338d600fb3685e375a370a057" integrity sha512-V9esFpNbK0arbN3fm2sxDKqMYgIp7XtVdE4Esj+PE4Qaaxdg1wIw48ITQIOn1sc8xXSmUviVL3cyjMqPlrVkiA== -minizlib@^2.0.0, minizlib@^2.1.0, minizlib@^2.1.1: +minizlib@^2.0.0, minizlib@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== @@ -5959,14 +5896,7 @@ minizlib@^2.0.0, minizlib@^2.1.0, minizlib@^2.1.1: minipass "^3.0.0" yallist "^4.0.0" -mkdirp@0.5.1: - version "0.5.1" - resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" - integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= - dependencies: - minimist "0.0.8" - -mkdirp@0.5.5, "mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@^0.5.4: +"mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@^0.5.4: version "0.5.5" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -5978,66 +5908,46 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mocha-webdriver@0.2.9: - version "0.2.9" - resolved "https://registry.npmjs.org/mocha-webdriver/-/mocha-webdriver-0.2.9.tgz" - integrity sha512-WNr7Yq1uhcvLYyjDChKvW9y2hD8DnzJ0Av/LheZtiS0/6MQtPvxx1XO7OtdnxiS6my9gKg4kRe0944dggLGEFg== +mocha-webdriver@0.2.13: + version "0.2.13" + resolved "https://registry.yarnpkg.com/mocha-webdriver/-/mocha-webdriver-0.2.13.tgz#6e39360e08dc32e2de7375c6eaee83bd6aa98cc6" + integrity sha512-xSQ9fTViIucIZs+loRzKhOkUtztMheHYRg2+CrgqBPg9/MYjOWbcUk+uye5r117BN/r57KRuZxvsZRH0Jm0gCg== dependencies: chai "^4.1.2" chai-as-promised "^7.1.1" chromedriver "^74.0.0" fs-extra "^8.0.1" - geckodriver "^1.19.1" - mocha "^7.1.2" + geckodriver "^3.2.0" + mocha "^10.1.0" npm-run-path "^3.1.0" selenium-webdriver "^4.0.0-alpha.1" -mocha@5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz" - integrity sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ== - dependencies: - browser-stdout "1.3.1" - commander "2.15.1" - debug "3.1.0" - diff "3.5.0" - escape-string-regexp "1.0.5" - glob "7.1.2" - growl "1.10.5" - he "1.1.1" - minimatch "3.0.4" - mkdirp "0.5.1" - supports-color "5.4.0" - -mocha@^7.1.2: - version "7.2.0" - resolved "https://registry.npmjs.org/mocha/-/mocha-7.2.0.tgz" - integrity sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ== +mocha@10.2.0, mocha@^10.1.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8" + integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg== dependencies: - ansi-colors "3.2.3" + ansi-colors "4.1.1" browser-stdout "1.3.1" - chokidar "3.3.0" - debug "3.2.6" - diff "3.5.0" - escape-string-regexp "1.0.5" - find-up "3.0.0" - glob "7.1.3" - growl "1.10.5" + chokidar "3.5.3" + debug "4.3.4" + diff "5.0.0" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.2.0" he "1.2.0" - js-yaml "3.13.1" - log-symbols "3.0.0" - minimatch "3.0.4" - mkdirp "0.5.5" - ms "2.1.1" - node-environment-flags "1.0.6" - object.assign "4.1.0" - strip-json-comments "2.0.1" - supports-color "6.0.0" - which "1.3.1" - wide-align "1.1.3" - yargs "13.3.2" - yargs-parser "13.1.2" - yargs-unparser "1.6.0" + js-yaml "4.1.0" + log-symbols "4.1.0" + minimatch "5.0.1" + ms "2.1.3" + nanoid "3.3.3" + serialize-javascript "6.0.0" + strip-json-comments "3.1.1" + supports-color "8.1.1" + workerpool "6.2.1" + yargs "16.2.0" + yargs-parser "20.2.4" + yargs-unparser "2.0.0" module-deps@^4.0.8: version "4.1.1" @@ -6105,17 +6015,12 @@ ms@2.0.0: resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= -ms@2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz" - integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== - ms@2.1.2, ms@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.0.0: +ms@2.1.3, ms@^2.0.0: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -6138,6 +6043,11 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nanoid@3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" + integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -6192,14 +6102,6 @@ node-addon-api@^4.2.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== -node-environment-flags@1.0.6: - version "1.0.6" - resolved "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz" - integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw== - dependencies: - object.getownpropertydescriptors "^2.0.3" - semver "^5.7.0" - node-fetch@2.6.7: version "2.6.7" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz" @@ -6260,11 +6162,6 @@ node-releases@^2.0.6: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.8.tgz#0f349cdc8fcfa39a92ac0be9bc48b7706292b9ae" integrity sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A== -node-status-codes@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/node-status-codes/-/node-status-codes-1.0.0.tgz" - integrity sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8= - nodemon@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.4.tgz" @@ -6312,6 +6209,11 @@ normalize-url@^4.1.0: resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz" integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + now-and-later@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/now-and-later/-/now-and-later-2.0.1.tgz#8e579c8685764a7cc02cb680380e94f43ccb1f7c" @@ -6366,21 +6268,11 @@ object-inspect@^1.9.0: resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz" integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== -object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: +object-keys@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz" - integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== - dependencies: - define-properties "^1.1.2" - function-bind "^1.1.1" - has-symbols "^1.0.0" - object-keys "^1.0.11" - object.assign@^4.0.4: version "4.1.4" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" @@ -6391,25 +6283,6 @@ object.assign@^4.0.4: has-symbols "^1.0.3" object-keys "^1.1.1" -object.assign@^4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz" - integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== - dependencies: - call-bind "^1.0.0" - define-properties "^1.1.3" - has-symbols "^1.0.1" - object-keys "^1.1.1" - -object.getownpropertydescriptors@^2.0.3: - version "2.1.2" - resolved "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz" - integrity sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.18.0-next.2" - on-finished@~2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" @@ -6484,19 +6357,24 @@ p-cancelable@^1.0.0: resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz" integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== -p-limit@^2.0.0, p-limit@^2.2.0: +p-cancelable@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" + integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== + +p-limit@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== dependencies: p-try "^2.0.0" -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== dependencies: - p-limit "^2.0.0" + yocto-queue "^0.1.0" p-locate@^4.1.0: version "4.1.0" @@ -6505,6 +6383,13 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + p-map@^1.1.1: version "1.2.0" resolved "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz" @@ -6568,13 +6453,6 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.5: pbkdf2 "^3.0.3" safe-buffer "^5.1.1" -parse-json@^2.1.0: - version "2.2.0" - resolved "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz" - integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= - dependencies: - error-ex "^1.2.0" - parse5-htmlparser2-tree-adapter@^6.0.0: version "6.0.1" resolved "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz" @@ -6614,11 +6492,6 @@ path-dirname@^1.0.0: resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" integrity sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q== -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz" - integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" @@ -6864,11 +6737,6 @@ prelude-ls@~1.1.2: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz" integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w== -prepend-http@^1.0.1: - version "1.0.4" - resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz" - integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= - prepend-http@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz" @@ -7046,6 +6914,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz" @@ -7096,14 +6969,6 @@ rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -read-all-stream@^3.0.0: - version "3.1.0" - resolved "https://registry.npmjs.org/read-all-stream/-/read-all-stream-3.1.0.tgz" - integrity sha1-NcPhd/IHjveJ7kv6+kNzB06u9Po= - dependencies: - pinkie-promise "^2.0.0" - readable-stream "^2.0.0" - read-only-stream@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz" @@ -7159,13 +7024,6 @@ readdir-glob@^1.0.0: dependencies: minimatch "^3.0.4" -readdirp@~3.2.0: - version "3.2.0" - resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz" - integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ== - dependencies: - picomatch "^2.0.4" - readdirp@~3.4.0: version "3.4.0" resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz" @@ -7173,6 +7031,13 @@ readdirp@~3.4.0: dependencies: picomatch "^2.2.1" +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + rechoir@^0.7.0: version "0.7.1" resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz" @@ -7346,6 +7211,11 @@ requires-port@^1.0.0: resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== +resolve-alpn@^1.0.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" + integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz" @@ -7404,6 +7274,13 @@ responselike@^1.0.2: dependencies: lowercase-keys "^1.0.0" +responselike@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.1.tgz#9a0bc8fdc252f3fb1cca68b016591059ba1422bc" + integrity sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw== + dependencies: + lowercase-keys "^2.0.0" + retry@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" @@ -7515,7 +7392,7 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -semver@^5.7.0, semver@^5.7.1: +semver@^5.7.1: version "5.7.1" resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -7551,9 +7428,9 @@ send@0.16.2: range-parser "~1.2.0" statuses "~1.4.0" -serialize-javascript@^6.0.0: +serialize-javascript@6.0.0, serialize-javascript@^6.0.0: version "6.0.0" - resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== dependencies: randombytes "^2.1.0" @@ -7884,14 +7761,6 @@ strict-uri-encode@^2.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== -"string-width@^1.0.2 || 2": - version "2.1.1" - resolved "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz" - integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" - "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" @@ -7901,7 +7770,7 @@ strict-uri-encode@^2.0.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@^3.0.0, string-width@^3.1.0: +string-width@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz" integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== @@ -7928,22 +7797,6 @@ string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" -string.prototype.trimend@^1.0.4: - version "1.0.4" - resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz" - integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - -string.prototype.trimstart@^1.0.4: - version "1.0.4" - resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz" - integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" @@ -7970,14 +7823,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -strip-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz" - integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= - dependencies: - ansi-regex "^3.0.0" - -strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: +strip-ansi@^5.1.0: version "5.2.0" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz" integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== @@ -7998,16 +7844,16 @@ strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" -strip-json-comments@2.0.1, strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - -strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: +strip-json-comments@3.1.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + strnum@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" @@ -8028,19 +7874,12 @@ subarg@^1.0.0: dependencies: minimist "^1.1.0" -supports-color@5.4.0: - version "5.4.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz" - integrity sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w== - dependencies: - has-flag "^3.0.0" - -supports-color@6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz" - integrity sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg== +supports-color@8.1.1, supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== dependencies: - has-flag "^3.0.0" + has-flag "^4.0.0" supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" @@ -8056,13 +7895,6 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" @@ -8096,15 +7928,15 @@ tar-stream@^2.2.0: inherits "^2.0.3" readable-stream "^3.1.1" -tar@6.0.2: - version "6.0.2" - resolved "https://registry.npmjs.org/tar/-/tar-6.0.2.tgz" - integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg== +tar@6.1.11: + version "6.1.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" + integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA== dependencies: chownr "^2.0.0" fs-minipass "^2.0.0" minipass "^3.0.0" - minizlib "^2.1.0" + minizlib "^2.1.1" mkdirp "^1.0.3" yallist "^4.0.0" @@ -8214,11 +8046,6 @@ through2@^4.0.0: resolved "https://registry.npmjs.org/through/-/through-2.3.8.tgz" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= -timed-out@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/timed-out/-/timed-out-2.0.0.tgz" - integrity sha1-84sK6B03R9YoAB9B2vxlKs5nHAo= - timers-browserify@^1.0.1: version "1.4.2" resolved "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz" @@ -8498,16 +8325,6 @@ umd@^3.0.0: resolved "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz" integrity sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow== -unbox-primitive@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz" - integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw== - dependencies: - function-bind "^1.1.1" - has-bigints "^1.0.1" - has-symbols "^1.0.2" - which-boxed-primitive "^1.0.2" - unc-path-regex@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" @@ -8580,11 +8397,6 @@ unpipe@1.0.0, unpipe@~1.0.0: resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= -unzip-response@^1.0.0: - version "1.0.2" - resolved "https://registry.npmjs.org/unzip-response/-/unzip-response-1.0.2.tgz" - integrity sha1-uYTwh3/AqJwsdzzB73tbIytbBv4= - unzipper@^0.10.11: version "0.10.11" resolved "https://registry.npmjs.org/unzipper/-/unzipper-0.10.11.tgz" @@ -8643,13 +8455,6 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -url-parse-lax@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz" - integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM= - dependencies: - prepend-http "^1.0.1" - url-parse-lax@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz" @@ -8951,17 +8756,6 @@ whatwg-url@^8.0.0: tr46 "^2.1.0" webidl-conversions "^6.1.0" -which-boxed-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz" - integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== - dependencies: - is-bigint "^1.0.1" - is-boolean-object "^1.1.0" - is-number-object "^1.0.4" - is-string "^1.0.5" - is-symbol "^1.0.3" - which-module@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz" @@ -8979,13 +8773,6 @@ which-typed-array@^1.1.2: has-tostringtag "^1.0.0" is-typed-array "^1.1.10" -which@1.3.1: - version "1.3.1" - resolved "https://registry.npmjs.org/which/-/which-1.3.1.tgz" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - which@^2.0.1, which@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -9000,13 +8787,6 @@ why-is-node-running@2.0.3: dependencies: stackback "0.0.2" -wide-align@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - wide-align@^1.1.2, wide-align@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" @@ -9048,14 +8828,10 @@ wordwrap@^1.0.0: resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -wrap-ansi@^5.1.0: - version "5.1.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz" - integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== - dependencies: - ansi-styles "^3.2.0" - string-width "^3.0.0" - strip-ansi "^5.0.0" +workerpool@6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" + integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== wrap-ansi@^6.2.0: version "6.2.0" @@ -9211,13 +8987,10 @@ yallist@^4.0.0: resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@13.1.2, yargs-parser@^13.1.2: - version "13.1.2" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz" - integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" +yargs-parser@20.2.4: + version "20.2.4" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== yargs-parser@^18.1.2: version "18.1.3" @@ -9237,30 +9010,28 @@ yargs-parser@^21.0.0: resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs-unparser@1.6.0: - version "1.6.0" - resolved "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.0.tgz" - integrity sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw== +yargs-unparser@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== dependencies: - flat "^4.1.0" - lodash "^4.17.15" - yargs "^13.3.0" + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" -yargs@13.3.2, yargs@^13.3.0: - version "13.3.2" - resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz" - integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== +yargs@16.2.0, yargs@^16.0.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== dependencies: - cliui "^5.0.0" - find-up "^3.0.0" - get-caller-file "^2.0.1" + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" require-directory "^2.1.1" - require-main-filename "^2.0.0" - set-blocking "^2.0.0" - string-width "^3.0.0" - which-module "^2.0.0" - y18n "^4.0.0" - yargs-parser "^13.1.2" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" yargs@^15.3.1: version "15.4.1" @@ -9279,19 +9050,6 @@ yargs@^15.3.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^16.0.0: - version "16.2.0" - resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - yargs@^17.3.1: version "17.5.1" resolved "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz" @@ -9313,6 +9071,11 @@ yauzl@^2.10.0: buffer-crc32 "~0.2.3" fd-slicer "~1.1.0" +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + zip-stream@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz"