From 2438a632559dc7a6d8fe2c86a0a88655b560ff18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Mon, 5 Sep 2022 10:24:34 +0200 Subject: [PATCH] (core) Moving widget tests to core Summary: - Custom widget tests are now in grist-core - Adding buildtools for grist-plugin-api.js Test Plan: Existing tests Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3617 --- .gitignore | 1 + buildtools/build.sh | 2 + buildtools/update_type_info.sh | 27 + buildtools/webpack.api.config.js | 44 ++ package.json | 1 + test/fixtures/docs/CustomWidget.grist | Bin 0 -> 188416 bytes test/fixtures/docs/TypeEncoding.grist | Bin 0 -> 573440 bytes test/fixtures/sites/config/index.html | 43 ++ test/fixtures/sites/config/page.js | 72 ++ test/fixtures/sites/embed/embed.html | 16 + test/fixtures/sites/filter/index.html | 12 + test/fixtures/sites/filter/page.js | 13 + test/fixtures/sites/hello/index.html | 5 + test/fixtures/sites/paste/paste.html | 27 + test/fixtures/sites/probe/index.html | 11 + test/fixtures/sites/probe/page.js | 20 + test/fixtures/sites/readout/index.html | 21 + test/fixtures/sites/readout/page.js | 37 + test/fixtures/sites/types/index.html | 16 + test/fixtures/sites/types/page.js | 42 ++ test/fixtures/sites/zap/index.html | 11 + test/fixtures/sites/zap/page.js | 56 ++ test/nbrowser/CustomView.ts | 478 +++++++++++++ test/nbrowser/CustomWidgets.ts | 583 +++++++++++++++ test/nbrowser/CustomWidgetsConfig.ts | 952 +++++++++++++++++++++++++ test/nbrowser/customUtil.ts | 12 + yarn.lock | 19 +- 27 files changed, 2519 insertions(+), 2 deletions(-) create mode 100755 buildtools/update_type_info.sh create mode 100644 buildtools/webpack.api.config.js create mode 100644 test/fixtures/docs/CustomWidget.grist create mode 100644 test/fixtures/docs/TypeEncoding.grist create mode 100644 test/fixtures/sites/config/index.html create mode 100644 test/fixtures/sites/config/page.js create mode 100644 test/fixtures/sites/embed/embed.html create mode 100644 test/fixtures/sites/filter/index.html create mode 100644 test/fixtures/sites/filter/page.js create mode 100644 test/fixtures/sites/hello/index.html create mode 100644 test/fixtures/sites/paste/paste.html create mode 100644 test/fixtures/sites/probe/index.html create mode 100644 test/fixtures/sites/probe/page.js create mode 100644 test/fixtures/sites/readout/index.html create mode 100644 test/fixtures/sites/readout/page.js create mode 100644 test/fixtures/sites/types/index.html create mode 100644 test/fixtures/sites/types/page.js create mode 100644 test/fixtures/sites/zap/index.html create mode 100644 test/fixtures/sites/zap/page.js create mode 100644 test/nbrowser/CustomView.ts create mode 100644 test/nbrowser/CustomWidgets.ts create mode 100644 test/nbrowser/CustomWidgetsConfig.ts create mode 100644 test/nbrowser/customUtil.ts diff --git a/.gitignore b/.gitignore index 8b622f6a..af1f4bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /_build/ /static/*.bundle.js /static/*.bundle.js.map +/static/grist-plugin-api* /static/bundle.css /static/browser-check.js /static/*.bundle.js.*.txt diff --git a/buildtools/build.sh b/buildtools/build.sh index b3bb832a..38c1b69d 100755 --- a/buildtools/build.sh +++ b/buildtools/build.sh @@ -10,6 +10,8 @@ fi set -x tsc --build $PROJECT +buildtools/update_type_info.sh app webpack --config buildtools/webpack.config.js --mode production webpack --config buildtools/webpack.check.js --mode production +webpack --config buildtools/webpack.api.config.js --mode production cat app/client/*.css app/client/*/*.css > static/bundle.css diff --git a/buildtools/update_type_info.sh b/buildtools/update_type_info.sh new file mode 100755 index 00000000..2e90b1dc --- /dev/null +++ b/buildtools/update_type_info.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +# updates any Foo*-ti.ts files $root that are older than Foo.ts + +root=$1 +if [[ -z "$root" ]]; then + echo "Usage: $0 app" + exit 1 +fi + +for root in "$@"; do + for ti in $(find $root/ -iname "*-ti.ts"); do + root=$(basename $ti -ti.ts) + dir=$(dirname $ti) + src="$dir/$root.ts" + if [ ! -e $src ]; then + echo "Cannot find src $src for $ti, aborting" + exit 1 + fi + if [ $src -nt $ti ]; then + echo "Updating $ti from $src" + node_modules/.bin/ts-interface-builder $src + fi + done +done diff --git a/buildtools/webpack.api.config.js b/buildtools/webpack.api.config.js new file mode 100644 index 00000000..e14f973c --- /dev/null +++ b/buildtools/webpack.api.config.js @@ -0,0 +1,44 @@ +const path = require('path'); + +module.exports = { + target: 'web', + entry: { + "grist-plugin-api": "app/plugin/grist-plugin-api", + }, + output: { + sourceMapFilename: "[file].map", + path: path.resolve("./static"), + library: "grist" + }, + devtool: "source-map", + node: false, + resolve: { + extensions: ['.ts', '.js'], + modules: [ + path.resolve('.'), + path.resolve('./ext'), + path.resolve('./stubs'), + path.resolve('./node_modules') + ], + fallback: { + 'path': require.resolve("path-browserify"), + }, + }, + optimization: { + minimize: false, // keep class names in code + }, + module: { + rules: [ + { + test: /\.(js|ts)?$/, + loader: 'esbuild-loader', + options: { + loader: 'ts', + target: 'es2017', + sourcemap: true, + }, + exclude: /node_modules/ + }, + ] + } +}; diff --git a/package.json b/package.json index c0fd673d..2d7a7adf 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "sinon": "7.1.1", "source-map-loader": "^0.2.4", "tmp-promise": "1.0.5", + "ts-interface-builder": "0.3.2", "typescript": "4.7.4", "webpack": "5.73.0", "webpack-cli": "4.10.0", diff --git a/test/fixtures/docs/CustomWidget.grist b/test/fixtures/docs/CustomWidget.grist new file mode 100644 index 0000000000000000000000000000000000000000..ec7e322fcf0b78329f1cc673cf3dc0e489eee444 GIT binary patch literal 188416 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/docs/TypeEncoding.grist b/test/fixtures/docs/TypeEncoding.grist new file mode 100644 index 0000000000000000000000000000000000000000..0263caaf49dc42c65edc9870794def5d6430cd07 GIT binary patch literal 573440 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 b + + + + + + +
+
+
+ +
onOptions event data:
+

+
+    
onRecord event data:
+

+
+    
onRecord mapping data:
+

+
+    
onRecords event data:
+

+
+    
onRecord mappings data:
+

+    
+    
configure handler:
+

+
+    
Method input json:
+ +
Method output json:
+
+
Methods:
+ + + + + + + + + diff --git a/test/fixtures/sites/config/page.js b/test/fixtures/sites/config/page.js new file mode 100644 index 00000000..37bcc366 --- /dev/null +++ b/test/fixtures/sites/config/page.js @@ -0,0 +1,72 @@ +/* global document, grist, window */ + +// Ready message can be configured from url +const urlParams = new URLSearchParams(window.location.search); +const ready = urlParams.get('ready') ? JSON.parse(urlParams.get('ready')) : undefined; + +if (ready && ready.onEditOptions) { + ready.onEditOptions = () => { + document.getElementById('configure').innerHTML = 'called'; + }; +} + +grist.ready(ready); + +grist.onOptions(data => { + document.getElementById('onOptions').innerHTML = JSON.stringify(data); +}); + +grist.onRecord((data, mappings) => { + document.getElementById('onRecord').innerHTML = JSON.stringify(data); + document.getElementById('onRecordMappings').innerHTML = JSON.stringify(mappings); +}); + +grist.onRecords((data, mappings) => { + document.getElementById('onRecords').innerHTML = JSON.stringify(data); + document.getElementById('onRecordsMappings').innerHTML = JSON.stringify(mappings); +}); + +async function run(handler) { + try { + document.getElementById('output').innerText = 'waiting...'; + const result = await handler(JSON.parse(document.getElementById('input').value || '[]')); + document.getElementById('output').innerText = result === undefined ? 'undefined' : JSON.stringify(result); + } catch (err) { + document.getElementById('output').innerText = JSON.stringify({error: err.message || String(err)}); + } +} + +// eslint-disable-next-line no-unused-vars +async function getOptions() { + return run(() => grist.widgetApi.getOptions()); +} +// eslint-disable-next-line no-unused-vars +async function setOptions() { + return run(options => grist.widgetApi.setOptions(...options)); +} +// eslint-disable-next-line no-unused-vars +async function setOption() { + return run(options => grist.widgetApi.setOption(...options)); +} +// eslint-disable-next-line no-unused-vars +async function getOption() { + return run(options => grist.widgetApi.getOption(...options)); +} +// eslint-disable-next-line no-unused-vars +async function clearOptions() { + return run(() => grist.widgetApi.clearOptions()); +} +// eslint-disable-next-line no-unused-vars +async function mappings() { + return run(() => grist.sectionApi.mappings()); +} +// eslint-disable-next-line no-unused-vars +async function configure() { + return run((options) => grist.sectionApi.configure(...options)); +} + +window.onload = () => { + document.getElementById('ready').innerText = 'ready'; + document.getElementById('access').innerHTML = urlParams.get('access'); + document.getElementById('readonly').innerHTML = urlParams.get('readonly'); +}; diff --git a/test/fixtures/sites/embed/embed.html b/test/fixtures/sites/embed/embed.html new file mode 100644 index 00000000..6cc907ab --- /dev/null +++ b/test/fixtures/sites/embed/embed.html @@ -0,0 +1,16 @@ + + + + + + + +

Embed Grist

+ + + + diff --git a/test/fixtures/sites/filter/index.html b/test/fixtures/sites/filter/index.html new file mode 100644 index 00000000..b16996e0 --- /dev/null +++ b/test/fixtures/sites/filter/index.html @@ -0,0 +1,12 @@ + + + + + + + +

Filter

+

Enter row ids (ie: "1" or "1, 3, 4"):

+ + + diff --git a/test/fixtures/sites/filter/page.js b/test/fixtures/sites/filter/page.js new file mode 100644 index 00000000..b5178fb5 --- /dev/null +++ b/test/fixtures/sites/filter/page.js @@ -0,0 +1,13 @@ + +/* global document, grist, window */ + +function setup() { + grist.ready(); + grist.allowSelectBy(); + document.querySelector('#rowIds').addEventListener('change', (ev) => { + const rowIds = ev.target.value.split(',').map(Number); + grist.setSelectedRows(rowIds); + }); +} + +window.onload = setup; diff --git a/test/fixtures/sites/hello/index.html b/test/fixtures/sites/hello/index.html new file mode 100644 index 00000000..21435435 --- /dev/null +++ b/test/fixtures/sites/hello/index.html @@ -0,0 +1,5 @@ + + +

Hello World

+ + diff --git a/test/fixtures/sites/paste/paste.html b/test/fixtures/sites/paste/paste.html new file mode 100644 index 00000000..a17e572e --- /dev/null +++ b/test/fixtures/sites/paste/paste.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
ab
c
de
f
+ + diff --git a/test/fixtures/sites/probe/index.html b/test/fixtures/sites/probe/index.html new file mode 100644 index 00000000..2d9165fd --- /dev/null +++ b/test/fixtures/sites/probe/index.html @@ -0,0 +1,11 @@ + + + + + + + +

Probe

+
+ + diff --git a/test/fixtures/sites/probe/page.js b/test/fixtures/sites/probe/page.js new file mode 100644 index 00000000..24e57135 --- /dev/null +++ b/test/fixtures/sites/probe/page.js @@ -0,0 +1,20 @@ + + +/* global document, grist, window */ + +grist.ready(); + +function readDoc() { + const api = grist.rpc.getStub("GristDocAPI@grist", grist.checkers.GristDocAPI); + const placeholder = document.getElementById('placeholder'); + const fallback = setTimeout(() => { + placeholder.innerHTML = '
no joy
'; + }, 1000); + api.listTables() + .then(tables => { + clearTimeout(fallback); + placeholder.innerHTML = `
${JSON.stringify(tables)}
`; + }); +} + +window.onload = readDoc; diff --git a/test/fixtures/sites/readout/index.html b/test/fixtures/sites/readout/index.html new file mode 100644 index 00000000..c30f639d --- /dev/null +++ b/test/fixtures/sites/readout/index.html @@ -0,0 +1,21 @@ + + + + + + + +

Readout

+

placeholder

+
+

rowId

+
+

tableId

+
+
+

record

+
+

records

+
+ + diff --git a/test/fixtures/sites/readout/page.js b/test/fixtures/sites/readout/page.js new file mode 100644 index 00000000..dd466612 --- /dev/null +++ b/test/fixtures/sites/readout/page.js @@ -0,0 +1,37 @@ + + +/* global document, grist, window */ + +function readDoc() { + const fetchTable = grist.docApi.fetchSelectedTable(); + const placeholder = document.getElementById('placeholder'); + const fallback = setTimeout(() => { + placeholder.innerHTML = '
no joy
'; + }, 1000); + fetchTable + .then(table => { + clearTimeout(fallback); + placeholder.innerHTML = `
${JSON.stringify(table)}
`; + }); +} + +function setup() { + grist.ready(); + grist.on('message', function(e) { + if ('options' in e) return; + document.getElementById('rowId').innerHTML = e.rowId || ''; + document.getElementById('tableId').innerHTML = e.tableId || ''; + readDoc(); + }); + grist.onRecord(function(rec) { + document.getElementById('record').innerHTML = JSON.stringify(rec); + }); + grist.onRecords(function(recs) { + document.getElementById('records').innerHTML = JSON.stringify(recs); + }); + grist.onNewRecord(function(rec) { + document.getElementById('record').innerHTML = 'new'; + }); +} + +window.onload = setup; diff --git a/test/fixtures/sites/types/index.html b/test/fixtures/sites/types/index.html new file mode 100644 index 00000000..fbc0e749 --- /dev/null +++ b/test/fixtures/sites/types/index.html @@ -0,0 +1,16 @@ + + + + + + + +

Types

+
+ onRecord() matches a record in table? +
+
+

record

+

+  
+
diff --git a/test/fixtures/sites/types/page.js b/test/fixtures/sites/types/page.js
new file mode 100644
index 00000000..dcc2f0cc
--- /dev/null
+++ b/test/fixtures/sites/types/page.js
@@ -0,0 +1,42 @@
+/* global document, grist, window */
+
+function formatValue(value, indent='') {
+  let basic = `${value} [typeof=${typeof value}]`;
+  if (value && typeof value === 'object') {
+    basic += ` [name=${value.constructor.name}]`;
+  }
+  if (value instanceof Date) {
+    // For moment, use moment(value) or moment(value).tz(value.timezone), it's just hard to
+    // include moment into this test fixture.
+    basic += ` [date=${value.toISOString()}]`;
+  }
+  if (value && typeof value === 'object' && value.constructor.name === 'Object') {
+    basic += "\n" + formatObject(value);
+  }
+  return basic;
+}
+
+function formatObject(obj) {
+  const keys = Object.keys(obj).sort();
+  const rows = keys.map(k => `${k}: ${formatValue(obj[k])}`.replace(/\n/g, '\n  '));
+  return rows.join("\n");
+}
+
+function setup() {
+  let lastRecords = [];
+  grist.ready();
+  grist.onRecords(function(records) { lastRecords = records; });
+  grist.onRecord(function(rec) {
+    const formatted = formatObject(rec);
+    document.getElementById('record').innerHTML = formatted;
+
+    // Check that there is an identical object in lastRecords, to ensure that onRecords() returns
+    // the same kind of representation.
+    const rowInRecords = lastRecords.find(r => (r.id === rec.id));
+    const match = (formatObject(rowInRecords) === formatted);
+    document.getElementById('match').textContent = String(match);
+
+  });
+}
+
+window.onload = setup;
diff --git a/test/fixtures/sites/zap/index.html b/test/fixtures/sites/zap/index.html
new file mode 100644
index 00000000..f8986e5a
--- /dev/null
+++ b/test/fixtures/sites/zap/index.html
@@ -0,0 +1,11 @@
+
+  
+    
+    
+    
+  
+  
+    

Zap

+
+ + diff --git a/test/fixtures/sites/zap/page.js b/test/fixtures/sites/zap/page.js new file mode 100644 index 00000000..91671667 --- /dev/null +++ b/test/fixtures/sites/zap/page.js @@ -0,0 +1,56 @@ +/* global document, grist, window */ + +/** + * This widget connects to the document, gets a list of all user tables in it, + * and then tries to replace all cells with the text 'zap'. It requires full + * access to do this. + */ + +let failures = 0; +function problem(err) { + // Trying to zap formula columns will fail, but that's ok. + if (String(err).includes("formula column")) { return; } + console.error(err); + document.getElementById('placeholder').innerHTML = 'zap failed'; + failures++; +} + +async function zap() { + grist.ready(); + try { + // If no access is granted, listTables will hang. Detect this condition with + // a timeout. + const timeout = setTimeout(() => problem(new Error('cannot connect')), 1000); + const tables = await grist.docApi.listTables(); + clearTimeout(timeout); + // Iterate through user tables. + for (const tableId of tables) { + // Read table content. + const data = await grist.docApi.fetchTable(tableId); + const ids = data.id; + // Prepare to zap all columns except id and manualSort. + delete data.id; + delete data.manualSort; + for (const key of Object.keys(data)) { + const column = data[key]; + for (let i = 0; i < ids.length; i++) { + column[i] = 'zap'; + } + // Zap columns one by one since if they are a formula column they will fail. + await grist.docApi.applyUserActions([[ + 'BulkUpdateRecord', + tableId, + ids, + {[key]: column}, + ]]).catch(problem); + } + } + } catch(err) { + problem(err); + } + if (failures === 0) { + document.getElementById('placeholder').innerHTML = 'zap succeeded'; + } +} + +window.onload = zap; diff --git a/test/nbrowser/CustomView.ts b/test/nbrowser/CustomView.ts new file mode 100644 index 00000000..0c8dfe9e --- /dev/null +++ b/test/nbrowser/CustomView.ts @@ -0,0 +1,478 @@ +import {safeJsonParse} from 'app/common/gutil'; +import {assert, driver, Key} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; + +import { serveCustomViews, Serving, setAccess } from 'test/nbrowser/customUtil'; + +import * as chai from 'chai'; +chai.config.truncateThreshold = 5000; + +async function setCustomWidget() { + // if there is a select widget option + if (await driver.find('.test-config-widget-select').isPresent()) { + const selected = await driver.find('.test-config-widget-select .test-select-open').getText(); + if (selected != "Custom URL") { + await driver.find('.test-config-widget-select .test-select-open').click(); + await driver.findContent('.test-select-menu li', "Custom URL").click(); + await gu.waitForServer(); + } + } +} + +describe('CustomView', function() { + this.timeout(20000); + const cleanup = setupTestSuite(); + + let serving: Serving; + + before(async function() { + if (server.isExternalServer()) { + this.skip(); + } + serving = await serveCustomViews(); + }); + + after(async function() { + if (serving) { + await serving.shutdown(); + } + }); + + for (const access of ['none', 'read table', 'full'] as const) { + + function withAccess(obj: any, fallback: any) { + return ((access !== 'none') && obj) || fallback; + } + + function readJson(txt: string) { + return safeJsonParse(txt, null); + } + + describe(`with access level ${access}`, function() { + + before(async function() { + if (server.isExternalServer()) { + this.skip(); + } + const mainSession = await gu.session().teamSite.login(); + await mainSession.tempDoc(cleanup, 'Favorite_Films.grist'); + if (!await gu.isSidePanelOpen('right')) { + await gu.toggleSidePanel('right'); + } + await driver.find('.test-config-data').click(); + }); + + it('gets appropriate notification of row set changes', async function() { + // Link a section on the "All" page of Favorite Films demo + await driver.findContent('.test-treeview-itemHeader', /All/).click(); + await gu.getSection('Friends record').click(); + await driver.find('.test-pwc-editDataSelection').click(); + await driver.find('.test-wselect-addBtn').click(); + await gu.waitForServer(); + await driver.find('.test-right-select-by').click(); + await driver.findContent('.test-select-menu li', /Performances record • Film/).click(); + await driver.find('.test-pwc-editDataSelection').click(); + await driver.findContent('.test-wselect-type', /Custom/).click(); + await driver.find('.test-wselect-addBtn').click(); + await gu.waitForServer(); + + // Replace the widget with a custom widget that just reads out the data + // as JSON. + await driver.find('.test-config-widget').click(); + await setCustomWidget(); + await driver.find('.test-config-widget-url').click(); + await driver.sendKeys(`${serving.url}/readout`, Key.ENTER); + await setAccess(access); + await gu.waitForServer(); + + // Check that the data looks right. + const iframe = gu.getSection('Friends record').find('iframe'); + await driver.switchTo().frame(iframe); + assert.deepEqual(readJson(await driver.find('#placeholder').getText()), + withAccess({ Name: ["Tom"], + Favorite_Film: ["Toy Story"], + Age: ["25"], + id: [2] }, null)); + assert.equal(await driver.find('#rowId').getText(), withAccess('2', '')); + assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); + assert.deepEqual(readJson(await driver.find('#records').getText()), + withAccess([{ Name: "Tom", // not a list! + Favorite_Film: "Toy Story", + Age: "25", + id: 2 }], null)); + await driver.switchTo().defaultContent(); + + // Switch row in source section, and see if data updates correctly. + await gu.getCell({section: 'Performances record', col: 0, rowNum: 5}).click(); + await driver.switchTo().frame(iframe); + assert.deepEqual(readJson(await driver.find('#placeholder').getText()), + withAccess({ Name: ["Roger", "Evan"], + Favorite_Film: ["Forrest Gump", "Forrest Gump"], + Age: ["22", "35"], + id: [1, 5] }, null)); + assert.equal(await driver.find('#rowId').getText(), withAccess('1', '')); + assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); + assert.deepEqual(readJson(await driver.find('#records').getText()), + withAccess([{ Name: "Roger", + Favorite_Film: "Forrest Gump", + Age: "22", + id: 1 }, + { Name: "Evan", + Favorite_Film: "Forrest Gump", + Age: "35", + id: 5 }], null)); + await driver.switchTo().defaultContent(); + }); + + it('gets notification of row changes and content changes', async function() { + // Add a custom view linked to Friends + await driver.findContent('.test-treeview-itemHeader', /Friends/).click(); + await driver.findWait('.test-dp-add-new', 1000).doClick(); + await driver.find('.test-dp-add-widget-to-page').doClick(); + await driver.findContent('.test-wselect-type', /Custom/).click(); + await driver.findContent('.test-wselect-table', /Friends/).doClick(); + await driver.find('.test-wselect-selectby').doClick(); + await driver.findContent('.test-wselect-selectby option', /FRIENDS/).doClick(); + await driver.find('.test-wselect-addBtn').click(); + await gu.waitForServer(); + + // Choose the custom view that just reads out data as json + await driver.find('.test-config-widget').click(); + await setCustomWidget(); + await driver.find('.test-config-widget-url').click(); + await driver.sendKeys(`${serving.url}/readout`, Key.ENTER); + await setAccess(access); + await gu.waitForServer(); + + // Check that data and cursor looks right + const iframe = gu.getSection('Friends custom').find('iframe'); + await driver.switchTo().frame(iframe); + assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name, + withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined)); + assert.equal(await driver.find('#rowId').getText(), withAccess('1', '')); + assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); + assert.equal(readJson(await driver.find('#record').getText())?.Name, + withAccess('Roger', undefined)); + assert.deepEqual(readJson(await driver.find('#records').getText())?.[0]?.Name, + withAccess('Roger', undefined)); + + // Change row in Friends + await driver.switchTo().defaultContent(); + await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 2}).click(); + + // Check that rowId is updated + await driver.switchTo().frame(iframe); + assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name, + withAccess(['Roger', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined)); + assert.equal(await driver.find('#rowId').getText(), withAccess('2', '')); + assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); + assert.equal(readJson(await driver.find('#record').getText())?.Name, + withAccess('Tom', undefined)); + assert.deepEqual(readJson(await driver.find('#records').getText())?.[0]?.Name, + withAccess('Roger', undefined)); + await driver.switchTo().defaultContent(); + + // Change a cell in Friends + await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); + await gu.enterCell('Rabbit'); + await gu.waitForServer(); + // Return to the cell after automatically going to next row. + await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); + + // Check the data in view updates + await driver.switchTo().frame(iframe); + assert.deepEqual(readJson(await driver.find('#placeholder').getText())?.Name, + withAccess(['Rabbit', 'Tom', 'Sydney', 'Bill', 'Evan', 'Mary'], undefined)); + assert.equal(await driver.find('#rowId').getText(), withAccess('1', '')); + assert.equal(await driver.find('#tableId').getText(), withAccess('Friends', '')); + assert.equal(readJson(await driver.find('#record').getText())?.Name, + withAccess('Rabbit', undefined)); + assert.deepEqual(readJson(await driver.find('#records').getText())?.[0]?.Name, + withAccess('Rabbit', undefined)); + await driver.switchTo().defaultContent(); + + // Select new row and test if custom view has noticed it. + await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 7}).click(); + await driver.switchTo().frame(iframe); + assert.equal(await driver.find('#rowId').getText(), withAccess('new', '')); + assert.equal(await driver.find('#record').getText(), withAccess('new', '')); + await driver.switchTo().defaultContent(); + await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); + await driver.switchTo().frame(iframe); + assert.equal(await driver.find('#rowId').getText(), withAccess('1', '')); + assert.equal(readJson(await driver.find('#record').getText())?.Name, withAccess('Rabbit', undefined)); + await driver.switchTo().defaultContent(); + + // Revert the cell change + await gu.undo(); + }); + + it('allows switching to custom section by clicking inside it', async function() { + await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); + assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS'); + assert.equal(await driver.find('.test-config-widget-url').isPresent(), false); + + const iframe = gu.getSection('Friends custom').find('iframe'); + await driver.switchTo().frame(iframe); + await driver.find('body').click(); + + // Check that the right secton is active, and its settings visible in the side panel. + await driver.switchTo().defaultContent(); + assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS Custom'); + assert.equal(await driver.find('.test-config-widget-url').isPresent(), true); + + // Switch back. + await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).click(); + assert.equal(await gu.getActiveSectionTitle(), 'FRIENDS'); + assert.equal(await driver.find('.test-config-widget-url').isPresent(), false); + }); + + it('deals correctly with requests that require full access', async function() { + // Choose a custom widget that tries to replace all cells in all user tables with 'zap'. + await gu.getSection('Friends Custom').click(); + await driver.find('.test-config-widget').click(); + await setAccess("none"); + await gu.waitForServer(); + + await gu.setValue(driver.find('.test-config-widget-url'), ''); + await driver.find('.test-config-widget-url').click(); + await driver.sendKeys(`${serving.url}/zap`, Key.ENTER); + await setAccess(access); + await gu.waitForServer(); + + // Wait for widget to finish its work. + const iframe = gu.getSection('Friends custom').find('iframe'); + await driver.switchTo().frame(iframe); + await gu.waitToPass(async () => { + assert.match(await driver.find('#placeholder').getText(), /zap/); + }, 10000); + const outcome = await driver.find('#placeholder').getText(); + await driver.switchTo().defaultContent(); + + const cell = await gu.getCell({section: 'FRIENDS', col: 0, rowNum: 1}).getText(); + if (access === 'full') { + assert.equal(cell, 'zap'); + assert.match(outcome, /zap succeeded/); + } else { + assert.notEqual(cell, 'zap'); + assert.match(outcome, /zap failed/); + } + }); + }); + } + + it('should receive friendly types when reading data from Grist', async function() { + // TODO The same decoding should probably apply to calls like fetchTable() which are satisfied + // by the server. + const mainSession = await gu.session().teamSite.login(); + await mainSession.tempDoc(cleanup, 'TypeEncoding.grist'); + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-config-data').click(); + + // The test doc already has a Custom View widget. It just needs to + // have a URL set. + await gu.getSection('TYPES custom').click(); + await driver.find('.test-config-widget').click(); + await setCustomWidget(); + // If we needed to change widget to Custom URL, make sure access is read table. + await setAccess("read table"); + await driver.find('.test-config-widget-url').click(); + await driver.sendKeys(`${serving.url}/types`, Key.ENTER); + + const iframe = gu.getSection('TYPES custom').find('iframe'); + await driver.switchTo().frame(iframe); + await driver.findContentWait('#record', /AnyDate/, 1000000); + let lines = (await driver.find('#record').getText()).split('\n'); + + // The first line has regular old values. + assert.deepEqual(lines, [ + "AnyDate: 2020-07-02 [typeof=object] [name=GristDate] [date=2020-07-02T00:00:00.000Z]", + "AnyDateTime: 1990-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=1990-08-21T17:19:40.705Z]", + "AnyRef: Types[2] [typeof=object] [name=Reference]", + "Bool: true [typeof=boolean]", + "Date: 2020-07-01 [typeof=object] [name=GristDate] [date=2020-07-01T00:00:00.000Z]", + "DateTime: 2020-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=2020-08-21T17:19:40.705Z]", + "Numeric: 17.25 [typeof=number]", + "RECORD: [object Object] [typeof=object] [name=Object]", + " AnyDate: 2020-07-02 [typeof=object] [name=GristDate] [date=2020-07-02T00:00:00.000Z]", + " AnyDateTime: 1990-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=1990-08-21T17:19:40.705Z]", + " AnyRef: Types[2] [typeof=object] [name=Reference]", + " Bool: true [typeof=boolean]", + " Date: 2020-07-01 [typeof=object] [name=GristDate] [date=2020-07-01T00:00:00.000Z]", + " DateTime: 2020-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=2020-08-21T17:19:40.705Z]", + " Numeric: 17.25 [typeof=number]", + " Reference: Types[2] [typeof=object] [name=Reference]", + " Text: Hello! [typeof=string]", + " id: 24 [typeof=number]", + "Reference: Types[2] [typeof=object] [name=Reference]", + "Text: Hello! [typeof=string]", + "id: 24 [typeof=number]", + ]); + + // #match tells us if onRecords() returned the same representation for this record. + assert.equal(await driver.find('#match').getText(), 'true'); + + // Switch to the next row, which has blank values. + await driver.switchTo().defaultContent(); + await gu.getCell({section: 'TYPES', col: 0, rowNum: 2}).click(); + await driver.switchTo().frame(iframe); + await driver.findContentWait('#record', /AnyDate: null/, 1000); + lines = (await driver.find('#record').getText()).split('\n'); + assert.deepEqual(lines, [ + "AnyDate: null [typeof=object]", + "AnyDateTime: null [typeof=object]", + "AnyRef: Types[0] [typeof=object] [name=Reference]", + "Bool: false [typeof=boolean]", + "Date: null [typeof=object]", + "DateTime: null [typeof=object]", + "Numeric: 0 [typeof=number]", + "RECORD: [object Object] [typeof=object] [name=Object]", + " AnyDate: null [typeof=object]", + " AnyDateTime: null [typeof=object]", + " AnyRef: Types[0] [typeof=object] [name=Reference]", + " Bool: false [typeof=boolean]", + " Date: null [typeof=object]", + " DateTime: null [typeof=object]", + " Numeric: 0 [typeof=number]", + " Reference: Types[0] [typeof=object] [name=Reference]", + " Text: [typeof=string]", + " id: 1 [typeof=number]", + "Reference: Types[0] [typeof=object] [name=Reference]", + "Text: [typeof=string]", + "id: 1 [typeof=number]", + ]); + + // #match tells us if onRecords() returned the same representation for this record. + assert.equal(await driver.find('#match').getText(), 'true'); + + // Switch to the next row, which has various error values. + await driver.switchTo().defaultContent(); + await gu.getCell({section: 'TYPES', col: 0, rowNum: 3}).click(); + await driver.switchTo().frame(iframe); + await driver.findContentWait('#record', /AnyDate: null/, 1000); + lines = (await driver.find('#record').getText()).split('\n'); + + assert.deepEqual(lines, [ + "AnyDate: #Invalid Date: Not-a-Date [typeof=object] [name=RaisedException]", + "AnyDateTime: #Invalid DateTime: Not-a-DateTime [typeof=object] [name=RaisedException]", + "AnyRef: #AssertionError [typeof=object] [name=RaisedException]", + "Bool: true [typeof=boolean]", + "Date: Not-a-Date [typeof=string]", + "DateTime: Not-a-DateTime [typeof=string]", + "Numeric: Not-a-Number [typeof=string]", + "RECORD: [object Object] [typeof=object] [name=Object]", + " AnyDate: null [typeof=object]", + " AnyDateTime: null [typeof=object]", + " AnyRef: null [typeof=object]", + " Bool: true [typeof=boolean]", + " Date: Not-a-Date [typeof=string]", + " DateTime: Not-a-DateTime [typeof=string]", + " Numeric: Not-a-Number [typeof=string]", + " Reference: No-Ref [typeof=string]", + " Text: Errors [typeof=string]", + " _error_: [object Object] [typeof=object] [name=Object]", + " AnyDate: InvalidTypedValue: Invalid Date: Not-a-Date [typeof=string]", + " AnyDateTime: InvalidTypedValue: Invalid DateTime: Not-a-DateTime [typeof=string]", + " AnyRef: AssertionError: [typeof=string]", + " id: 2 [typeof=number]", + "Reference: No-Ref [typeof=string]", + "Text: Errors [typeof=string]", + "id: 2 [typeof=number]", + ]); + + // #match tells us if onRecords() returned the same representation for this record. + assert.equal(await driver.find('#match').getText(), 'true'); + }); + + it('respect access rules', async function() { + // Create a Favorite Films copy, with access rules on columns, rows, and tables. + const mainSession = await gu.session().teamSite.login(); + const api = mainSession.createHomeApi(); + const doc = await mainSession.tempDoc(cleanup, 'Favorite_Films.grist', {load: false}); + await api.applyUserActions(doc.id, [ + ['AddTable', 'Opinions', [{id: 'A'}]], + ['AddRecord', 'Opinions', null, {A: 'do not zap plz'}], + ['AddRecord', '_grist_ACLResources', -1, {tableId: 'Performances', colIds: 'Actor'}], + ['AddRecord', '_grist_ACLResources', -2, {tableId: 'Films', colIds: '*'}], + ['AddRecord', '_grist_ACLResources', -3, {tableId: 'Opinions', colIds: '*'}], + ['AddRecord', '_grist_ACLRules', null, { + resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'none', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -2, aclFormula: 'rec.id % 2 == 0', permissionsText: 'none', + }], + ['AddRecord', '_grist_ACLRules', null, { + resource: -3, aclFormula: '', permissionsText: 'none', + }], + ]); + + // Open it up and add a new linked section. + await mainSession.loadDoc(`/doc/${doc.id}`); + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-config-data').click(); + await driver.findContent('.test-treeview-itemHeader', /All/).click(); + await gu.getSection('Friends record').click(); + await driver.find('.test-pwc-editDataSelection').click(); + await driver.find('.test-wselect-addBtn').click(); + await gu.waitForServer(); + await driver.find('.test-right-select-by').click(); + await driver.findContent('.test-select-menu li', /Performances record • Film/).click(); + await driver.find('.test-pwc-editDataSelection').click(); + await driver.findContent('.test-wselect-type', /Custom/).click(); + await driver.find('.test-wselect-addBtn').click(); + await gu.waitForServer(); + + // Select a custom widget that tries to replace all cells in all user tables with 'zap'. + await driver.find('.test-config-widget').click(); + await setCustomWidget(); + await driver.find('.test-config-widget-url').click(); + await driver.sendKeys(`${serving.url}/zap`, Key.ENTER); + await setAccess("full"); + await gu.waitForServer(); + + // Wait for widget to finish its work. + const iframe = gu.getSection('Friends record').find('iframe'); + await driver.switchTo().frame(iframe); + await gu.waitToPass(async () => { + assert.match(await driver.find('#placeholder').getText(), /zap/); + }, 10000); + await driver.switchTo().defaultContent(); + + // Now leave the page and remove all access rules. + await mainSession.loadDocMenu('/'); + await api.applyUserActions(doc.id, [ + ['BulkRemoveRecord', '_grist_ACLRules', [2, 3, 4]] + ]); + + // Check that the expected cells got zapped. + + // In performances table, all but Actor column should have been zapped. + const performances = await api.getDocAPI(doc.id).getRows('Performances'); + let keys = Object.keys(performances); + for (let i = 0; i < performances.id.length; i++) { + for (const key of keys) { + if (key !== 'Actor' && key !== 'id' && key !== 'manualSort') { + // use match since zap may be embedded in an error, e.g. if inserted in ref column. + assert.match(String(performances[key][i]), /zap/); + } + assert.notMatch(String(performances['Actor'][i]), /zap/); + } + } + + // In films table, every second row should have been zapped. + const films = await api.getDocAPI(doc.id).getRows('Films'); + keys = Object.keys(films); + for (let i = 0; i < films.id.length; i++) { + for (const key of keys) { + if (key !== 'id' && key !== 'manualSort') { + assert.equal(films[key][i] === 'zap', films.id[i] % 2 === 1); + } + } + } + + // Opinions table should be untouched. + const opinions = await api.getDocAPI(doc.id).getRows('Opinions'); + assert.equal(opinions['A'][0], 'do not zap plz'); + }); +}); diff --git a/test/nbrowser/CustomWidgets.ts b/test/nbrowser/CustomWidgets.ts new file mode 100644 index 00000000..23bfd084 --- /dev/null +++ b/test/nbrowser/CustomWidgets.ts @@ -0,0 +1,583 @@ +import {assert, driver, Key} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; +import {serveSomething} from 'test/server/customUtil'; +import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; +import {AccessTokenResult} from 'app/plugin/GristAPI'; +import {TableOperations} from 'app/plugin/TableOperations'; +import {getAppRoot} from 'app/server/lib/places'; +import fetch from 'node-fetch'; +import * as path from 'path'; + +// Valid manifest url. +const manifestEndpoint = '/manifest.json'; +// Valid widget url. +const widgetEndpoint = '/widget'; +// Custom URL label in selectbox. +const CUSTOM_URL = 'Custom URL'; + +// Create some widgets: +const widget1: ICustomWidget = {widgetId: '1', name: 'W1', url: widgetEndpoint + '?name=W1'}; +const widget2: ICustomWidget = {widgetId: '2', name: 'W2', url: widgetEndpoint + '?name=W2'}; +const fromAccess = (level: AccessLevel) => + ({widgetId: level, name: level, url: widgetEndpoint, accessLevel: level}) as ICustomWidget; +const widgetNone = fromAccess(AccessLevel.none); +const widgetRead = fromAccess(AccessLevel.read_table); +const widgetFull = fromAccess(AccessLevel.full); + +// Holds widgets manifest content. +let widgets: ICustomWidget[] = []; + +describe('CustomWidgets', function () { + this.timeout(20000); + const cleanup = setupTestSuite(); + + // Holds url for sample widget server. + let widgetServerUrl = ''; + + // Switches widget manifest url + function useManifest(url: string) { + return server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : ''); + } + + before(async function () { + if (server.isExternalServer()) { + this.skip(); + } + // Create simple widget server that serves manifest.json file, some widgets and some error pages. + const widgetServer = await serveSomething(app => { + app.get('/404', (_, res) => res.sendStatus(404).end()); // not found + app.get('/500', (_, res) => res.sendStatus(500).end()); // internal error + app.get('/200', (_, res) => res.sendStatus(200).end()); // valid response with OK + app.get('/401', (_, res) => res.sendStatus(401).end()); // unauthorized + app.get('/403', (_, res) => res.sendStatus(403).end()); // forbidden + app.get(widgetEndpoint, (req, res) => + res + .header('Content-Type', 'text/html') + .send('\n' + + (req.query.name || req.query.access) + // send back widget name from query string or access level + '\n') + .end() + ); + app.get(manifestEndpoint, (_, res) => + res + .header('Content-Type', 'application/json') + // prefix widget endpoint with server address + .json(widgets.map(widget => ({...widget, url: `${widgetServerUrl}${widget.url}`}))) + .end() + ); + app.get('/grist-plugin-api.js', (_, res) => + res.sendFile( + 'grist-plugin-api.js', { + root: path.resolve(getAppRoot(), "static") + })); + }); + + cleanup.addAfterAll(widgetServer.shutdown); + widgetServerUrl = widgetServer.url; + + // Start with valid endpoint and 2 widgets. + widgets = [widget1, widget2]; + await useManifest(manifestEndpoint); + + const session = await gu.session().login(); + await session.tempDoc(cleanup, 'Hello.grist'); + // Add custom section. + await gu.addNewSection(/Custom/, /Table1/, {selectBy: /TABLE1/}); + + // Override gristConfig to enable widget list. + await driver.executeScript('window.gristConfig.enableWidgetRepository = true;'); + }); + + // Open or close widget menu. + const toggle = () => driver.find('.test-config-widget-select .test-select-open').click(); + // Get current value from widget menu. + const current = () => driver.find('.test-config-widget-select .test-select-open').getText(); + // Get options from widget menu (must be first opened). + const options = () => driver.findAll('.test-select-menu li', e => e.getText()); + // Select widget from the menu. + const select = async (text: string | RegExp) => { + await driver.findContent('.test-select-menu li', text).click(); + await gu.waitForServer(); + }; + // Get rendered content from custom section. + const content = async () => { + const iframe = driver.find('iframe'); + await driver.switchTo().frame(iframe); + const text = await driver.find('body').getText(); + await driver.switchTo().defaultContent(); + return text; + }; + async function execute( + op: (table: TableOperations) => Promise, + tableSelector: (grist: any) => TableOperations = (grist) => grist.selectedTable + ) { + const iframe = await driver.find('iframe'); + await driver.switchTo().frame(iframe); + try { + const harness = async (done: any) => { + const grist = (window as any).grist; + grist.ready(); + const table = tableSelector(grist); + try { + let result = await op(table); + if (result === undefined) { + result = "__undefined__"; + } + done(result); + } catch (e) { + done(String(e.message || e)); + } + }; + const cmd = + 'const done = arguments[arguments.length - 1];\n' + + 'const op = ' + op.toString() + ';\n' + + 'const tableSelector = ' + tableSelector.toString() + ';\n' + + 'const harness = ' + harness.toString() + ';\n' + + 'harness(done);\n'; + const result = await driver.executeAsyncScript(cmd); + // done callback will return null instead of undefined + return result === "__undefined__" ? undefined : result; + } finally { + await driver.switchTo().defaultContent(); + } + } + // Replace url for the Custom URL widget. + const setUrl = async (url: string) => { + await driver.find('.test-config-widget-url').click(); + // First clear textbox. + await gu.clearInput(); + if (url) { + await gu.sendKeys(`${widgetServerUrl}${url}`, Key.ENTER); + } else { + await gu.sendKeys(Key.ENTER); + } + }; + // Get an URL from the URL textbox. + const getUrl = () => driver.find('.test-config-widget-url').value(); + // Get first error message from error toasts. + const getErrorMessage = async () => (await gu.getToasts())[0]; + // Changes active section to recreate creator panel. + async function recreatePanel() { + await gu.getSection('TABLE1').click(); + await gu.getSection('TABLE1 Custom').click(); + await gu.waitForServer(); + } + // Gets or sets access level + async function access(level?: AccessLevel) { + const text = { + [AccessLevel.none] : "No document access", + [AccessLevel.read_table]: "Read selected table", + [AccessLevel.full]: "Full document access" + }; + if (!level) { + const currentAccess = await driver.find('.test-config-widget-access .test-select-open').getText(); + return Object.entries(text).find(e => e[1] === currentAccess)![0]; + } else { + await driver.find('.test-config-widget-access .test-select-open').click(); + await driver.findContent('.test-select-menu li', text[level]).click(); + await gu.waitForServer(); + } + } + + // Checks if access prompt is visible. + const hasPrompt = () => driver.find(".test-config-widget-access-accept").isPresent(); + // Accepts new access level. + const accept = () => driver.find(".test-config-widget-access-accept").click(); + // Rejects new access level. + const reject = () => driver.find(".test-config-widget-access-reject").click(); + + it('should show widgets in dropdown', async () => { + await gu.toggleSidePanel('right'); + await driver.find('.test-config-widget').click(); + await gu.waitForServer(); // Wait for widgets to load. + + // Selectbox should have select label. + assert.equal(await current(), CUSTOM_URL); + + // There should be 3 options (together with Custom URL) + await toggle(); + assert.deepEqual(await options(), [CUSTOM_URL, widget1.name, widget2.name]); + await toggle(); + }); + + it('should switch between widgets', async () => { + // Test custom URL. + await toggle(); + await select(CUSTOM_URL); + assert.equal(await current(), CUSTOM_URL); + assert.equal(await getUrl(), ''); + await setUrl('/200'); + assert.equal(await content(), 'OK'); + + // Test first widget. + await toggle(); + await select(widget1.name); + assert.equal(await current(), widget1.name); + assert.equal(await content(), widget1.name); + + // Test second widget. + await toggle(); + await select(widget2.name); + assert.equal(await current(), widget2.name); + assert.equal(await content(), widget2.name); + + // Go back to Custom URL. + await toggle(); + await select(CUSTOM_URL); + assert.equal(await getUrl(), ''); + assert.equal(await current(), CUSTOM_URL); + await setUrl('/200'); + assert.equal(await content(), 'OK'); + + // Clear url and test if message page is shown. + await setUrl(''); + assert.equal(await current(), CUSTOM_URL); + assert.isTrue((await content()).startsWith('Custom widget')); // start page + + await recreatePanel(); + assert.equal(await current(), CUSTOM_URL); + await gu.undo(7); + }); + + it('should show error message for invalid widget url list', async () => { + const testError = async (url: string, error: string) => { + // Switch section to rebuild the creator panel. + await useManifest(url); + await recreatePanel(); + assert.include(await getErrorMessage(), error); + await gu.wipeToasts(); + // List should contain only a Custom URL. + await toggle(); + assert.deepEqual(await options(), [CUSTOM_URL]); + await toggle(); + }; + + await testError('/404', "Remote widget list not found"); + await testError('/500', "Remote server returned an error"); + await testError('/401', "Remote server returned an error"); + await testError('/403', "Remote server returned an error"); + // Invalid content in a response. + await testError('/200', "Error reading widget list"); + + // Reset to valid manifest. + await useManifest(manifestEndpoint); + await recreatePanel(); + }); + + it('should show widget when it was removed from list', async () => { + // Select widget1 and then remove it from the list. + await toggle(); + await select(widget1.name); + widgets = [widget2]; + // Invalidate cache. + await useManifest(manifestEndpoint); + // Toggle sections to reset creator panel and fetch list of available widgets. + await recreatePanel(); + // But still should be selected with a correct url. + assert.equal(await current(), widget1.name); + assert.equal(await content(), widget1.name); + await gu.undo(1); + }); + + it('should switch access level to none on new widget', async () => { + widgets = [widget1, widget2]; + await useManifest(manifestEndpoint); + await recreatePanel(); + + await toggle(); + await select(widget1.name); + assert.equal(await access(), AccessLevel.none); + await access(AccessLevel.full); + assert.equal(await access(), AccessLevel.full); + + await toggle(); + await select(widget2.name); + assert.equal(await access(), AccessLevel.none); + await access(AccessLevel.full); + assert.equal(await access(), AccessLevel.full); + + await toggle(); + await select(CUSTOM_URL); + assert.equal(await access(), AccessLevel.none); + await access(AccessLevel.full); + assert.equal(await access(), AccessLevel.full); + + await toggle(); + await select(widget2.name); + assert.equal(await access(), AccessLevel.none); + await access(AccessLevel.full); + assert.equal(await access(), AccessLevel.full); + + await gu.undo(8); + }); + + it('should prompt for access change', async () => { + widgets = [widget1, widget2, widgetFull, widgetNone, widgetRead]; + await useManifest(manifestEndpoint); + await recreatePanel(); + + const test = async (w: ICustomWidget) => { + // Select widget without desired access level + await toggle(); + await select(widget1.name); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + + // Select one with desired access level + await toggle(); + await select(w.name); + // Access level should be still none (test by content which will display access level from query string) + assert.equal(await content(), AccessLevel.none); + assert.equal(await access(), AccessLevel.none); + assert.isTrue(await hasPrompt()); + + // Accept, and test if prompt is hidden, and level stays + await accept(); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), w.accessLevel); + + // Do the same, but this time reject + await toggle(); + await select(widget1.name); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + + await toggle(); + await select(w.name); + assert.isTrue(await hasPrompt()); + assert.equal(await content(), AccessLevel.none); + + await reject(); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + assert.equal(await content(), AccessLevel.none); + }; + + await test(widgetFull); + await test(widgetRead); + }); + + it('should auto accept none access level', async () => { + // Select widget without access level + await toggle(); + await select(widget1.name); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + + // Switch to one with none access level + await toggle(); + await select(widgetNone.name); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + assert.equal(await content(), AccessLevel.none); + }); + + it('should show prompt when user switches sections', async () => { + // Select widget without access level + await toggle(); + await select(widget1.name); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + + // Switch to one with full access level + await toggle(); + await select(widgetFull.name); + assert.isTrue(await hasPrompt()); + + // Switch section, and test if prompt is hidden + await recreatePanel(); + assert.isTrue(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + assert.equal(await content(), AccessLevel.none); + }); + + it('should hide prompt when user switches widget', async () => { + // Select widget without access level + await toggle(); + await select(widget1.name); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + + // Switch to one with full access level + await toggle(); + await select(widgetFull.name); + assert.isTrue(await hasPrompt()); + + // Switch to another level. + await toggle(); + await select(widget1.name); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + }); + + it('should hide prompt when manually changes access level', async () => { + // Select widget with no access level + const selectNone = async () => { + await toggle(); + await select(widgetNone.name); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + assert.equal(await content(), AccessLevel.none); + }; + + // Selects widget with full access level + const selectFull = async () => { + await toggle(); + await select(widgetFull.name); + assert.isTrue(await hasPrompt()); + assert.equal(await content(), AccessLevel.none); + assert.equal(await content(), AccessLevel.none); + }; + + await selectNone(); + await selectFull(); + + // Select the same level. + await access(AccessLevel.full); + assert.isFalse(await hasPrompt()); + assert.equal(await access(), AccessLevel.full); + assert.equal(await content(), AccessLevel.full); + + await selectNone(); + await selectFull(); + + // Select the normal level, prompt should be still there, as widget needs a higher permission. + await access(AccessLevel.read_table); + assert.isTrue(await hasPrompt()); + assert.equal(await access(), AccessLevel.read_table); + assert.equal(await content(), AccessLevel.read_table); + + await selectNone(); + await selectFull(); + + // Select the none level. + await access(AccessLevel.none); + assert.isTrue(await hasPrompt()); + assert.equal(await access(), AccessLevel.none); + assert.equal(await content(), AccessLevel.none); + }); + + it("should support grist.selectedTable", async () => { + // Open a custom widget with full access. + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-config-widget').click(); + await gu.waitForServer(); + await toggle(); + await select(widget1.name); + await access(AccessLevel.full); + + // Check an upsert works. + await execute(async (table) => { + await table.upsert({ + require: {A: 'hello'}, + fields: {A: 'goodbye'} + }); + }); + await gu.waitToPass(async () => { + assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 1, col: 0}).getText(), 'goodbye'); + }); + + // Check an update works. + await execute(async table => { + return table.update({ + id: 2, + fields: {A: 'farewell'} + }); + }); + await gu.waitToPass(async () => { + assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 2, col: 0}).getText(), 'farewell'); + }); + + // Check options are passed along. + await execute(async table => { + return table.upsert({ + require: {}, + fields: {A: 'goodbyes'} + }, {onMany: 'all', allowEmptyRequire: true}); + }); + await gu.waitToPass(async () => { + assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 1, col: 0}).getText(), 'goodbyes'); + assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 2, col: 0}).getText(), 'goodbyes'); + }); + + // Check a create works. + const {id} = await execute(async table => { + return table.create({ + fields: {A: 'partA', B: 'partB'} + }); + }) as {id: number}; + assert.equal(id, 5); + await gu.waitToPass(async () => { + assert.equal(await gu.getCell({section: 'TABLE1', rowNum: id, col: 0}).getText(), 'partA'); + assert.equal(await gu.getCell({section: 'TABLE1', rowNum: id, col: 1}).getText(), 'partB'); + }); + + // Check a destroy works. + let result = await execute(async table => { + await table.destroy(1); + }); + assert.isUndefined(result); + await gu.waitToPass(async () => { + assert.equal(await gu.getCell({section: 'TABLE1', rowNum: id - 1, col: 0}).getText(), 'partA'); + }); + result = await execute(async table => { + await table.destroy([2]); + }); + assert.isUndefined(result); + await gu.waitToPass(async () => { + assert.equal(await gu.getCell({section: 'TABLE1', rowNum: id - 2, col: 0}).getText(), 'partA'); + }); + + // Check errors are friendly. + const errMessage = await execute(async table => { + await table.create({fields: {ziggy: 1}}); + }); + assert.equal(errMessage, 'Invalid column "ziggy"'); + }); + + it("should support grist.getTable", async () => { + // Check an update on an existing table works. + await execute(async table => { + return table.update({ + id: 3, + fields: {A: 'back again'} + }); + }, (grist) => grist.getTable('Table1')); + await gu.waitToPass(async () => { + assert.equal(await gu.getCell({section: 'TABLE1', rowNum: 1, col: 0}).getText(), 'back again'); + }); + + // Check an update on a nonexistent table fails. + assert.match(String(await execute(async table => { + return table.update({ + id: 3, + fields: {A: 'back again'} + }); + }, (grist) => grist.getTable('Table2'))), /Table not found/); + }); + + it("should support grist.getAccessTokens", async () => { + const iframe = await driver.find('iframe'); + await driver.switchTo().frame(iframe); + try { + const tokenResult: AccessTokenResult = await driver.executeAsyncScript( + (done: any) => (window as any).grist.getAccessToken().then(done) + ); + assert.sameMembers(Object.keys(tokenResult), ['ttlMsecs', 'token', 'baseUrl']); + const result = await fetch(tokenResult.baseUrl + `/tables/Table1/records?auth=${tokenResult.token}`); + assert.sameMembers(Object.keys(await result.json()), ['records']); + } finally { + await driver.switchTo().defaultContent(); + } + }); + + it('should offer only custom url when disabled', async () => { + await toggle(); + await select(CUSTOM_URL); + await driver.executeScript('window.gristConfig.enableWidgetRepository = false;'); + await recreatePanel(); + assert.isTrue(await driver.find('.test-config-widget-url').isDisplayed()); + assert.isFalse(await driver.find('.test-config-widget-select').isPresent()); + }); +}); diff --git a/test/nbrowser/CustomWidgetsConfig.ts b/test/nbrowser/CustomWidgetsConfig.ts new file mode 100644 index 00000000..965dd303 --- /dev/null +++ b/test/nbrowser/CustomWidgetsConfig.ts @@ -0,0 +1,952 @@ +import {assert, driver, Key} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {server, setupTestSuite} from 'test/nbrowser/testUtils'; +import {addStatic, serveSomething} from 'test/server/customUtil'; +import {AccessLevel} from 'app/common/CustomWidget'; + +// Valid manifest url. +const manifestEndpoint = '/manifest.json'; + +let docId = ''; + +// Tester widget name. +const TESTER_WIDGET = 'Tester'; +const NORMAL_WIDGET = 'Normal'; +const READ_WIDGET = 'Read'; +const FULL_WIDGET = 'Full'; +const COLUMN_WIDGET = 'COLUMN_WIDGET'; +// Custom URL label in selectbox. +const CUSTOM_URL = 'Custom URL'; +// Holds url for sample widget server. +let widgetServerUrl = ''; + +// Creates url for Config Widget passing ready arguments in URL. This is not builtin method, Config Widget understands +// this parameter and is using it as an argument for the ready method. +function createConfigUrl(ready?: any) { + return ready ? `${widgetServerUrl}/config?ready=` + encodeURI(JSON.stringify(ready)) : `${widgetServerUrl}/config`; +} + +// Open or close widget menu. +const click = (selector: string) => driver.find(`${selector}`).click(); +const toggleDrop = (selector: string) => click(`${selector} .test-select-open`); +const toggleWidgetMenu = () => toggleDrop('.test-config-widget-select'); +const getOptions = () => driver.findAll('.test-select-menu li', el => el.getText()); +// Get current value from widget menu. +const currentWidget = () => driver.find('.test-config-widget-select .test-select-open').getText(); +// Select widget from the menu. +const clickOption = async (text: string | RegExp) => { + await driver.findContent('.test-select-menu li', text).click(); + await gu.waitForServer(); +}; +// Persists custom options. +const persistOptions = () => click('.test-section-menu-small-btn-save'); + +// Helpers to create test ids for column pickers +const pickerLabel = (name: string) => `.test-config-widget-label-for-${name}`; +const pickerDrop = (name: string) => `.test-config-widget-mapping-for-${name}`; +const pickerAdd = (name: string) => `.test-config-widget-add-column-for-${name}`; + +// Helpers to work with menus +async function clickMenuItem(name: string) { + await driver.findContent('.grist-floating-menu li', name).click(); + await gu.waitForServer(); +} +const getMenuOptions = () => driver.findAll('.grist-floating-menu li', el => el.getText()); +async function getListItems(col: string) { + return await driver + .findAll(`.test-config-widget-map-list-for-${col} .test-config-widget-ref-select-label`, el => el.getText()); +} + +// Gets or sets access level +async function givenAccess(level?: AccessLevel) { + const text = { + [AccessLevel.none]: 'No document access', + [AccessLevel.read_table]: 'Read selected table', + [AccessLevel.full]: 'Full document access', + }; + if (!level) { + const currentAccess = await driver.find('.test-config-widget-access .test-select-open').getText(); + return Object.entries(text).find(e => e[1] === currentAccess)![0]; + } else { + await driver.find('.test-config-widget-access .test-select-open').click(); + await driver.findContent('.test-select-menu li', text[level]).click(); + await gu.waitForServer(); + } +} + +// Checks if access prompt is visible. +const hasPrompt = () => driver.find('.test-config-widget-access-accept').isPresent(); +// Accepts new access level. +const accept = () => driver.find('.test-config-widget-access-accept').click(); +// When refreshing, we need to make sure widget repository is enabled once again. +async function refresh() { + await driver.navigate().refresh(); + await gu.waitForDocToLoad(); + // Switch section and enable config + await gu.selectSectionByTitle('Table'); + await driver.executeScript('window.gristConfig.enableWidgetRepository = true;'); + await gu.selectSectionByTitle('Widget'); +} + +async function selectAccess(access: string) { + // if the current access is ok do nothing + if ((await givenAccess()) === access) { + // unless we need to confirm it + if (await hasPrompt()) { + await accept(); + } + } else { + // else switch access level + await givenAccess(access as AccessLevel); + } +} + +// Checks if active section has option in the menu to open configuration +async function hasSectionOption() { + const menu = await gu.openSectionMenu('viewLayout'); + const has = 1 === (await menu.findAll('.test-section-open-configuration')).length; + await driver.sendKeys(Key.ESCAPE); + return has; +} + +async function saveMenu() { + await driver.findWait('.active_section .test-section-menu-small-btn-save', 100).click(); + await gu.waitForServer(); +} + +async function revertMenu() { + await driver.findWait('.active_section .test-section-menu-small-btn-revert', 100).click(); +} + +async function clearOptions() { + await gu.openSectionMenu('sortAndFilter'); + await driver.findWait('.test-section-menu-btn-remove-options', 100).click(); + await driver.sendKeys(Key.ESCAPE); +} + +// Check if the Sort menu is in correct state +async function checkSortMenu(state: 'empty' | 'modified' | 'customized' | 'emptyNotSaved') { + // for modified and emptyNotSaved menu should be greyed and buttons should be hidden + if (state === 'modified' || state === 'emptyNotSaved') { + assert.isTrue(await driver.find('.active_section .test-section-menu-wrapper').matches('[class*=-unsaved]')); + } else { + assert.isFalse(await driver.find('.active_section .test-section-menu-wrapper').matches('[class*=-unsaved]')); + } + // open menu + await gu.openSectionMenu('sortAndFilter'); + // for modified state, there should be buttons save and revert + if (state === 'modified' || state === 'emptyNotSaved') { + assert.isTrue(await driver.find('.test-section-menu-btn-save').isPresent()); + } else { + assert.isFalse(await driver.find('.test-section-menu-btn-save').isPresent()); + } + const text = await driver.find('.test-section-menu-custom-options').getText(); + if (state === 'empty' || state === 'emptyNotSaved') { + assert.equal(text, '(empty)'); + } else if (state === 'modified') { + assert.equal(text, '(modified)'); + } else if (state === 'customized') { + assert.equal(text, '(customized)'); + } + // there should be option to delete custom options + if (state === 'empty' || state === 'emptyNotSaved') { + assert.isFalse(await driver.find('.test-section-menu-btn-remove-options').isPresent()); + } else { + assert.isTrue(await driver.find('.test-section-menu-btn-remove-options').isPresent()); + } + await driver.sendKeys(Key.ESCAPE); +} + +describe('CustomWidgetsConfig', function () { + this.timeout(30000); // almost 20 second on dev machine. + const cleanup = setupTestSuite(); + let mainSession: gu.Session; + gu.bigScreen(); + + before(async function () { + if (server.isExternalServer()) { + this.skip(); + } + // Create simple widget server that serves manifest.json file, some widgets and some error pages. + const widgetServer = await serveSomething(app => { + app.get('/manifest.json', (_, res) => { + res.json([ + { + // Main Custom Widget with onEditOptions handler. + name: TESTER_WIDGET, + url: createConfigUrl({onEditOptions: true}), + widgetId: 'tester1', + }, + { + // Widget without ready options. + name: NORMAL_WIDGET, + url: createConfigUrl(), + widgetId: 'tester2', + }, + { + // Widget requesting read access. + name: READ_WIDGET, + url: createConfigUrl({requiredAccess: AccessLevel.read_table}), + widgetId: 'tester3', + }, + { + // Widget requesting full access. + name: FULL_WIDGET, + url: createConfigUrl({requiredAccess: AccessLevel.full}), + widgetId: 'tester4', + }, + { + // Widget with column mapping + name: COLUMN_WIDGET, + url: createConfigUrl({requiredAccess: AccessLevel.read_table, columns: ['Column']}), + widgetId: 'tester5', + }, + ]); + }); + addStatic(app); + }); + cleanup.addAfterAll(widgetServer.shutdown); + widgetServerUrl = widgetServer.url; + await server.testingHooks.setWidgetRepositoryUrl(`${widgetServerUrl}${manifestEndpoint}`); + + mainSession = await gu.session().login(); + const doc = await mainSession.tempDoc(cleanup, 'CustomWidget.grist'); + docId = doc.id; + // Make sure widgets are enabled. + await driver.executeScript('window.gristConfig.enableWidgetRepository = true;'); + await gu.toggleSidePanel('right', 'open'); + await gu.selectSectionByTitle('Widget'); + }); + + // Poor man widget rpc. Class that invokes various parts in the tester widget. + class Widget { + constructor(public frameSelector = 'iframe') {} + // Wait for a frame. + public async waitForFrame() { + await driver.wait(() => driver.find(this.frameSelector).isPresent(), 1000); + const iframe = driver.find(this.frameSelector); + await driver.switchTo().frame(iframe); + await driver.wait(async () => (await driver.find('#ready').getText()) === 'ready', 1000); + await driver.switchTo().defaultContent(); + } + public async content() { + return await this._read('body'); + } + public async readonly() { + const text = await this._read('#readonly'); + return text === 'true'; + } + public async access() { + const text = await this._read('#access'); + return text as AccessLevel; + } + public async onRecordMappings() { + const text = await this._read('#onRecordMappings'); + return JSON.parse(text || 'null'); + } + public async onRecords() { + const text = await this._read('#onRecords'); + return JSON.parse(text || 'null'); + } + public async onRecordsMappings() { + const text = await this._read('#onRecordsMappings'); + return JSON.parse(text || 'null'); + } + // Wait for frame to close. + public async waitForClose() { + await driver.wait(async () => !(await driver.find(this.frameSelector).isPresent()), 1000); + } + // Wait for the onOptions event, and return its value. + public async onOptions() { + const iframe = driver.find(this.frameSelector); + await driver.switchTo().frame(iframe); + // Wait for options to get filled, initially this div is empty, + // as first message it should get at least null as an options. + await driver.wait(async () => await driver.find('#onOptions').getText(), 1000); + const text = await driver.find('#onOptions').getText(); + await driver.switchTo().defaultContent(); + return JSON.parse(text); + } + public async wasConfigureCalled() { + const text = await this._read('#configure'); + return text === 'called'; + } + public async setOptions(options: any) { + return await this.invokeOnWidget('setOptions', [options]); + } + public async setOption(key: string, value: any) { + return await this.invokeOnWidget('setOption', [key, value]); + } + public async getOption(key: string) { + return await this.invokeOnWidget('getOption', [key]); + } + public async clearOptions() { + return await this.invokeOnWidget('clearOptions'); + } + public async getOptions() { + return await this.invokeOnWidget('getOptions'); + } + public async mappings() { + return await this.invokeOnWidget('mappings'); + } + // Invoke method on a Custom Widget. + // Each method is available as a button with content that is equal to the method name. + // It accepts single argument, that we pass by serializing it to #input textbox. Widget invokes + // the method and serializes its return value to #output div. When there is an error, it is also + // serialized to the #output div. + public async invokeOnWidget(name: string, input?: any[]) { + // Switch to frame. + const iframe = driver.find(this.frameSelector); + await driver.switchTo().frame(iframe); + // Clear input box that holds arguments. + await driver.find('#input').click(); + await gu.clearInput(); + // Serialize argument to the textbox (or leave empty). + if (input !== undefined) { + await driver.sendKeys(JSON.stringify(input)); + } + // Find button that is responsible for invoking method. + await driver.findContent('button', gu.exactMatch(name)).click(); + // Wait for the #output div to be filled with a result. Custom Widget will set it to + // "waiting..." before invoking the method. + await driver.wait(async () => (await driver.find('#output').value()) !== 'waiting...'); + // Read the result. + const text = await driver.find('#output').getText(); + // Switch back to main window. + await driver.switchTo().defaultContent(); + // If the method was a void method, the output will be "undefined". + if (text === 'undefined') { + return; // Simulate void method. + } + // Result will always be parsed json. + const parsed = JSON.parse(text); + // All exceptions will be serialized to { error : <> } + if (parsed?.error) { + // Rethrow the error. + throw new Error(parsed.error); + } else { + // Or return result. + return parsed; + } + } + + private async _read(selector: string) { + const iframe = driver.find(this.frameSelector); + await driver.switchTo().frame(iframe); + const text = await driver.find(selector).getText(); + await driver.switchTo().defaultContent(); + return text; + } + } + // Rpc for main widget (Custom Widget). + const widget = new Widget(); + + beforeEach(async () => { + // Before each test, we will switch to Custom Url (to cleanup the widget) + // and then back to the Tester widget. + if ((await currentWidget()) !== CUSTOM_URL) { + await toggleWidgetMenu(); + await clickOption(CUSTOM_URL); + } + await toggleWidgetMenu(); + await clickOption(TESTER_WIDGET); + }); + + it('should render columns mapping', async () => { + const revert = await gu.begin(); + assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); + await toggleWidgetMenu(); + // Select widget that has single column configuration. + await clickOption(COLUMN_WIDGET); + await widget.waitForFrame(); + await accept(); + // Visible columns section should be hidden. + assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); + // Record event should be fired. + assert.deepEqual(await widget.onRecords(), [ + {id: 1, A: 'A' }, + {id: 2, A: 'B' }, + {id: 3, A: 'C' }, + ]); + // Mappings should null at first. + assert.isNull(await widget.onRecordsMappings()); + // We should see a single Column picker. + assert.isTrue(await driver.find('.test-config-widget-label-for-Column').isPresent()); + // With single column to map. + await toggleDrop(pickerDrop('Column')); + assert.deepEqual(await getOptions(), ['A']); + await clickOption('A'); + await gu.waitForServer(); + // Widget should receive mappings + assert.deepEqual(await widget.onRecordsMappings(), {Column: 'A'}); + await revert(); + }); + + it('should render multiple mappings', async () => { + const revert = await gu.begin(); + await toggleWidgetMenu(); + await clickOption(CUSTOM_URL); + // This is not standard way of creating widgets. The widgets in this test is reading this parameter + // and is using it to invoke the ready method. + await gu.setWidgetUrl( + createConfigUrl({ + columns: ['M1', {name: 'M2', optional: true}, {name: 'M3', title: 'T3'}, {name: 'M4', type: 'Text'}], + requiredAccess: 'read table', + }) + ); + await accept(); + const empty = {M1: null, M2: null, M3: null, M4: null}; + await widget.waitForFrame(); + assert.isNull(await widget.onRecordsMappings()); + // We should see 4 pickers + assert.isTrue(await driver.find(pickerLabel('M1')).isPresent()); + assert.isTrue(await driver.find(pickerLabel('M2')).isPresent()); + assert.isTrue(await driver.find(pickerLabel('M3')).isPresent()); + assert.isTrue(await driver.find(pickerLabel('M4')).isPresent()); + assert.equal(await driver.find(pickerLabel('M1')).getText(), 'M1'); + assert.equal(await driver.find(pickerLabel('M2')).getText(), 'M2 (optional)'); + // Label for picker M3 should have alternative text; + assert.equal(await driver.find(pickerLabel('M3')).getText(), 'T3'); + assert.equal(await driver.find(pickerLabel('M4')).getText(), 'M4'); + // All picker should show "Pick a column" except M4, which should say "Pick a text column" + assert.equal(await driver.find(pickerDrop('M1')).getText(), 'Pick a column'); + assert.equal(await driver.find(pickerDrop('M2')).getText(), 'Pick a column'); + assert.equal(await driver.find(pickerDrop('M3')).getText(), 'Pick a column'); + assert.equal(await driver.find(pickerDrop('M4')).getText(), 'Pick a text column'); + // Mappings should be empty + assert.isNull(await widget.onRecordsMappings()); + // Should be able to select column A for all options + await toggleDrop(pickerDrop('M1')); + await clickOption('A'); + await gu.waitForServer(); + assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A'}); + await toggleDrop(pickerDrop('M2')); + await clickOption('A'); + await gu.waitForServer(); + assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A', M2: 'A'}); + await toggleDrop(pickerDrop('M3')); + await clickOption('A'); + await gu.waitForServer(); + assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A', M2: 'A', M3: 'A'}); + await toggleDrop(pickerDrop('M4')); + await clickOption('A'); + await gu.waitForServer(); + assert.deepEqual(await widget.onRecordsMappings(), {M1: 'A', M2: 'A', M3: 'A', M4: 'A'}); + // Single record should also receive update. + assert.deepEqual(await widget.onRecordMappings(), {M1: 'A', M2: 'A', M3: 'A', M4: 'A'}); + // Undo should revert mappings - there should be only 3 operations to revert to first mapping. + await gu.undo(3); + assert.deepEqual(await widget.onRecordsMappings(), {... empty, M1: 'A'}); + // Add another columns, numeric B and any C. + await gu.selectSectionByTitle('Table'); + await gu.addColumn('B'); + await gu.getCell('B', 1).click(); + await gu.enterCell('99'); + await gu.addColumn('C'); + await gu.selectSectionByTitle('Widget'); + // Column M1 should be mappable to all 3, column M4 only to A and C + await toggleDrop(pickerDrop('M1')); + assert.deepEqual(await getOptions(), ['A', 'B', 'C']); + await toggleDrop(pickerDrop('M4')); + assert.deepEqual(await getOptions(), ['A', 'C']); + await toggleDrop(pickerDrop('M1')); + await clickOption('B'); + assert.deepEqual(await widget.onRecordsMappings(), {...empty, M1: 'B'}); + await revert(); + }); + + it('should clear mappings on widget switch', async () => { + const revert = await gu.begin(); + + await toggleWidgetMenu(); + await clickOption(COLUMN_WIDGET); + await accept(); + + // Make sure columns are there to pick. + + // Visible column section is hidden. + assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); + // We should see a single Column picker. + assert.isTrue(await driver.find('.test-config-widget-label-for-Column').isPresent()); + + // Pick first column + await toggleDrop(pickerDrop('Column')); + await clickOption('A'); + await gu.waitForServer(); + + // Now change to a widget without columns + await toggleWidgetMenu(); + await clickOption(NORMAL_WIDGET); + + // Picker should disappear and column mappings should be visible + assert.isTrue(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); + assert.isFalse(await driver.find('.test-config-widget-label-for-Column').isPresent()); + + await selectAccess(AccessLevel.read_table); + // Widget should receive full records. + assert.deepEqual(await widget.onRecords(), [ + {id: 1, A: 'A'}, + {id: 2, A: 'B'}, + {id: 3, A: 'C'}, + ]); + // Now go back to the widget with mappings. + await toggleWidgetMenu(); + await clickOption(COLUMN_WIDGET); + await accept(); + assert.equal(await driver.find(pickerDrop('Column')).getText(), 'Pick a column'); + assert.isFalse(await driver.find('.test-vfc-visible-fields-select-all').isPresent()); + assert.isTrue(await driver.find('.test-config-widget-label-for-Column').isPresent()); + await revert(); + }); + + it('should render multiple options', async () => { + const revert = await gu.begin(); + await toggleWidgetMenu(); + await clickOption(CUSTOM_URL); + await gu.setWidgetUrl( + createConfigUrl({ + columns: [ + {name: 'M1', allowMultiple: true}, + {name: 'M2', type: 'Text', allowMultiple: true}, + ], + requiredAccess: 'read table', + }) + ); + await accept(); + const empty = {M1: [], M2: []}; + await widget.waitForFrame(); + // Add some columns, numeric B and any C. + await gu.selectSectionByTitle('Table'); + await gu.addColumn('B'); + await gu.getCell('B', 1).click(); + await gu.enterCell('99'); + await gu.addColumn('C'); + await gu.selectSectionByTitle('Widget'); + // Make sure we have no mappings + assert.deepEqual(await widget.onRecordsMappings(), null); + // Map all columns to M1 + await click(pickerAdd('M1')); + assert.deepEqual(await getMenuOptions(), ['A', 'B', 'C']); + await clickMenuItem('A'); + await click(pickerAdd('M1')); + await clickMenuItem('B'); + await click(pickerAdd('M1')); + await clickMenuItem('C'); + assert.deepEqual(await widget.onRecordsMappings(), {...empty, M1: ['A', 'B', 'C']}); + // Map A and C to M2 + await click(pickerAdd('M2')); + assert.deepEqual(await getMenuOptions(), ['A', 'C']); + // There should be information that column B is hidden (as it is not text) + assert.equal(await driver.find('.test-config-widget-map-message-M2').getText(), '1 non-text column is not shown'); + await clickMenuItem('A'); + await click(pickerAdd('M2')); + await clickMenuItem('C'); + assert.deepEqual(await widget.onRecordsMappings(), {M1: ['A', 'B', 'C'], M2: ['A', 'C']}); + function dragItem(column: string, item: string) { + return driver.findContent(`.test-config-widget-map-list-for-${column} .kf_draggable`, item); + } + // Should support reordering, reorder - move A after C + await driver.withActions(actions => + actions + .move({origin: dragItem('M1', 'A')}) + .move({origin: dragItem('M1', 'A').find('.test-dragger')}) + .press() + .move({origin: dragItem('M1', 'C'), y: 1}) + .release() + ); + await gu.waitForServer(); + assert.deepEqual(await widget.onRecordsMappings(), {M1: ['B', 'C', 'A'], M2: ['A', 'C']}); + // Should support removing + const removeButton = (column: string, item: string) => { + return dragItem(column, item).mouseMove().find('.test-config-widget-ref-select-remove'); + }; + await removeButton('M1', 'B').click(); + await gu.waitForServer(); + assert.deepEqual(await widget.onRecordsMappings(), {M1: ['C', 'A'], M2: ['A', 'C']}); + // Should undo removing + await gu.undo(); + assert.deepEqual(await widget.onRecordsMappings(), {M1: ['B', 'C', 'A'], M2: ['A', 'C']}); + await removeButton('M1', 'B').click(); + await gu.waitForServer(); + await removeButton('M1', 'C').click(); + await gu.waitForServer(); + await removeButton('M2', 'C').click(); + await gu.waitForServer(); + assert.deepEqual(await widget.onRecordsMappings(), {M1: ['A'], M2: ['A']}); + await revert(); + }); + + it('should remove mapping when column is deleted', async () => { + const revert = await gu.begin(); + await toggleWidgetMenu(); + // Prepare mappings for single and multiple columns + await clickOption(CUSTOM_URL); + await gu.setWidgetUrl( + createConfigUrl({ + columns: [{name: 'M1'}, {name: 'M2', allowMultiple: true}], + requiredAccess: 'read table', + }) + ); + await accept(); + await widget.waitForFrame(); + // Add some columns, to remove later + await gu.selectSectionByTitle('Table'); + await gu.addColumn('B'); + await gu.addColumn('C'); + await gu.selectSectionByTitle('Widget'); + // Make sure we have no mappings + assert.deepEqual(await widget.onRecordsMappings(), null); + // Map B to M1 + await toggleDrop(pickerDrop('M1')); + await clickOption('B'); + // Map all columns to M2 + for (const col of ['A', 'B', 'C']) { + await click(pickerAdd('M2')); + await clickMenuItem(col); + } + assert.deepEqual(await widget.onRecordsMappings(), {M1: 'B', M2: ['A', 'B', 'C']}); + assert.deepEqual(await widget.onRecords(), [ + {id: 1, B: null, A: 'A', C: null}, + {id: 2, B: null, A: 'B', C: null}, + {id: 3, B: null, A: 'C', C: null}, + ]); + const removeColumn = async (col: string) => { + await gu.selectSectionByTitle('Table'); + await gu.openColumnMenu(col, 'Delete column'); + await gu.waitForServer(); + await gu.selectSectionByTitle('Widget'); + }; + // Remove B column + await removeColumn('B'); + // Mappings should be updated + assert.deepEqual(await widget.onRecordsMappings(), {M1: null, M2: ['A', 'C']}); + // Records should not have B column + assert.deepEqual(await widget.onRecords(), [ + {id: 1, A: 'A', C: null}, + {id: 2, A: 'B', C: null}, + {id: 3, A: 'C', C: null}, + ]); + // Should be able to add B once more + + // Add B as a new column + await gu.selectSectionByTitle('Table'); + await gu.addColumn('B'); + await gu.selectSectionByTitle('Widget'); + // Adding the same column should not add it to mappings or records (as this is a new Id) + assert.deepEqual(await widget.onRecordsMappings(), {M1: null, M2: ['A', 'C']}); + assert.deepEqual(await widget.onRecords(), [ + {id: 1, A: 'A', C: null}, + {id: 2, A: 'B', C: null}, + {id: 3, A: 'C', C: null}, + ]); + + // Add B column as a new one. + await toggleDrop(pickerDrop('M1')); + // Make sure it is there to select. + assert.deepEqual(await getOptions(), ['A', 'C', 'B']); + await clickOption('B'); + await click(pickerAdd('M2')); + assert.deepEqual(await getMenuOptions(), ['B']); // multiple selection will only show not selected columns + await clickMenuItem('B'); + assert.deepEqual(await widget.onRecordsMappings(), {M1: 'B', M2: ['A', 'C', 'B']}); + assert.deepEqual(await widget.onRecords(), [ + {id: 1, B: null, A: 'A', C: null}, + {id: 2, B: null, A: 'B', C: null}, + {id: 3, B: null, A: 'C', C: null}, + ]); + await revert(); + }); + + it('should remove mapping when column type is changed', async () => { + const revert = await gu.begin(); + await toggleWidgetMenu(); + // Prepare mappings for single and multiple columns + await clickOption(CUSTOM_URL); + await gu.setWidgetUrl( + createConfigUrl({ + columns: [{name: 'M1', type: 'Text'}, {name: 'M2', type: 'Text', allowMultiple: true}], + requiredAccess: 'read table', + }) + ); + await accept(); + await widget.waitForFrame(); + assert.deepEqual(await widget.onRecordsMappings(), null); + assert.deepEqual(await widget.onRecords(), [ + {id: 1, A: 'A'}, + {id: 2, A: 'B'}, + {id: 3, A: 'C'}, + ]); + await toggleDrop(pickerDrop("M1")); + await clickOption("A"); + await click(pickerAdd("M2")); + await clickMenuItem("A"); + assert.equal(await driver.find(pickerDrop("M1")).getText(), "A"); + assert.deepEqual(await getListItems("M2"), ["A"]); + assert.deepEqual(await widget.onRecordsMappings(), {M1: 'A', M2: ["A"]}); + assert.deepEqual(await widget.onRecords(), [ + {id: 1, A: 'A'}, + {id: 2, A: 'B'}, + {id: 3, A: 'C'}, + ]); + // Change column type to numeric + await gu.selectSectionByTitle('Table'); + await gu.getCell("A", 1).click(); + await gu.setType(/Numeric/); + await gu.selectSectionByTitle('Widget'); + await driver.find(".test-right-tab-pagewidget").click(); + // Drop should be empty, + assert.equal(await driver.find(pickerDrop("M1")).getText(), "Pick a text column"); + assert.isEmpty(await getListItems("M2")); + // with no options + await toggleDrop(pickerDrop("M1")); + assert.isEmpty(await getOptions()); + await gu.sendKeys(Key.ESCAPE); + // The same for M2 + await click(pickerAdd("M2")); + assert.isEmpty(await getMenuOptions()); + assert.deepEqual(await widget.onRecordsMappings(), {M1: null, M2: []}); + assert.deepEqual(await widget.onRecords(), [ + {id: 1}, + {id: 2}, + {id: 3}, + ]); + await revert(); + }); + + it('should not display options on grid, card, card list, chart', async () => { + // Add Empty Grid + await gu.addNewSection(/Table/, /Table1/); + assert.isFalse(await hasSectionOption()); + await gu.undo(); + + // Add Card view + await gu.addNewSection(/Card/, /Table1/); + assert.isFalse(await hasSectionOption()); + await gu.undo(); + + // Add Card List view + await gu.addNewSection(/Card List/, /Table1/); + assert.isFalse(await hasSectionOption()); + await gu.undo(); + + // Add Card List view + await gu.addNewSection(/Chart/, /Table1/); + assert.isFalse(await hasSectionOption()); + await gu.undo(); + + // Add Custom - no section option by default + await gu.addNewSection(/Custom/, /Table1/); + assert.isFalse(await hasSectionOption()); + await toggleWidgetMenu(); + await clickOption(TESTER_WIDGET); + assert.isTrue(await hasSectionOption()); + await gu.undo(2); + }); + + it('should indicate current state', async () => { + // Save button is available under Filter/Sort menu. + // For this custom widget it has four states: + // - Empty: no options are saved + // - Modified: options were set but are not saved yet + // - Customized: options are saved + // - Empty not saved: options are cleared but not saved + // This test test all the available transitions between those four states + + const options = {test: 1} as const; + const options2 = {test: 2} as const; + // From the start we should be in empty state + await checkSortMenu('empty'); + // Make modification + await widget.setOptions(options); + // State should be modified + await checkSortMenu('modified'); + assert.deepEqual(await widget.onOptions(), options); + // Revert, should end up with empty state. + await revertMenu(); + await checkSortMenu('empty'); + assert.equal(await widget.onOptions(), null); + + // Update once again and save. + await widget.setOptions(options); + await saveMenu(); + await checkSortMenu('customized'); + // Now test if undo works. + await gu.undo(); + await checkSortMenu('empty'); + assert.equal(await widget.onOptions(), null); + + // Update once again and save. + await widget.setOptions(options); + await saveMenu(); + // Modify and check the state - should be modified + await widget.setOptions(options2); + await checkSortMenu('modified'); + assert.deepEqual(await widget.onOptions(), options2); + await saveMenu(); + + // Now clear options. + await clearOptions(); + await checkSortMenu('emptyNotSaved'); + assert.equal(await widget.onOptions(), null); + // And revert + await revertMenu(); + await checkSortMenu('customized'); + assert.deepEqual(await widget.onOptions(), options2); + // Clear once again and save. + await clearOptions(); + await saveMenu(); + assert.equal(await widget.onOptions(), null); + await checkSortMenu('empty'); + // And check if undo goes to customized + await gu.undo(); + await checkSortMenu('customized'); + assert.deepEqual(await widget.onOptions(), options2); + }); + + for (const access of ['none', 'read table', 'full'] as const) { + describe(`with ${access} access`, function () { + before(function () { + if (server.isExternalServer()) { + this.skip(); + } + }); + it(`should get null options`, async () => { + await selectAccess(access); + await widget.waitForFrame(); + assert.equal(await widget.onOptions(), null); + assert.equal(await widget.access(), access); + assert.isFalse(await widget.readonly()); + }); + + it(`should save config options and inform about it the main widget`, async () => { + await selectAccess(access); + await widget.waitForFrame(); + // Save config and check if normal widget received new configuration + const config = {key: 1} as const; + // save options through config, + await widget.setOptions(config); + // make sure custom widget got options, + assert.deepEqual(await widget.onOptions(), config); + await persistOptions(); + // and make sure it will get it once again, + await refresh(); + assert.deepEqual(await widget.onOptions(), config); + // and can read it on demand + assert.deepEqual(await widget.getOptions(), config); + }); + + it(`should save and read options`, async () => { + await selectAccess(access); + await widget.waitForFrame(); + // Make sure get options returns null. + assert.equal(await widget.getOptions(), null); + // Invoke setOptions, should return undefined (no error). + assert.equal(await widget.setOptions({key: 'any'}), null); + // Once again get options, and see if it was saved. + assert.deepEqual(await widget.getOptions(), {key: 'any'}); + await widget.clearOptions(); + }); + + it(`should save and read options by keys`, async () => { + await selectAccess(access); + await widget.waitForFrame(); + // Should support key operations + const set = async (key: string, value: any) => { + assert.equal(await widget.setOption(key, value), undefined); + assert.deepEqual(await widget.getOption(key), value); + }; + await set('one', 1); + await set('two', 2); + assert.deepEqual(await widget.getOptions(), {one: 1, two: 2}); + const json = {n: null, json: {value: [1, {val: 'a', bool: true}]}}; + await set('json', json); + assert.equal(await widget.clearOptions(), undefined); + assert.equal(await widget.getOptions(), null); + await set('one', 1); + assert.equal(await widget.setOptions({key: 'any'}), undefined); + assert.deepEqual(await widget.getOptions(), {key: 'any'}); + await widget.clearOptions(); + }); + + it(`should call configure method`, async () => { + await selectAccess(access); + await widget.waitForFrame(); + // Make sure configure wasn't called yet. + assert.isFalse(await widget.wasConfigureCalled()); + // Open configuration through the creator panel + await driver.find('.test-config-widget-open-configuration').click(); + assert.isTrue(await widget.wasConfigureCalled()); + + // Refresh, and call through the menu. + await refresh(); + await gu.waitForDocToLoad(); + await widget.waitForFrame(); + // Make sure configure wasn't called yet. + assert.isFalse(await widget.wasConfigureCalled()); + // Click through the menu. + const menu = await gu.openSectionMenu('viewLayout', 'Widget'); + await menu.find('.test-section-open-configuration').click(); + assert.isTrue(await widget.wasConfigureCalled()); + }); + }); + } + + it('should show options action button', async () => { + // Select widget without options + await toggleWidgetMenu(); + await clickOption(NORMAL_WIDGET); + assert.isFalse(await hasSectionOption()); + // Select widget with options + await toggleWidgetMenu(); + await clickOption(TESTER_WIDGET); + assert.isTrue(await hasSectionOption()); + // Select widget without options + await toggleWidgetMenu(); + await clickOption(NORMAL_WIDGET); + assert.isFalse(await hasSectionOption()); + }); + + it('should prompt user for correct access level', async () => { + // Select widget without request + await toggleWidgetMenu(); + await clickOption(NORMAL_WIDGET); + assert.isFalse(await hasPrompt()); + assert.equal(await givenAccess(), AccessLevel.none); + assert.equal(await widget.access(), AccessLevel.none); + // Select widget that requests read access. + await toggleWidgetMenu(); + await clickOption(READ_WIDGET); + assert.isTrue(await hasPrompt()); + assert.equal(await givenAccess(), AccessLevel.none); + assert.equal(await widget.access(), AccessLevel.none); + await accept(); + assert.equal(await givenAccess(), AccessLevel.read_table); + assert.equal(await widget.access(), AccessLevel.read_table); + // Select widget that requests full access. + await toggleWidgetMenu(); + await clickOption(FULL_WIDGET); + assert.isTrue(await hasPrompt()); + assert.equal(await givenAccess(), AccessLevel.none); + assert.equal(await widget.access(), AccessLevel.none); + await accept(); + assert.equal(await givenAccess(), AccessLevel.full); + assert.equal(await widget.access(), AccessLevel.full); + await gu.undo(5); + }); + + it('should pass readonly mode to custom widget', async () => { + const api = mainSession.createHomeApi(); + await api.updateDocPermissions(docId, {users: {'support@getgrist.com': 'viewers'}}); + + const viewer = await gu.session().user('support').login(); + await viewer.loadDoc(`/doc/${docId}`); + + // Make sure that widget knows about readonly mode. + assert.isTrue(await widget.readonly()); + + // Log back + await mainSession.login(); + await mainSession.loadDoc(`/doc/${docId}`); + await refresh(); + }); +}); diff --git a/test/nbrowser/customUtil.ts b/test/nbrowser/customUtil.ts new file mode 100644 index 00000000..becdf15a --- /dev/null +++ b/test/nbrowser/customUtil.ts @@ -0,0 +1,12 @@ +export * from 'test/server/customUtil'; +import {driver} from "mocha-webdriver"; + +export async function setAccess(option: "none"|"read table"|"full") { + const text = { + "none" : "No document access", + "read table": "Read selected table", + "full": "Full document access" + }; + await driver.find(`.test-config-widget-access .test-select-open`).click(); + await driver.findContent(`.test-select-menu li`, text[option]).click(); +} diff --git a/yarn.lock b/yarn.lock index e26c0019..76e0b4a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1859,7 +1859,7 @@ commander@9.3.0: resolved "https://registry.yarnpkg.com/commander/-/commander-9.3.0.tgz#f619114a5a2d2054e0d9ff1b31d5ccf89255e26b" integrity sha512-hv95iU5uXPbK83mjrJKuZyFM/LBAoCV/XhVGkS5Je6tl7sxr6A0ITMw5WoRV46/UaJ46Nllm3Xt7IaJhXTIkzw== -commander@^2.11.0, commander@^2.20.0: +commander@^2.11.0, commander@^2.12.2, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -3016,7 +3016,7 @@ fs-extra@7.0.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^4.0.1, fs-extra@^4.0.2: +fs-extra@^4.0.1, fs-extra@^4.0.2, fs-extra@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg== @@ -6736,6 +6736,16 @@ tr46@^1.0.1: resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk= +ts-interface-builder@0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/ts-interface-builder/-/ts-interface-builder-0.3.2.tgz#664f7f4d2bd0079950ba6bb7cd2780262009a68f" + integrity sha512-8LcB+qSwnDzBeP47Nug2+4NUjdRNJ94MfzLNXQ4mmAM8UidDDQS0YoD7Ng6XONa8rX6nJenlgph1X459VYqypQ== + dependencies: + commander "^2.12.2" + fs-extra "^4.0.3" + glob "^7.1.6" + typescript "^3.0.0" + ts-interface-checker@1.0.2, ts-interface-checker@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-1.0.2.tgz#63f73a098b0ed34b982df1f490c54890e8e5e0b3" @@ -6838,6 +6848,11 @@ typescript@4.7.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== +typescript@^3.0.0: + version "3.9.10" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" + integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== + uglify-js@^3.1.4: version "3.16.3" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.16.3.tgz#94c7a63337ee31227a18d03b8a3041c210fd1f1d"