(core) bump mocha version to allow parallel tests; move more tests to core

Summary:
This uses a newer version of mocha in grist-core so that tests can be run in parallel. That allows more tests to be moved without slowing things down overall. Tests moved are venerable browser tests; only the ones that "just work" or worked without too much trouble to are moved, in order to keep the diff from growing too large. Will wrestle with more in follow up.

Parallelism is at the file level, rather than the individual test.

The newer version of mocha isn't needed for grist-saas repo; tests are parallelized in our internal CI by other means. I've chosen to allocate files to workers in a cruder way than our internal CI, based on initial characters rather than an automated process. The automated process would need some reworking to be compatible with mocha running in parallel mode.

Test Plan: this diff was tested first on grist-core, then ported to grist-saas so saas repo history will correctly track history of moved files.

Reviewers: jarek

Reviewed By: jarek

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3927
pull/550/head
Paul Fitzpatrick 10 months ago
parent 7d3b4b49d5
commit bcbf57d590

@ -4,6 +4,7 @@ module.exports = {
env: {
node: true,
es6: true,
mocha: true,
},
// Set parser to support, e.g. import() function for dynamic imports (see
// https://stackoverflow.com/a/47833471/328565 and https://stackoverflow.com/a/69557309/328565).

@ -13,6 +13,9 @@ jobs:
build_and_test:
runs-on: ubuntu-latest
strategy:
# it is helpful to know which sets of tests would have succeeded,
# even when there is a failure.
fail-fast: false
matrix:
python-version: [3.9]
node-version: [14.x]
@ -20,11 +23,11 @@ jobs:
- ':lint:python:client:common:smoke:'
- ':server-1-of-2:'
- ':server-2-of-2:'
- ':nbrowser-1-of-5:'
- ':nbrowser-2-of-5:'
- ':nbrowser-3-of-5:'
- ':nbrowser-4-of-5:'
- ':nbrowser-5-of-5:'
- ':nbrowser-^[A-G]:'
- ':nbrowser-^[H-L]:'
- ':nbrowser-^[M-O]:'
- ':nbrowser-^[P-S]:'
- ':nbrowser-^[^A-S]:'
steps:
- uses: actions/checkout@v3
@ -95,8 +98,8 @@ jobs:
- name: Run main tests without minio and redis
if: contains(matrix.tests, ':nbrowser-')
run: |
export TEST_SPLITS=$(echo $TESTS | sed "s/.*:nbrowser-\([^:]*\).*/\1/")
MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser
export GREP_TESTS=$(echo $TESTS | sed "s/.*:nbrowser-\([^:]*\).*/\1/")
MOCHA_WEBDRIVER_SKIP_CLEANUP=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser --parallel --jobs 3
env:
TESTS: ${{ matrix.tests }}

@ -15,6 +15,7 @@ module.exports = {
account: "app/client/accountMain",
billing: "app/client/billingMain",
activation: "app/client/activationMain",
test: "test/client-harness/client",
},
output: {
filename: "[name].bundle.js",
@ -78,4 +79,10 @@ module.exports = {
// To strip all locales except “en”
new MomentLocalesPlugin()
],
externals: {
// for test bundle: jsdom should not be touched within browser
jsdom: 'alert',
// for test bundle: jquery will be available as jQuery
jquery: 'jQuery'
},
};

@ -12,12 +12,12 @@
"install:python3": "buildtools/prepare_python3.sh",
"build:prod": "buildtools/build.sh",
"start:prod": "sandbox/run.sh",
"test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+-b --no-exit} --slow 8000 ${DEBUG:---forbid-only} -g ${GREP_TESTS:-''} '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
"test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+-b --no-exit} ${DEBUG:---forbid-only} -g ${GREP_TESTS:-''} --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'",
"test:client": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'",
"test:common": "GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'",
"test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie NODE_PATH=_build:_build/stubs:_build/ext mocha ${DEBUG:+'-b'} -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
"test:smoke": "NODE_PATH=_build:_build/stubs:_build/ext mocha _build/test/nbrowser/Smoke.js",
"test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} --slow 8000 ${DEBUG:---forbid-only} -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
"test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} ${DEBUG:---forbid-only} -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'",
"test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'",
"test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'",
"test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'",
"test:smoke": "mocha _build/test/nbrowser/Smoke.js",
"test:docker": "./test/test_under_docker.sh",
"test:python": "sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \"test*${GREP_TESTS}*.py\"}",
"cli": "NODE_PATH=_build:_build/stubs:_build/ext node _build/app/server/companion.js",
@ -91,8 +91,8 @@
"http-proxy": "1.18.1",
"i18next-scanner": "4.1.0",
"jsdom": "16.5.0",
"mocha": "5.2.0",
"mocha-webdriver": "0.2.9",
"mocha": "10.2.0",
"mocha-webdriver": "0.2.13",
"moment-locales-webpack-plugin": "^1.2.0",
"nodemon": "^2.0.4",
"otplib": "12.0.1",
@ -187,5 +187,13 @@
"jquery": "3.5.0",
"ts-interface-checker": "1.0.2",
"@gristlabs/sqlite3": "5.1.4-grist.8"
},
"mocha": {
"require": ["test/setupPaths",
"source-map-support/register",
"test/report-why-tests-hang",
"test/init-mocha-webdriver",
"test/split-tests",
"test/chai-as-promised"]
}
}

@ -0,0 +1 @@
../node_modules/mocha/mocha.css

@ -0,0 +1 @@
../node_modules/mocha/mocha.js

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<base href="/v/gtag/">
<title>Grist Tests</title>
<script src="jquery/dist/jquery.min.js"></script>
<script src="plotly/plotly-latest.min.js"></script>
<script src="./mocha.js"></script>
<script>mocha.setup('bdd')</script>
<script src="./test.bundle.js"></script>
<script>
onload = function() {
$('#app-test').ready(function() {
try {
window.loadTests();
} catch (err) {
console.log("ERROR", err);
mocha.failedTests = [];
mocha.failedTests.push({title: 'Failed to load', error: err.toString()});
document.getElementById('mocha-status').textContent = 'DONE - FAILED TO LOAD';
return;
}
mocha.checkLeaks();
// fxdriver_id is set by selenium, execWebdriverJQuery by webdriverjq.js.
mocha.globals(['cmd', 'fxdriver_id', 'execWebdriverJQuery']);
var runner = mocha.run();
mocha.failedTests = [];
runner.on('fail', function(test, err) {
mocha.failedTests.push({title: test.fullTitle(), error: err.toString()});
});
runner.on('end', function() {
document.getElementById('mocha-status').textContent = runner.failures > 0 ? 'DONE - FAILURE :(' : 'DONE - SUCCESS :)';
});
});
};
function scrollToBottom() {
var bottom = document.getElementById('mocha-end');
bottom.scrollIntoView(true);
}
afterEach(function() {
// keep scrolled to the bottom
return scrollToBottom();
});
after(function() {
// keep scrolled to the bottom
return scrollToBottom();
});
</script>
<style>
#mocha {
width: 50%;
}
#app-test {
position: fixed;
margin: -8px;
width: 40%;
height: 80%;
top: 20%;
left: 60%;
}
#mocha-status {
position: fixed;
bottom: 0px;
padding: 1rem;
border: 2px solid #cc9;
font-family: Helvetica, Arial, sans-serif;
}
/* mostly match #mocha-stats class */
.extra-info {
position: fixed;
top: 60px;
right: 10px;
font-size: 12px;
color: #888;
z-index: 1;
}
</style>
<link rel="stylesheet" href="./mocha.css">
</head>
<body>
<div id="mocha">
<div class="extra-info">
<a href="/test.html?timing=1">Run tests with timings</a>
</div>
</div>
<div id="mocha-end">&nbsp;</div>
<div id="mocha-status">TBD - RUNNING...</div>
</body>
</html>

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<base href="/v/gtag/">
<title>WebdriverJQuery test</title>
<script src="jquery/dist/jquery.min.js"></script>
</head>
<body>
<div class="foo bar">
<span class="baz">
Hello world
</span>
</div>
<div class="bar">
<span class="baz">
Good bye
<input id="btn" type="button" value="Go" onclick="this.value += 'o'">
</span>
</div>
</body>
</html>

@ -110,6 +110,9 @@ export async function main() {
if (process.env.GRIST_TESTING_SOCKET) {
await server.addTestingHooks();
}
if (process.env.GRIST_SERVE_PLUGINS_PORT) {
await server.startCopy('pluginServer', parseInt(process.env.GRIST_SERVE_PLUGINS_PORT, 10));
}
return server;
}

@ -0,0 +1,30 @@
/* global window */
window.loadTests = function() {
require('test/common/BinaryIndexedTree');
require('test/common/CircularArray');
require('test/common/MemBuffer');
require('test/common/arraySplice');
require('test/common/gutil');
require('test/common/marshal');
require('test/common/promises');
require('test/common/serializeTiming');
require('test/common/timeFormat');
require('test/common/ValueFormatter');
require('test/common/InactivityTimer');
require('test/client/clientUtil');
require('test/client/components/Layout');
require('test/client/components/commands');
require('test/client/components/sampleLayout');
require('test/client/lib/ObservableMap');
require('test/client/lib/ObservableSet');
require('test/client/lib/dispose');
require('test/client/lib/dom');
require('test/client/lib/koArray');
require('test/client/lib/koDom');
require('test/client/lib/koForm');
require('test/client/lib/koUtil');
require('test/client/models/modelUtil');
require('test/client/models/rowset');
require('test/client/lib/localStorageObs');
}

@ -16,7 +16,6 @@ function setTmpMochaGlobals() {
return;
}
/* global before, after */
const {JSDOM} = require('jsdom');
var prevGlobals;

@ -1,5 +1,3 @@
/* global describe, beforeEach, afterEach, it */
var assert = require('chai').assert;
var clientUtil = require('../clientUtil');
var dom = require('app/client/lib/dom');

@ -1,5 +1,3 @@
/* global describe, beforeEach, before, after, it */
var _ = require('underscore');
var sinon = require('sinon');
var assert = require('chai').assert;

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('chai').assert;
var sinon = require('sinon');
var Promise = require('bluebird');

@ -1,5 +1,3 @@
/* global describe, it, before */
const assert = require('chai').assert;
const ko = require('knockout');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('chai').assert;
var ko = require('knockout');

@ -1,5 +1,3 @@
/* global describe, it, before, after */
var dispose = require('app/client/lib/dispose');
var bluebird = require('bluebird');
@ -9,6 +7,8 @@ var sinon = require('sinon');
var clientUtil = require('../clientUtil');
var dom = require('app/client/lib/dom');
require('chai').config.truncateThreshold = 10000;
describe('dispose', function() {
clientUtil.setTmpMochaGlobals();
@ -153,9 +153,12 @@ describe('dispose', function() {
assert.equal(baz.dispose.callCount, 1);
assert(baz.dispose.calledBefore(bar.dispose));
assert.deepEqual(consoleErrors[0], ['Error constructing %s:', 'Foo', 'Error: test-error1']);
assert.deepEqual(consoleErrors[1], ['Error constructing %s:', 'Foo', 'Error: test-error2']);
assert.deepEqual(consoleErrors[2], ['Error constructing %s:', 'Foo', 'Error: test-error3']);
const name = consoleErrors[0][1]; // may be Foo, or minified.
assert(name === 'Foo' || name === 'o'); // this may not be reliable,
// just what I happen to see.
assert.deepEqual(consoleErrors[0], ['Error constructing %s:', name, 'Error: test-error1']);
assert.deepEqual(consoleErrors[1], ['Error constructing %s:', name, 'Error: test-error2']);
assert.deepEqual(consoleErrors[2], ['Error constructing %s:', name, 'Error: test-error3']);
assert.equal(consoleErrors.length, 3);
});

@ -1,5 +1,3 @@
/* global describe, it, before, after */
var assert = require('chai').assert;
var sinon = require('sinon');
var Promise = require('bluebird');

@ -1,5 +1,3 @@
/* global describe, it */
var _ = require('underscore');
var assert = require('assert');
var ko = require('knockout');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('assert');
var ko = require('knockout');
var sinon = require('sinon');

@ -4,8 +4,6 @@ const G = require('app/client/lib/browserGlobals').get('window', '$');
const sinon = require('sinon');
const assert = require('assert');
/* global describe, it, after, before, beforeEach */
describe("koDomScrolly", function() {
clientUtil.setTmpMochaGlobals();

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('chai').assert;
var ko = require('knockout');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('assert');
var ko = require('knockout');
var sinon = require('sinon');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('assert');
var ko = require('knockout');

@ -1,5 +1,3 @@
/* global describe, it, beforeEach */
var _ = require('underscore');
var assert = require('chai').assert;
var sinon = require('sinon');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('chai').assert;
var rowuid = require('app/client/models/rowuid');

@ -1,5 +1,3 @@
/* global describe, before, it */
var assert = require('assert');
var BinaryIndexedTree = require('app/common/BinaryIndexedTree');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('assert');
var CircularArray = require('app/common/CircularArray');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('assert');
var MemBuffer = require('app/common/MemBuffer');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('chai').assert;
var RecentItems = require('app/common/RecentItems');

@ -1,5 +1,3 @@
/* global describe, it */
var _ = require('underscore');
var assert = require('chai').assert;
var gutil = require('app/common/gutil');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('chai').assert;
var gutil = require('app/common/gutil');
var _ = require('underscore');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('chai').assert;
var marshal = require('app/common/marshal');
var MemBuffer = require('app/common/MemBuffer');

@ -6,8 +6,6 @@
*/
/* global describe, it, before */
var assert = require('chai').assert;
var bluebird = require('bluebird');

@ -1,5 +1,3 @@
/* global describe, it, before, after */
var _ = require('underscore');
var assert = require('assert');
var Chance = require('chance');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('assert');
var gutil = require('app/common/gutil');
var _ = require('underscore');

@ -1,5 +1,3 @@
/* global describe, it */
var assert = require('assert');
var {timeFormat} = require('app/common/timeFormat');

@ -0,0 +1,8 @@
declare module "test/nbrowser/gristUtil-nbrowser";
// Adds missing type declaration to chai
declare namespace Chai {
interface AssertStatic {
notIncludeMembers<T>(superset: T[], subset: T[], message?: string): void;
}
}

@ -0,0 +1,6 @@
{
"undef": true,
"unused": "vars",
"globalstrict": true,
"esnext": true
}

@ -0,0 +1,31 @@
/* global grist, self */
self.importScripts('/grist-plugin-api.js');
grist.rpc.registerImpl("testApiBrowser", {
getImportSource() {
const api = grist.rpc.getStub('GristDocAPI@grist', grist.checkers.GristDocAPI);
return api.getDocName()
.then((result) => {
const content = JSON.stringify({
tables: [{
table_name: '',
column_metadata: [{
id: 'getDocName',
type: 'Text'
}],
table_data: [[result]]
}]
});
const fileItem = {content, name: "GristDocAPI.jgrist"};
return {
item: { kind: "fileList", files: [fileItem] },
description: "GristDocAPI results"
};
});
}
});
grist.ready();

@ -0,0 +1,12 @@
name: browser-GristDocAPI
version: 0.0.0
description:
components:
safeBrowser: main.js
contributions:
importSources:
- importSource:
component: safeBrowser
name: testApiBrowser
label: Test GristDocAPI

@ -0,0 +1,28 @@
<html>
<body>
<h1 id="hello-bis-title">Hello Bis</h1>
</br>
<!-- numerous lines to produce a scrollable area !-->
0</br></br>
1</br></br>
2</br></br>
3</br></br>
4</br></br>
5</br></br>
6</br></br>
7</br></br>
8</br></br>
9</br></br>
10</br></br>
11</br></br>
12</br></br>
13</br></br>
14</br></br>
15</br></br>
16</br></br>
17</br></br>
18</br></br>
19</br></br>
20</br></br>
</body>
</html>

@ -0,0 +1,28 @@
<html>
<body>
<h1 id="hello-title">Hello</h1>
</br>
<!-- numerous lines to produce a scrollable area !-->
0</br></br>
1</br></br>
2</br></br>
3</br></br>
4</br></br>
5</br></br>
6</br></br>
7</br></br>
8</br></br>
9</br></br>
10</br></br>
11</br></br>
12</br></br>
13</br></br>
14</br></br>
15</br></br>
16</br></br>
17</br></br>
18</br></br>
19</br></br>
20</br></br>
</body>
</html>

@ -0,0 +1,14 @@
/* globals self, grist */
self.importScripts("/grist-plugin-api.js");
class CustomSection {
createSection(renderTarget) {
return grist.api.render('index.html', renderTarget);
}
}
grist.rpc.registerImpl('hello', new CustomSection(), grist.CustomSectionDescription);
grist.ready();

@ -0,0 +1,12 @@
name: helloSection
version: 0.0.0
components:
safeBrowser: main.js
contributions:
customSections:
- path: index.html
name: Hello World
- path: index-bis.html
name: Hello World (bis)
- path: test-subscribe-api.html
name: dataAPI test

@ -0,0 +1,11 @@
<html>
<head>
<script src="/grist-plugin-api.js"></script>
<script src="/jquery/dist/jquery.min.js"></script>
<script src="test-subscribe-api.js"></script>
</head>
<body>
<h1 id="data-api-section">Data API</h1>
<div id="panel"></div>
</body>
</html>

@ -0,0 +1,36 @@
/* global grist, window, $, document */
let tableId = 'Table1';
grist.ready();
grist.api.subscribe(tableId);
window.onload = () => {
showColumn('A');
};
grist.rpc.on("message", (msg) => {
if (msg.type === "docAction") {
// There could by many doc actions and fetching table is expensive, in practice this call would
// be be throttle
if (msg.action[0] === 'RenameTable') {
tableId = msg.action[2];
}
showColumn('A');
}
});
// fetch table and call the view with values of coldId
function showColumn(colId) {
grist.docApi.fetchTable(tableId).then(cols => updateView(cols[colId]));
}
// show the first column
function updateView(values) {
$("#panel").empty();
const res = $('<div class="result"></div>');
const text = document.createTextNode(JSON.stringify(values));
res.append(text);
$("#panel").append(res);
}

@ -0,0 +1,22 @@
<html>
<head>
<script src="/grist-plugin-api.js"></script>
<script src="script.js"></script>
<!-- jquery is required for running browser test (see: `test/browser/webdriverjq.js`) -->
<script src="/jquery/dist/jquery.min.js"></script>
<style type="text/css">
body {
background-color: #ffffffb0;
}
</style>
</head>
<body>
<input id="call-safePython" type="button" value="call safePython">
<input id="call-unsafeNode" type="button" value="call unsafeNode">
<input id="cancel" type="button" value="cancel">
<br/>
"name of the file: "
<input id="name" type="text">
<input id="ok" type="button" value="validate">
</body>
</html>

@ -0,0 +1,9 @@
/* global grist, self */
self.importScripts('/grist-plugin-api.js');
grist.addImporter('dummy', 'index.html', 'fullscreen');
grist.addImporter('dummy-inlined', 'index.html', 'inline');
grist.ready();

@ -0,0 +1,16 @@
name: pluginName
version: 0.0.1
components:
safeBrowser: main.js
safePython: sandbox/main.py
unsafeNode: node/main.js
contributions:
importSources:
- importSource:
component: safeBrowser
name: dummy
label: Dummy importer
- importSource:
component: safeBrowser
name: dummy-inlined
label: Inline Importer

@ -0,0 +1,4 @@
const grist = require('grist-plugin-api');
grist.rpc.registerFunc("func1", (name) => `Yo: ${name}`);
grist.ready();

@ -0,0 +1,11 @@
import sandbox
def greet(val):
return "With love: " + val
def main():
sandbox.register("func1", greet)
sandbox.run()
if __name__ == "__main__":
main()

@ -0,0 +1,44 @@
/* global grist, window, document, $ */
let resolve; // eslint-disable-line no-unused-vars
const importer = {
getImportSource: () => new Promise((_resolve) => {
resolve = _resolve;
})
};
grist.rpc.registerImpl('dummy', importer );
grist.rpc.registerImpl('dummy-inlined', importer );
grist.ready();
window.onload = function() {
callFunctionOnClick('#call-safePython', 'func1@sandbox/main.py', 'Bob');
callFunctionOnClick('#call-unsafeNode', 'func1@node/main.js', 'Alice');
document.querySelector('#cancel').addEventListener('click', () => resolve());
document.querySelector('#ok').addEventListener('click', () => {
const name = $('#name').val();
resolve({
item: {
kind: "fileList",
files: [{content: "A,B\n1,2\n", name}]
},
description: name + " selected!"
});
});
};
function callFunctionOnClick(selector, funcName, ...args) {
document.querySelector(selector).addEventListener('click', () => {
grist.rpc.callRemoteFunc(funcName, ...args)
.then(val => {
const resElement = document.createElement('h1');
resElement.classList.add(`result`);
resElement.textContent = val;
document.body.appendChild(resElement);
});
});
}

@ -0,0 +1,3 @@
version: 0.0.1
contributions:
importSources:

@ -0,0 +1,12 @@
name: crazy-plugin
version: 0.0.1
experimental: true
components:
safePython: sandbox/main.py
contributions:
fileParsers:
- fileExtensions: ["csv"]
parseFile:
component: "safePython"
name: "csv_parser"

@ -0,0 +1,3 @@
version: 0.0.1
contributions:
invalidContibutionPoint:

@ -0,0 +1,14 @@
name: pluginName
version: 0.0.1
components:
safePython: sandbox/main.py
deactivate:
# Let's keep it low for tests to be fast, but big enough for test to be accurate.
inactivitySec: 0.1
contributions:
fileParsers:
- fileExtensions: ["csv"]
parseFile:
component: "safePython"
name: "csv_parser"

@ -0,0 +1,28 @@
import time
import sandbox
# pylint: disable=unused-argument
# pylint: disable=no-member
def import_files(file_source, parse_options):
end = time.time() + 1
while time.time() < end:
pass
return {
"parseOptions": {},
# Make sure the output is a list of GristTables as documented at app/plugin/GristTable.ts
"tables": [{
"table_name": "mytable",
"column_metadata": [],
"table_data": [],
}]
}
def main():
sandbox.register("csv_parser.parseFile", import_files)
sandbox.run() # pylint: disable=no-member
if __name__ == "__main__":
main()

@ -0,0 +1,9 @@
name: missing-components
version: 0.0.1
# missing `components` entry
contributions:
fileParsers:
- fileExtensions: ["csv"]
parseFile:
component: "safePython"
name: "csv_parser"

@ -0,0 +1,10 @@
name: missing-safePython
version: 0.0.1
components:
# missing `safePython` component
contributions:
fileParsers:
- fileExtensions: ["csv"]
parseFile:
component: "safePython"
name: "csv_parser"

@ -0,0 +1,14 @@
name: pluginName
version: 0.0.1
components:
safePython: sandbox/main.py
deactivate:
# Let's keep it low for tests to be fast, but big enough for test to be accurate.
inactivitySec: 0.1
contributions:
fileParsers:
- fileExtensions: ["csv"]
parseFile:
component: "safePython"
name: "csv_parser"

@ -0,0 +1,28 @@
import sandbox
# pylint: disable=unused-argument
# pylint: disable=no-member
# TODO: configure pylint behavior for both `test/fixtures/plugins` and
# `/plugins` folders: either to ignore them completely or to ignore
# above mentioned rules.
def import_files(file_source, parse_options=None):
return {
"parseOptions": {},
"tables": [{
"table_name": "mytable",
"column_metadata": [],
"table_data": []
}]}
def main():
# Todo: Grist should expose a register method accepting arguments as
# follow: register('csv_parser', 'importFiles', can_parse)
sandbox.register("csv_parser.parseFile", import_files)
sandbox.run() # pylint: disable=no-member
if __name__ == "__main__":
main()

@ -0,0 +1,5 @@
const grist = require('grist-plugin-api');
grist.rpc.registerFunc("yo", (name) => `yo ${name}`);
grist.rpc.registerFunc("yoSafePython", (name) => grist.rpc.callRemoteFunc("yo@sandbox/main.py", name));
grist.ready();

@ -0,0 +1,17 @@
name: testPluginFunction
version: 0.0.1
components:
safePython: sandbox/main.py
unsafeNode: backend.js
safeBrowser: main.js
# For the purpose of this unit-test contributions property is actually
# NOT need and only provided for the sake of making this manifest
# valid,
contributions:
importSources:
- importSource:
component: "safeBrowser"
name: index.html
label: My safe importer

@ -0,0 +1,15 @@
import sandbox
def greet(name):
return "Hi " + name
def yo(name):
return "yo " + name + " from safePython"
def main():
sandbox.register("greet", greet)
sandbox.register("yo", yo)
sandbox.run()
if __name__ == "__main__":
main()

@ -0,0 +1,11 @@
name: pluginName
version: 0.0.1
components:
safePython: sandbox/main.py
contributions:
fileParsers:
- fileExtensions: ["csv"]
parseFile:
component: "safePython"
name: "csv_parser"

@ -0,0 +1,29 @@
import sandbox
# pylint: disable=unused-argument
# pylint: disable=no-member
# TODO: configure pylint behavior for both `test/fixtures/plugins` and
# `/plugins` folders: either to ignore them completely or to ignore
# above mentioned rules.
def import_files(file_source, parse_options):
parse_options.update({"NUM_ROWS" : 1})
return {
"parseOptions": parse_options,
"tables": [{
"table_name": "mytable",
"column_metadata": [],
"table_data": []
}]}
def main():
# Todo: Grist should expose a register method accepting arguments as
# follow: register('csv_parser', 'parseFile', can_parse)
sandbox.register("csv_parser.parseFile", import_files)
sandbox.run() # pylint: disable=no-member
if __name__ == "__main__":
main()

@ -0,0 +1,10 @@
name: pluginName
version: 0.0.1
components:
safeBrowser: '.'
contributions:
importSources:
- importSource:
component: "safeBrowser"
name: index.html
label: My safe importer

@ -0,0 +1,34 @@
const grist = require('grist-plugin-api');
const {foo} = grist.rpc.getStub('foo@grist');
let tableId = 'Table1';
const colId = 'A';
let promise = Promise.resolve(true);
grist.rpc.on('message', msg => {
if (msg.type === "docAction") {
if (msg.action[0] === 'RenameTable') {
tableId = msg.action[2];
}
promise = getColValues(colId).then(foo);
}
});
function getColValues(colId) {
return grist.docApi.fetchTable(tableId).then(data => data[colId]);
}
class TestSubscribe {
invoke(api, name, args){
return grist[api][name](...args);
}
// Returns a promise that resolves when an ongoing call resolves. Resolves right-awa if plugin has
// no pending call.
waitForPlugin() {
return promise.then(() => true);
}
}
module.exports = TestSubscribe;

@ -0,0 +1,21 @@
const grist = require('grist-plugin-api');
const TestSubscribe = require('./TestSubscribe');
grist.rpc.registerImpl("testApiNode", { // todo rename to testGristDocApiNode
invoke: (name, args) => {
const api = grist.rpc.getStub("GristDocAPI@grist", grist.checkers.GristDocAPI);
return api[name](...args)
.then((result) => [`node-GristDocAPI ${name}(${args.join(",")})`, result]);
},
});
grist.rpc.registerImpl("testDocStorage", {
invoke: (name, args) => {
const api = grist.rpc.getStub("DocStorage@grist", grist.checkers.Storage);
return api[name](...args);
},
});
grist.rpc.registerImpl("testSubscribe", new TestSubscribe());
grist.ready();

@ -0,0 +1,7 @@
name: node-GristDocAPI
version: 0.0.0
description:
components:
unsafeNode: main.js
contributions: {}

@ -0,0 +1,11 @@
name: node-fail
version: 0.0.0
description:
components:
unsafeNode: main.js
contributions:
fileParsers:
- fileExtensions: ["csv"]
parseFile:
component: unsafeNode
name: node-fail

@ -0,0 +1,11 @@
name: minicsv
version: 0.0.0
description: minicsv
components:
unsafeNode: nodebox/main.js
contributions:
fileParsers:
- fileExtensions: ["csv"]
parseFile:
component: unsafeNode
name: MiniCSV

@ -0,0 +1,69 @@
/**
*
* A minimal CSV reader with no type detection.
* All communication done by hand - real plugins should have helper code for
* RPC.
*
*/
const csv = require('csv');
const fs = require('fs');
const path = require('path');
function readCsv(data, replier) {
csv.parse(data, {}, function(err, output) {
const result = {
parseOptions: {
options: ""
},
tables: [
{
table_name: "space-monkey" + require('dependency_test'),
column_metadata: output[0].map(name => {
return {
id: name,
type: 'Text'
};
}),
table_data: output[0].map((name, idx) => {
return output.slice(1).map(row => row[idx]);
})
}
]
};
replier(result);
});
}
function processMessage(msg, replier, error_replier) {
if (msg.meth == 'parseFile') {
var dir = msg.dir;
var fname = msg.args[0].path;
var data = fs.readFileSync(path.resolve(dir, fname));
readCsv(data, replier);
} else {
error_replier('unknown method');
}
}
process.on('message', (m) => {
const sendReply = (result) => {
process.send({
mtype: 2, /* RespData */
reqId: m.reqId,
data: result
});
};
const sendError = (txt) => {
process.send({
mtype: 3, /* RespErr */
reqId: m.reqId,
mesg: txt
});
};
processMessage(m, sendReply, sendError);
});
// Once we have a handler for 'message' set up, send home a ready
// message to give the all-clear.
process.send({ mtype: 4, data: {ready: true }});

@ -0,0 +1,11 @@
{
"name": "dependency_test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}

@ -0,0 +1,11 @@
name: node-wrong-message
version: 0.0.0
description:
components:
unsafeNode: main.js
contributions:
fileParsers:
- fileExtensions: ["csv"]
parseFile:
component: unsafeNode
name: node-wrong-message

@ -0,0 +1,10 @@
name: validPluginName
version: 0.0.1
components:
safeBrowser: '.'
contributions:
importSources:
- importSource:
component: "safeBrowser"
name: index.html
label: My custom safe importer

@ -10,7 +10,7 @@
// Increase the threshold since the default (of 40 characters) is often too low.
// You can override it using CHAI_TRUNCATE_THRESHOLD env var; 0 disables it.
require('chai').config.truncateThreshold = process.env.CHAI_TRUNCATE_THRESHOLD ?
parseFloat(process.env.CHAI_TRUNCATE_THRESHOLD) : 200;
parseFloat(process.env.CHAI_TRUNCATE_THRESHOLD) : 4000;
// Set an explicit window size (if not set by an external variable), to ensure that manully-run
// and Jenkins-run tests, headless or not, use a consistent size. (Not that height is still not
@ -44,3 +44,22 @@ if (process.env.MOCHA_WEBDRIVER_IGNORE_CHROME_VERSION === undefined) {
if (process.env.MOCHA_WEBDRIVER_NO_CONTROL_BANNER === undefined) {
process.env.MOCHA_WEBDRIVER_NO_CONTROL_BANNER = "1";
}
// Detect whether there is an nbrowser test. If so,
// set an environment variable that will be available
// in individual processes if --parallel is enabled.
for (const arg of process.argv) {
if (arg.includes('/nbrowser/')) {
process.env.MOCHA_WEBDRIVER = '1';
}
}
// If --parallel is enabled, and we are in an individual
// worker process, set up mochaHooks. Watch out: at the
// time of writing, there's no way to have hooks run at the
// start and end of the worker process.
if (process.env.MOCHA_WORKER_ID !== undefined &&
process.env.MOCHA_WEBDRIVER !== undefined) {
const {getMochaHooks} = require('mocha-webdriver');
exports.mochaHooks = getMochaHooks();
}

@ -0,0 +1,24 @@
import { driver } from 'mocha-webdriver';
import { $, gu, server, test } from 'test/nbrowser/gristUtil-nbrowser';
describe('ClientUnitTests.ntest', function() {
test.setupTestSuite(this);
before(async function() {
await gu.supportOldTimeyTestCode();
var timingTests = process.env.ENABLE_TIMING_TESTS ? 1 : 0;
await driver.get(server.getHost() + '/v/gtag/test.html?timing=' + timingTests);
});
it('should reach 100% with no failures', async function() {
this.timeout(30000); // You've got 30 seconds
await $('#mocha-status:contains(DONE)').wait();
const failures = await driver.executeScript('return mocha.failedTests;');
if (failures.length > 0) {
var listing = failures.map(fail => fail.title + ': ' + fail.error).join("\n");
throw new Error("Browser returned " + failures.length + " failed tests:\n" + listing);
}
});
});

@ -0,0 +1,61 @@
/* global window */
import { assert, driver } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
describe('CodeEditor.ntest', function() {
const cleanup = test.setupTestSuite(this);
before(async function() {
await gu.supportOldTimeyTestCode();
await gu.useFixtureDoc(cleanup, '../uploads/CodeEditor.test.csv', true);
});
afterEach(function() {
return gu.checkForErrors();
});
it('Should activate on click of `Code View` button', async function() {
await gu.openSidePane('code');
assert.match(await $('.g-code-viewer').wait().getText(),
/class CodeEditor_test:[^]*A = grist.Text\(\)[^]*B = grist.Numeric\(\)/);
});
it('Should update to reflect changes in schema', async function() {
await gu.actions.selectTabView('CodeEditor.test');
// open the side menu
await gu.openSidePane('field');
await gu.getCellRC(0, 0).click();
await $(".test-field-label").wait(assert.isDisplayed);
await $(".test-field-label").sendNewText('foo');
await gu.waitForServer();
await gu.getCellRC(0, 1).click();
await $(".test-field-label").sendNewText('bar');
await gu.waitForServer(); // Must wait for colId change to finish
await gu.setType('Reference');
await gu.applyTypeConversion();
await gu.setVisibleCol('foo');
await gu.waitForServer();
// Check that type conversion worked correctly.
assert.equal(await gu.getCellRC(1, 1).text(), 'Bob');
await gu.openSidePane('code');
assert.match(await $('.g-code-viewer').wait().getText(),
/foo = grist.Text\(\)[^]*bar = grist.Reference\('CodeEditor_test'\)/);
});
it('should filter out helper columns', async function() {
assert.notInclude(await $('.g-code-viewer').wait().getText(), 'gristHelper');
});
it('should allow text selection', async function() {
const textElem = $('.hljs-title:contains(CodeEditor)');
await textElem.click();
await driver.withActions(a => a.doubleClick(textElem.elem()));
assert.equal(await driver.executeScript(() => window.getSelection().toString()), 'CodeEditor_test');
});
});

@ -0,0 +1,276 @@
import { assert, driver } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
const colHeaderScrollOpts = {block: "start", inline: "end"};
describe('ColumnOps.ntest', function() {
const cleanup = test.setupTestSuite(this);
before(async function() {
this.timeout(Math.max(this.timeout(), 30000)); // Long-running test, unfortunately
await gu.supportOldTimeyTestCode();
await gu.useFixtureDoc(cleanup, "World.grist", true);
await gu.toggleSidePanel('left', 'close');
});
afterEach(function() {
return gu.checkForErrors();
});
it("should allow adding and deleting columns", async function() {
await gu.clickColumnMenuItem('Name', 'Insert column to the right');
await gu.waitForServer();
// Newly created columns labels become editable automatically. The next line checks that the
// label is editable and then closes the editor.
await gu.userActionsCollect(true);
await gu.getOpenEditingLabel(await gu.getColumnHeader('A')).wait().sendKeys($.ENTER);
// Verify that no UserActions were actually sent.
await gu.waitForServer();
await gu.userActionsVerify([]);
await gu.userActionsCollect(false);
await gu.waitAppFocus(true);
await assert.isPresent(gu.getColumnHeader('A'), true);
await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader('A')), false);
await gu.clickColumnMenuItem('A', 'Delete column');
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('A'), false);
});
it("should allow adding columns with new column menu", async function() {
await assert.isPresent(gu.getColumnHeader('A'), false);
await $('.mod-add-column').scrollIntoView(true);
await $('.mod-add-column').click();
await gu.actions.selectFloatingOption('Add Column');
await gu.userActionsCollect(true);
await gu.waitToPass(() => gu.getColumnHeader('A'));
await gu.getOpenEditingLabel(await gu.getColumnHeader('A')).wait().sendKeys($.ENTER);
await gu.waitForServer();
await gu.userActionsVerify([["AddRecord", "_grist_Views_section_field", null,
{"colRef":43, "parentId":4, "parentPos":null}]]);
await gu.userActionsCollect(false);
await gu.waitAppFocus(true);
await assert.isPresent(gu.getColumnHeader('A'), true);
await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader('A')), false);
assert.deepEqual(await gu.getGridLabels('City'),
["Name", "Country", "District", "Population", "A"]);
});
it("should allow hiding columns", async function() {
await assert.isPresent(gu.getColumnHeader('Name'), true);
await gu.getColumnHeader('Name').scrollIntoView(colHeaderScrollOpts);
await gu.clickColumnMenuItem('Name', 'Hide column');
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('Name'), false);
await $(".test-undo").click();
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('Name'), true);
});
it("[+] button should allow showing hidden columns", async function() {
// Hide a column first
await assert.isPresent(gu.getColumnHeader('Name'), true);
await gu.getColumnHeader('Name').scrollIntoView(colHeaderScrollOpts);
await gu.clickColumnMenuItem('Name', 'Hide column');
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('Name'), false);
// Then show it using the add column menu
await $('.mod-add-column').scrollIntoView(true);
await $(".mod-add-column").click();
await gu.actions.selectFloatingOption('Show column Name');
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('Name'), true);
});
it("[+] button show add column directly if no hidden columns", async function() {
await $('.mod-add-column').scrollIntoView(true);
await $(".mod-add-column").click();
await gu.actions.selectFloatingOption('Show column Pop');
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader("Pop. '000"), true);
await assert.isPresent(gu.getColumnHeader('B'), false);
await $('.mod-add-column').scrollIntoView(true);
await $(".mod-add-column").click();
await gu.waitToPass(() => gu.getColumnHeader('B'));
await gu.getOpenEditingLabel(await gu.getColumnHeader('B')).wait().sendKeys($.ENTER);
await gu.waitForServer();
await gu.waitAppFocus(true);
await assert.isPresent(gu.getColumnHeader('B'), true);
});
it("should allow renaming columns", async function() {
await gu.getColumnHeader('Name').scrollIntoView(colHeaderScrollOpts);
await gu.clickColumnMenuItem('Name', 'Rename column');
await gu.getOpenEditingLabel(await gu.getColumnHeader('Name')).sendKeys('Renamed', $.ENTER);
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('Renamed'), true);
assert.deepEqual(await gu.getGridValues({
cols: ['Renamed'],
rowNums: [3, 4, 5],
}), ['A Coruña (La Coruña)', 'Aachen', 'Aalborg']);
// Verify that undo/redo works with renaming
await gu.undo();
await assert.isPresent(gu.getColumnHeader('Name'), true);
assert.deepEqual(await gu.getGridValues({
cols: ['Name'],
rowNums: [3, 4, 5],
}), ['A Coruña (La Coruña)', 'Aachen', 'Aalborg']);
await gu.redo();
await assert.isPresent(gu.getColumnHeader('Renamed'), true);
assert.deepEqual(await gu.getGridValues({
cols: ['Renamed'],
rowNums: [3, 4, 5],
}), ['A Coruña (La Coruña)', 'Aachen', 'Aalborg']);
// Refresh the page and check that the rename sticks.
await driver.navigate().refresh();
assert.equal(await $('.active_section .test-viewsection-title').wait().text(), 'CITY');
await gu.waitForServer(5000);
await gu.clickCellRC(1, 4);
await assert.isPresent(gu.getColumnHeader('Renamed'), true);
// Check that it is renamed in the sidepane
await gu.openSidePane('field');
assert.equal(await $(".test-field-label").val(), "Renamed");
// Check that both the label and the Id are changed when label and Id are linked
let deriveIdCheckbox = $(".test-field-derive-id");
assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]'));
await deriveIdCheckbox.click();
await gu.waitForServer();
assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]'));
assert(await $(".test-field-col-id").val(), "Renamed");
// Check that just the label is changed when label and Id are unlinked
await gu.getColumnHeader('Renamed').scrollIntoView(colHeaderScrollOpts);
await gu.clickColumnMenuItem('Renamed', 'Rename column');
await gu.getOpenEditingLabel(await gu.getColumnHeader('Renamed')).wait().sendKeys('foo', $.ENTER);
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('foo'), true);
await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader('foo')), false);
assert.equal(await $(".test-field-label").val(), "foo");
assert(await $(".test-field-col-id").val(), "Renamed");
// Saving an identical column label should still close the input.
await gu.getColumnHeader('foo').scrollIntoView(colHeaderScrollOpts);
await gu.clickColumnMenuItem('foo', 'Rename column');
await gu.userActionsCollect(true);
await gu.getOpenEditingLabel(await gu.getColumnHeader('foo')).wait().sendKeys('foo', $.ENTER);
await gu.waitForServer();
await gu.userActionsVerify([]);
await gu.userActionsCollect(false);
await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader('foo')), false);
await gu.waitAppFocus(true);
// Bug T384: Should save the column name after cancelling a rename earlier.
await gu.getColumnHeader('A').scrollIntoView(colHeaderScrollOpts);
await gu.clickColumnMenuItem('A', 'Rename column');
await gu.getOpenEditingLabel(await gu.getColumnHeader('A')).wait().sendKeys('C', $.ESCAPE);
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('A'), true);
await gu.clickColumnMenuItem('A', 'Rename column');
await gu.getOpenEditingLabel(await gu.getColumnHeader('A')).sendKeys('C', $.TAB);
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('C'), true);
});
it("should allow renaming columns with a click", async function() {
// Go to a non-target column first
await gu.getColumnHeader('Population').scrollIntoView(colHeaderScrollOpts);
await gu.getColumnHeader('Population').click();
// Now select the column of interest
await gu.getColumnHeader('foo').scrollIntoView(colHeaderScrollOpts);
await gu.getColumnHeader('foo').click();
// And click one more time to rename
await gu.getColumnHeader('foo').click();
await gu.getOpenEditingLabel(await gu.getColumnHeader('foo')).sendKeys('foot', $.ENTER);
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('foo'), false);
await assert.isPresent(gu.getColumnHeader('foot'), true);
// Click to rename back again
await gu.getColumnHeader('foot').click();
await gu.getOpenEditingLabel(await gu.getColumnHeader('foot')).sendKeys('foo', $.ENTER);
await gu.waitForServer();
await assert.isPresent(gu.getColumnHeader('foo'), true);
await assert.isPresent(gu.getColumnHeader('foot'), false);
});
it("should allow deleting multiple columns", async function() {
await gu.actions.selectTabView('Country');
// delete shortcut should delete all selected columns
await gu.selectGridArea([1, 0], [1, 1]);
await gu.sendKeys([$.ALT, '-']);
await gu.waitForServer();
assert.deepEqual(await gu.getGridLabels('Country'),
["Continent", "Region", "SurfaceArea", "IndepYear", "Population", "LifeExpectancy",
"GNP", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", "Capital", "Code2"]);
// Undo to restore changes
await gu.undo(1, 5000);
// delete menu item should delete all selected columns
await gu.selectGridArea([1, 2], [1, 8]);
await gu.clickColumnMenuItem('SurfaceArea', 'Delete', true);
await gu.waitForServer();
assert.deepEqual(await gu.getGridLabels('Country'),
["Code", "Name", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", "Capital", "Code2"]);
// Undo to restore changes
await gu.undo(1, 5000);
// the delete shortcut should delete all columns in a cell selection as well
await gu.clickCellRC(2, 5);
await gu.sendKeys([$.SHIFT, $.RIGHT, $.RIGHT]);
await gu.sendKeys([$.ALT, '-']);
await gu.waitForServer();
assert.deepEqual(await gu.getGridLabels('Country'),
["Code", "Name", "Continent", "Region", "SurfaceArea", "GNP", "GNPOld", "LocalName",
"GovernmentForm", "HeadOfState", "Capital", "Code2"]);
// Undo to restore changes
await gu.undo(1, 5000);
// Nudge first few columns back into view if they've drifted out of it.
await gu.toggleSidePanel('right', 'close');
await gu.sendKeys($.LEFT, $.LEFT, $.LEFT);
// opening a column menu outside the selection should move the selection
await gu.clickCellRC(2, 2);
await gu.sendKeys([$.SHIFT, $.RIGHT]);
await gu.clickColumnMenuItem('IndepYear', 'Delete', true);
await gu.waitForServer();
assert.deepEqual(await gu.getGridLabels('Country'),
["Code", "Name", "Continent", "Region", "SurfaceArea", "Population",
"LifeExpectancy", "GNP", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState",
"Capital", "Code2"]);
// Undo to restore changes
await gu.undo();
});
it("should allow hiding multiple columns", async function() {
await gu.actions.selectTabView('Country');
await gu.openSidePane('view');
// hide menu item should hide all selected columns
await gu.selectGridArea([1, 2], [1, 8]);
await gu.clickColumnMenuItem('SurfaceArea', 'Hide 7 columns', true);
await gu.waitForServer();
assert.deepEqual(await gu.getGridLabels('Country'),
["Code", "Name", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", "Capital", "Code2"]);
assert.deepEqual(await $('.test-vfc-visible-fields .kf_draggable_content').array().text(),
["Code", "Name", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState", "Capital", "Code2"]);
assert.deepEqual(await $('.test-vfc-hidden-fields .kf_draggable_content').array().text(),
["Name", "Continent", "Region", "SurfaceArea", "IndepYear", "Population",
"LifeExpectancy", "GNP", "Self"]);
// Undo to restore changes
await gu.undo(1, 5000);
assert.deepEqual(await gu.getGridLabels('Country'),
["Code", "Name", "Continent", "Region", "SurfaceArea", "IndepYear", "Population",
"LifeExpectancy", "GNP", "GNPOld", "LocalName", "GovernmentForm", "HeadOfState",
"Capital", "Code2"]);
});
});

@ -158,7 +158,7 @@ async function checkSortMenu(state: 'empty' | 'modified' | 'customized' | 'empty
}
describe('CustomWidgetsConfig', function () {
this.timeout(30000); // almost 20 second on dev machine.
this.timeout(60000);
const cleanup = setupTestSuite();
let mainSession: gu.Session;
gu.bigScreen();

@ -0,0 +1,504 @@
import { assert, driver } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
describe('Dates.ntest', function() {
const cleanup = test.setupTestSuite(this);
let doc;
before(async function() {
await gu.supportOldTimeyTestCode();
doc = await gu.useFixtureDoc(cleanup, "Hello.grist", true);
await gu.toggleSidePanel("left", "close");
});
afterEach(function() {
return gu.checkForErrors();
});
it('should allow correct datetime reformatting', async function() {
await gu.openSidePane('field');
var cell = await gu.getCellRC(0, 0);
// Move to the first column
await cell.click();
await gu.sendKeys('2008-01-10 9:20pm', $.ENTER);
// Change type to 'DateTime'
await gu.setType('DateTime');
await $('.test-tz-autocomplete').wait().click();
await gu.sendKeys($.DELETE, 'UTC', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), '2008-01-10 9:20pm');
await $('.test-type-transform-apply').wait().click();
await gu.waitForServer();
// Change timezone to 'America/Los_Angeles' and check that the date is correct
await $('.test-tz-autocomplete').wait().click();
await gu.sendKeys('Los An', $.ENTER);
await gu.waitForServer();
assert.equal(await $('.test-tz-autocomplete input').val(), 'America/Los_Angeles');
assert.equal(await cell.text(), '2008-01-10 1:20pm');
// Change format and check that date is reformatted
await gu.dateFormat('MMMM Do, YYYY');
await gu.timeFormat('HH:mm:ss z');
assert.equal(await gu.getCellRC(0, 0).text(), 'January 10th, 2008 13:20:00 PST');
// Change to custom format and check that the date is reformatted
await gu.dateFormat('Custom');
await $('$Widget_dateCustomFormat .kf_text').click();
await gu.sendKeys($.SELECT_ALL, 'dddd', $.ENTER);
await gu.timeFormat("Custom");
await $('$Widget_timeCustomFormat .kf_text').click();
await gu.sendKeys($.SELECT_ALL, 'Hmm', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'Thursday 1320');
});
it('should include a functioning datetime editor', async function() {
var cell = await gu.getCellRC(0, 0);
// DateTime editor should open, separate date and time, and replace incomplete format
// with YYYY-MM-DD
await cell.click();
await gu.sendKeys($.ENTER);
assert.equal(await $('.celleditor_text_editor').first().val(), '2008-01-10');
// Date should be changable by clicking the calendar dates
await $('.celleditor_text_editor').first().sendKeys($.DOWN); // Opens date picker even if window has no focus.
await $('.datepicker .day:contains(19)').wait().click();
await gu.sendKeys($.ENTER);
assert.equal(await cell.text(), 'Saturday 1320');
// Date editor should convert Moment formats to datepicker safe formats
// Date editor should allow tabbing between date and time entry boxes
await gu.dateFormat('MMMM Do, YYYY');
await gu.timeFormat('h:mma');
await cell.click();
await gu.sendKeys($.ENTER);
assert.deepEqual(await $('.celleditor_text_editor').array().val(),
['January 19th, 2008', '1:20pm']);
await gu.sendKeys($.SELECT_ALL, 'February 20th, 2009', $.TAB, '8:15am', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'February 20th, 2009 8:15am');
// DateTime editor should close and save value when the user clicks away
await cell.click();
await gu.sendKeys($.ENTER, $.SELECT_ALL, $.DELETE);
await gu.getCellRC(0, 3).click(); // click away
await gu.waitForServer();
// Since only the date value was removed, the cell should give AltText of the time value
assert.equal(await cell.text(), '8:15am');
assert.hasClass(await cell.find('.field_clip'), 'invalid');
// DateTime editor should close and revert value when the user presses escape
await cell.click();
await gu.sendKeys($.ENTER, 'April 2, 1993', $.ESCAPE);
assert.equal(await cell.text(), '8:15am');
});
it('should allow correct date reformatting', async function() {
var cell = await gu.getCellRC(0, 1);
// Move to the first column
await cell.click();
await gu.sendKeys('2016-01-08', $.ENTER);
// Change type to 'Date'
await gu.setType('Date');
await $('.test-type-transform-apply').wait().click();
await gu.waitForServer(); // Make sure type is set
// Check that the date is correct
await $('$Widget_dateFormat').wait();
assert.equal(await cell.text(), '2016-01-08');
// Change format and check that date is reformatted
await gu.dateFormat('MMMM Do, YYYY');
await gu.waitForServer();
assert.equal(await cell.text(), 'January 8th, 2016');
// Try another format
await gu.dateFormat('DD MMM YYYY');
await gu.waitForServer();
assert.equal(await cell.text(), '08 Jan 2016');
// Change to custom format and check that the date is reformatted
await gu.dateFormat('Custom');
await $('$Widget_dateCustomFormat .kf_text').click();
await gu.sendKeys($.SELECT_ALL, 'dddd', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'Friday');
});
it('should include a functioning date editor', async function() {
var cell = await gu.getCellRC(0, 1);
// Date editor should open and replace incomplete format with YYYY-MM-DD
await cell.click();
await gu.sendKeys($.ENTER);
assert.equal(await $('.celleditor_text_editor').val(), '2016-01-08');
// Date should be changable by clicking the calendar dates
await $('.celleditor_text_editor').sendKeys($.DOWN); // Opens date picker even if window has no focus.
await $('.datepicker .day:contains(19)').wait().click();
await gu.sendKeys($.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'Tuesday');
// Date editor should convert Moment formats to datepicker safe formats
// Date editor should save the date on enter press
await gu.dateFormat('MMMM Do, YYYY');
await cell.click();
await gu.sendKeys($.ENTER);
assert.equal(await $('.celleditor_text_editor').val(), 'January 19th, 2016');
await gu.sendKeys($.SELECT_ALL, 'February 20th, 2016', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'February 20th, 2016');
// Date editor should close and save value when the user clicks away
await cell.click();
await gu.sendKeys($.ENTER, $.SELECT_ALL, $.DELETE);
await gu.getCellRC(0, 3).click(); // click away
await gu.waitForServer();
assert.equal(await cell.text(), '');
// Date editor should close and revert value when the user presses escape
await cell.click();
await gu.sendKeys($.ENTER, 'April 2, 1993', $.ESCAPE);
assert.equal(await cell.text(), '');
});
it('should reload values correctly after reopen', async function() {
await gu.getCellRC(0, 0).click();
await gu.sendKeys('February 20th, 2009', $.TAB, '8:15am', $.ENTER);
await gu.getCellRC(0, 1).click();
await gu.sendKeys('January 19th, 1968', $.ENTER);
await gu.getCellRC(1, 1).click();
await gu.sendKeys($.DELETE);
await gu.waitForServer();
await gu.getCellRC(0, 2).click();
await gu.waitAppFocus(true);
await gu.sendKeys('=');
await $('.test-editor-tooltip-convert').click();
await gu.sendKeys('$A', $.ENTER);
await gu.waitForServer();
await gu.waitAppFocus(true);
await gu.getCellRC(0, 3).click();
await gu.sendKeys('=');
await gu.waitAppFocus(false);
await gu.sendKeys('$B', $.ENTER);
await gu.waitForServer();
assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: ['A', 'B', 'C', 'D']}), [
'February 20th, 2009 8:15am',
'January 19th, 1968',
'2009-02-20 08:15:00-08:00',
'1968-01-19',
'', '', '', ''
]);
// We don't have a quick way to shutdown a document and reopen from scratch. So instead, we'll
// make a copy of the document, and open that to test that values got saved correctly.
// TODO: it would be good to add a way to reload document from scratch, perhaps by reloading
// with a special URL fragment.
await gu.copyDoc(doc.id, true);
assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: ['A', 'B', 'C', 'D']}), [
'February 20th, 2009 8:15am',
'January 19th, 1968',
'2009-02-20 08:15:00-08:00',
'1968-01-19',
'', '', '', ''
]);
});
it('should support shortcuts to insert date/time', async function() {
await gu.openSidePane('field');
// Check the types of the first two columns.
await gu.clickCellRC(0, 0);
await gu.assertType('DateTime');
await gu.clickCellRC(0, 1);
await gu.assertType('Date');
// Insert a few more columns: empty, Text, Numeric.
await addColumn();
await addColumn();
await addColumn();
await gu.clickCellRC(0, 3);
await gu.setType('Numeric');
await gu.clickCellRC(0, 4);
await gu.setType('Text');
// Override Date.now() and timezone in the current browser page to return a consistent value,
// used e.g. for the default for the year and month.
await driver.executeScript(
"Date.now = () => 1477548296087; " + // This value is 2016-10-27 02:04:56.087 EST
"exposeModulesForTests().then(() => { " +
"window.exposedModules.moment.tz.setDefault('America/New_York');" +
"});"
);
async function fillWithShortcuts() {
await gu.toggleSidePanel('right', 'close');
// Type the Date-only shortcut into each cell in the second row.
await gu.clickCellRC(1, 0);
for (var i = 0; i < 6; i++) {
await gu.sendKeys([$.MOD, ';'], $.TAB);
}
// Type the Date-Time shortcut into each cell in the third row.
await gu.clickCellRC(2, 0);
for (i = 0; i < 6; i++) {
await gu.sendKeys([$.MOD, $.SHIFT, ';'], $.TAB);
}
}
// Change document timezone to US/Hawaii (3 hours behind LA, which is TZ of the first column).
// We check that shortcuts for Text/Any columns use the document timezone.
await setGlobalTimezone('US/Hawaii');
await fillWithShortcuts();
// Compare the values. NOTE: this assumes EST timezone for the browser's local time.
assert.deepEqual(await gu.getGridValues({rowNums: [2, 3], cols: [0, 1, 2, 3, 4]}), [
// Note that column A has Los_Angeles timezone set, so time differs from Hawaii.
// Note that Date column gets the date in Hawaii, not local or UTC (both 2016-10-27).
// The originally empty column had its type guessed as Date when the current date was first entered,
// hence "2016-10-26" appears in both rows.
"October 26th, 2016 11:04pm", "October 26th, 2016", "2016-10-26", "0", "2016-10-26",
"October 26th, 2016 11:04pm", "October 26th, 2016", "2016-10-26", "0", "2016-10-26 20:04:56",
]);
// Undo the 8 cells we actually filled in, and check that the empty column reverted to Any
await gu.undo(8);
await gu.clickCellRC(1, 2);
await gu.assertType('Any');
// Change document timezone back to America/New_York.
await setGlobalTimezone('America/New_York');
await fillWithShortcuts();
// Compare the values. NOTE: this assumes EST timezone for the browser's local time.
assert.deepEqual(await gu.getGridValues({rowNums: [2, 3], cols: [0, 1, 2, 3, 4]}), [
// Note that column A has Los_Angeles timezone set, so date differs by one from New_York.
"October 26th, 2016 11:04pm", "October 27th, 2016", "2016-10-27", "0", "2016-10-27",
"October 26th, 2016 11:04pm", "October 27th, 2016", "2016-10-27", "0", "2016-10-27 02:04:56",
]);
});
it('should allow navigating the datepicker with the keyboard', async function() {
// Change the date using the datepicker.
let cell = await gu.getCellRC(0, 1);
await cell.scrollIntoView({inline: "end"}).click();
await gu.sendKeys($.ENTER);
await gu.waitAppFocus(false);
await gu.sendKeys($.UP, $.UP, $.LEFT, $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'January 11th, 1968');
// Do the same in the datetime editor.
cell = await gu.getCellRC(1, 0);
await cell.click();
await gu.sendKeys($.ENTER);
await gu.waitAppFocus(false);
await gu.sendKeys($.UP, $.RIGHT, $.RIGHT, $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'October 28th, 2016 11:04pm');
// Start navigating the datepicker, then start typing to return to using the cell editor.
cell = await gu.getCellRC(1, 1);
await cell.click();
// The first backspace should return to cell edit mode, then the following keys should
// change the year to 2009.
await gu.sendKeys($.ENTER);
await gu.waitAppFocus(false);
await gu.sendKeys($.DOWN, $.RIGHT, $.BACK_SPACE, '9', $.LEFT, $.BACK_SPACE, '0', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'October 27th, 2009');
});
// NOTE: This addresses a bug where typical date entry formats were not recognized.
// See https://phab.getgrist.com/T308
it('should allow using common formats to enter the date', async function() {
let cell = await gu.getCellRC(2, 1);
await cell.click();
await gu.sendKeys('April 2 1993', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'April 2nd, 1993');
cell = await gu.getCellRC(1, 0);
await cell.click();
await gu.sendKeys('December', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), `December 1st, 2016 11:04pm`);
cell = await gu.getCellRC(0, 1);
await cell.click();
await gu.sendKeys('7-Sep', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), `September 7th, 2016`);
await cell.click();
await gu.sendKeys('6/8', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), `June 8th, 2016`);
// The selected format should take precedence over the default format when
// parsing the date. Entering the same thing as before (6/8) will yield a different
// result after changing the format.
await gu.openSidePane('field');
cell = await gu.getCellRC(1, 1);
await cell.click();
await gu.dateFormat('DD-MM-YYYY');
await cell.click();
await gu.sendKeys('6/8', $.ENTER);
await gu.waitForServer();
await gu.dateFormat('MMMM Do, YYYY');
assert.equal(await cell.text(), `August 6th, 2016`);
cell = await gu.getCellRC(2, 1);
await cell.click();
await gu.sendKeys('1937', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), `January 1st, 1937`);
});
it('should not attempt to parse non-dates', async function() {
// Should allow AltText
let cell = await gu.getCellRC(2, 1);
await cell.click();
await gu.sendKeys('Applesauce', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), 'Applesauce');
await assert.hasClass(cell.find('.field_clip'), 'invalid');
// Should allow AltText even of numbers that cannot be parsed as dates.
// Manually entered numbers should not be read as timestamps.
cell = await gu.getCellRC(1, 0);
await cell.click();
await gu.sendKeys('100000', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), '100000 11:04pm');
await assert.hasClass(cell.find('.field_clip'), 'invalid');
// Should give AltText if just the time is entered but not the date.
cell = await gu.getCellRC(1, 0);
await cell.click();
await gu.sendKeys($.ENTER, $.TAB, '3', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), '100000 11:04pm 3');
await assert.hasClass(cell.find('.field_clip'), 'invalid');
});
it("should allow working with naive date object", async function() {
await gu.clickCellRC(0, 1);
await gu.sendKeys([$.ALT, '=']);
await gu.waitForServer();
await gu.sendKeys("Diff", $.ENTER);
await gu.waitForServer();
await gu.sendKeys('=');
await gu.waitAppFocus(false);
await gu.sendKeys('($A-DTIME($B)).total_seconds()', $.ENTER);
await gu.waitForServer();
await gu.waitAppFocus();
assert.deepEqual(await gu.getCellRC(0, 2).text(), '-230211900');
// change global timezone should recompute formula
await setGlobalTimezone('Paris');
assert.deepEqual(await gu.getCellRC(0, 2).text(), '-230190300');
});
// NOTE: This tests a specific bug where AltText values in a column that has been coverted
// to a date column do not respond to updates until refresh. This bug was exposed via the
// error dom in FieldBuilder not being re-evaluated after a column transform.
it('should allow deleting AltText values in a newly changed Date column', async function() {
// Change the type to text and enter a text value.
await gu.clickCellRC(0, 1);
await gu.setType('Text');
await gu.applyTypeConversion();
await gu.clickCellRC(2, 1);
await gu.sendKeys('banana', $.ENTER);
await gu.waitForServer();
assert.equal(await gu.getCellRC(2, 1).text(), 'banana');
// Change back to Date and try to remove the text.
await gu.setType('Date');
await $('.test-type-transform-apply').wait().click();
await gu.waitForServer();
assert.equal(await gu.getCellRC(2, 1).text(), 'banana');
await gu.clickCellRC(2, 1);
await gu.sendKeys($.BACK_SPACE);
await gu.waitForServer();
assert.equal(await gu.getCellRC(2, 1).text(), '');
await gu.undo();
});
it("should report informative error when AltText is used for date", async function() {
// Enter a formula column that uses a date.
await gu.clickCellRC(0, 1);
await gu.sendKeys([$.ALT, '=']);
await gu.waitForServer();
await gu.sendKeys("Month", $.ENTER);
await gu.waitForServer();
await gu.sendKeys("=$B.month", $.ENTER);
await gu.waitForServer();
assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: ['B', 'Month']}), [
"June 8th, 2016", "6",
"August 6th, 2016", "8",
"banana", "#Invalid Date: banana",
"", "#AttributeError",
]);
});
it('should default timezone to document\'s timezone', async function() {
// add a DateTime column
await addDateTimeColumn();
await gu.timeFormat('HH:mm:ss');
// BUG: it is required to click somewhere after setting the type of a column for the shortcut to
// work
// TODO: removes gu.getCellRC(1, 3).click() below when its fixed
await gu.getCellRC(1, 3).click();
// get the current date
await gu.sendKeys([$.MOD, $.SHIFT, ';']);
await gu.waitForServer();
const date1 = await gu.getCellRC(1, 3).text();
// check default timezone
assert.equal(await $('.test-tz-autocomplete input').val(), 'Europe/Paris');
// set global document timezone to 'Europe/Paris'
await setGlobalTimezone('America/Los_Angeles');
// add another DateTime column
await addDateTimeColumn();
await gu.timeFormat('HH:mm:ss');
// todo: same as for gu.getCellRC(1, 3).click();
await gu.getCellRC(1, 4).click();
// get the current date
await gu.sendKeys([$.MOD, $.SHIFT, ';']);
await gu.waitForServer();
const date2 = await gu.getCellRC(1, 4).text();
// check default timezone
assert.equal(await $('.test-tz-autocomplete input').val(), 'America/Los_Angeles');
// check that the delta between date1 and date2 is coherent with the delta between
// 'Europe/Paris' and 'America/Los_Angeles' timezones.
const delta = (new Date(date1) - new Date(date2)) / 1000 / 60 / 60;
assert.isAbove(delta, 6);
assert.isBelow(delta, 12);
});
});
async function addDateTimeColumn() {
await addColumn();
return gu.setType('DateTime');
}
async function addColumn() {
await gu.sendKeys([$.ALT, '=']);
await gu.waitForServer();
return gu.sendKeys($.ESCAPE);
}
async function setGlobalTimezone(name) {
await $('.test-user-icon').click(); // open the user menu
await $('.test-dm-doc-settings').click();
await $('.test-tz-autocomplete').click();
await $(`.test-acselect-dropdown li:contains(${name})`).click();
await gu.waitForServer();
await driver.navigate().back();
}

@ -0,0 +1,118 @@
import { assert } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
describe("DetailView.ntest", function () {
const cleanup = test.setupTestSuite(this);
gu.bigScreen();
before(async function() {
await gu.supportOldTimeyTestCode();
await gu.useFixtureDoc(cleanup, "Favorite_Films.grist", true);
// Open the view tab.
await gu.openSidePane('view');
// Open the 'All' view
await gu.actions.selectTabView('All');
// close the side pane
await gu.toggleSidePanel('left', 'close');
});
afterEach(function() {
return gu.checkForErrors();
});
it('should allow switching between card and detail view', async function() {
await gu.actions.viewSection('Performances detail').selectSection();
// Check that the detail cells have the correct values.
assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tom Hanks']);
await $('.grist-single-record__menu .detail-right').click();
// rowNum is always 1 for detail cells now.
assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tim Allen']);
// Swap to Card List view, check values.
await $('.test-right-panel button:contains(Change Widget)').click();
await $('.test-wselect-type:contains(Card List)').click();
await $('.test-wselect-addBtn').click();
await gu.waitForServer();
assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1, 2]),
['Tom Hanks', 'Tim Allen']);
// Swap back to Card view, re-check values.
await $('.test-right-panel button:contains(Change Widget)').click();
await $('.test-wselect-type:contains(Card)').click();
await $('.test-wselect-addBtn').click();
await gu.waitForServer();
assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tim Allen']);
await $('.grist-single-record__menu .detail-left').click();
assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tom Hanks']);
});
it('should allow editing cells', async function() {
// Updates should be reflected in the detail floating rowModel cell.
await gu.sendKeys('Roger Federer', $.ENTER);
await gu.waitForServer();
assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Roger Federer']);
// Undo updates should be reflected as well.
await gu.sendKeys([$.MOD, 'z']);
await gu.waitForServer();
assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1]), ['Tom Hanks']);
});
// Note: This is a test of a specific bug related to the detail rowModel being resized after
// being unset.
it('should allow row resize operations after switching section type', async function() {
// Switch to Card List view and enter a formula. This should cause the scrolly to resize all rows.
// If the detail view rowModel is wrongly resized, the action will fail.
await $('.test-right-panel button:contains(Change Widget)').click();
await $('.test-wselect-type:contains(Card List)').click();
await $('.test-wselect-addBtn').click();
await gu.waitForServer();
await gu.sendKeys('=');
await $('.test-editor-tooltip-convert').click(); // Convert to a formula
await gu.sendKeys('100', $.ENTER);
await gu.waitForServer();
assert.deepEqual(await gu.getVisibleDetailCells('Actor', [1, 2, 3, 4]),
['100', '100', '100', '100']);
});
it('should include an add record row', async function() {
// Should include an add record row which works in card view and detail view.
// Check that adding 'Jurassic Park' to the card view add record row adds it as a row.
// await gu.selectSectionByTitle("Performances detail");
//await gu.sendKeys([$.MOD, $.DOWN]);
await $('.g_record_detail:nth-child(14) .field_clip').eq(1).wait().click();
await gu.sendKeys('Jurassic Park', $.ENTER);
await gu.waitForServer();
assert.deepEqual(await gu.getVisibleDetailCells('Film', [14]), ['Jurassic Park']);
// Check that adding 'Star Wars' to the detail view add record row adds it as a row.
await $('.test-right-panel button:contains(Change Widget)').click();
await $('.test-wselect-type:contains(Card)').click();
await $('.test-wselect-addBtn').click();
await gu.waitForServer();
await $('.detail-add-btn').wait().click();
// Card view, so rowNum is now 1
await gu.getDetailCell('Film', 1).click();
await gu.sendKeys('Star Wars', $.ENTER);
await gu.waitForServer();
assert.deepEqual(await gu.getVisibleDetailCells('Film', [1]), ['Star Wars']);
// Should allow pasting into the add record row.
await gu.getDetailCell('Actor', 1).click();
await gu.sendKeys($.COPY);
await $('.detail-add-btn').click();
// Paste '100' into the last field of the row and check that it is added as its own row.
await gu.getDetailCell('Character', 1).click();
await gu.sendKeys($.PASTE);
await gu.waitForServer();
assert.deepEqual(await gu.getDetailCell('Character', 1).text(), '100');
// Should not throw errors when deleting the add record row.
await $('.detail-add-btn').click();
await gu.sendKeys([$.MOD, $.DELETE]);
// Errors will be detected in afterEach.
});
});

@ -6,7 +6,7 @@ import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import {EnvironmentSnapshot} from 'test/server/testUtils';
describe('DocTutorial', function () {
this.timeout(30000);
this.timeout(60000);
setupTestSuite();
gu.bigScreen();
@ -578,7 +578,7 @@ describe('DocTutorial', function () {
// Check that the changes we made earlier are included.
assert.equal(
await driver.find('.test-doc-tutorial-popup p').getText(),
await driver.findWait('.test-doc-tutorial-popup p', 2000).getText(),
'Welcome to the Grist Basics tutorial V2.'
);
});

@ -0,0 +1,61 @@
import { assert } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
const fse = require('fs-extra');
const path = require('path');
const axios = require('axios');
// Authentication headers to include into axios requests.
const headers = {Authorization: 'Bearer api_key_for_userz'};
describe('Export.ntest', function() {
const cleanup = test.setupTestSuite(this);
const pathsExpected = {
base: path.resolve(gu.fixturesRoot, "export-csv", "CCTransactions.csv"),
sorted: path.resolve(gu.fixturesRoot, "export-csv", "CCTransactions-DBA-desc.csv")
};
let dataExpected = {};
before(async function() {
await gu.supportOldTimeyTestCode();
await gu.useFixtureDoc(cleanup, "CCTransactions.grist", true);
// Read the expected contents before the test case starts, to simplify the promises there.
// (don't really need that simplification any more though).
for (const [key, fname] of Object.entries(pathsExpected)) {
dataExpected[key] = await fse.readFile(fname, {encoding: 'utf8'});
}
});
afterEach(function() {
return gu.checkForErrors();
});
it('should export correct data', async function() {
await $('.test-tb-share').click();
// Once the menu opens, get the href of the link.
await $('.grist-floating-menu').wait();
const href = await $('.grist-floating-menu a:contains(CSV)').wait().getAttribute('href');
// Download the data at the link and compare to expected.
const resp = await axios.get(href, {responseType: 'text', headers});
assert.equal(resp.headers['content-disposition'],
'attachment; filename="CCTransactions.csv"');
assert.equal(resp.data, dataExpected.base);
await $('.test-tb-share').click();
});
it('should respect active sort', async function() {
await gu.openColumnMenu('Doing Business As');
await $('.grist-floating-menu .test-sort-dsc').click()
await $('.test-tb-share').click();
// Once the menu opens, get the href of the link.
await $('.grist-floating-menu').wait();
const href = await $('.grist-floating-menu a:contains(CSV)').wait().getAttribute('href');
// Download the data at the link and compare to expected.
const resp = await axios.get(href, {responseType: 'text', headers});
assert.equal(resp.data, dataExpected.sorted);
});
// TODO: We should have a test case with multiple sections on the screen, that checks that
// export runs for the currently selected section.
});

@ -0,0 +1,195 @@
import { assert } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
describe('FieldConfigTab.ntest', function() {
const cleanup = test.setupTestSuite(this);
before(async function() {
await gu.supportOldTimeyTestCode();
await gu.useFixtureDoc(cleanup, "Hello.grist", true);
});
afterEach(function() {
return gu.checkForErrors();
});
it("should stay open when switching between columns or views", async function() {
// Add another table to the document.
await gu.actions.addNewTable();
await gu.actions.selectTabView('Table1');
await gu.openSidePane('field');
var fieldLabel = await $(".test-field-label").wait(assert.isDisplayed).elem();
await assert.isDisplayed(fieldLabel);
assert.equal(await fieldLabel.val(), "A");
// Move cursor to a different column.
await $("$GridView_columnLabel:nth-child(2)").click();
assert.equal(await fieldLabel.val(), "B");
// Switch to another view. The first column should be selected.
await gu.actions.selectTabView('Table2');
fieldLabel = $(".test-field-label").elem();
await assert.isDisplayed(fieldLabel);
assert.equal(await fieldLabel.val(), "A");
});
it("should support changing the column label and id together", async function() {
await gu.actions.selectTabView('Table1');
var fieldLabel = await $(".test-field-label").elem();
await gu.clickCellRC(0, 0); // Move back to the first cell.
assert.equal(await fieldLabel.val(), "A");
await $(".test-field-label").sendNewText("foo");
await gu.waitForServer();
// Check that both the label and colId changed in the side pane.
assert.equal(await fieldLabel.val(), "foo");
await $(".test-field-col-id").wait(async function(el) { return assert.equal(await el.val(), "$foo"); });
// Check that the label changed among column headers.
assert.equal(await $("$GridView_columnLabel:nth-child(1)").text(), "foo");
});
it("should support changing the column label and id separately", async function() {
await gu.actions.selectTabView('Table1');
await $("$GridView_columnLabel:nth-child(2)").click();
var fieldLabel = $(".test-field-label");
assert.equal(await fieldLabel.val(), "B");
// Uncheck the "derive id" checkbox.
var deriveIdCheckbox = $(".test-field-derive-id");
assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]'));
await deriveIdCheckbox.click();
await gu.waitForServer();
assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]'));
// Check that only the label changed in the side pane.
await fieldLabel.sendNewText("bar");
await gu.waitForServer();
assert.equal(await fieldLabel.val(), "bar");
await $("$GridView_columnLabel:nth-child(2)").wait(async function(el) { return assert.equal(await el.text(), "bar"); });
// Id should be unchanged, but we should be able to change it now.
assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2, 3, 4], cols: [1] }),
['', 'world', '', '']);
assert(await $(".test-field-col-id").val(), "B");
await $(".test-field-col-id").sendNewText("baz");
assert(await $(".test-field-col-id").val(), "baz");
assert.equal(await fieldLabel.val(), "bar");
assert.equal(await $("$GridView_columnLabel:nth-child(1)").text(), "foo");
assert.equal(await $("$GridView_columnLabel:nth-child(2)").text(), "bar");
// Make sure the changing Ids does not effect the data in the column
assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2, 3, 4], cols: [1] }),
['', 'world', '', '']);
await assert.hasClass(gu.getCell(0, 1).find('.field_clip'), 'invalid', false);
});
describe('Duplicate Labels', async function() {
let fieldLabel, deriveIdCheckbox;
beforeEach(() => {
fieldLabel = $(".test-field-label");
deriveIdCheckbox = $(".test-field-derive-id");
});
it('should allow duplicate labels with underived colIds', async function() {
// Change column 4 to have the same label as column 1
await $("$GridView_columnLabel:nth-child(4)").click();
assert.equal(await fieldLabel.val(), "D");
assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]'));
await deriveIdCheckbox.click();
await gu.waitForServer();
assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]'));
await fieldLabel.sendNewText("foo");
// Columns 1 and 4 should both be named foo
await $("$GridView_columnLabel:nth-child(1)").wait(async function(el) { return assert.equal(await el.text(), "foo"); });
await $("$GridView_columnLabel:nth-child(4)").wait(async function(el) { return assert.equal(await el.text(), "foo"); });
// But colId should be unchanged
assert(await $(".test-field-col-id").val(), "D");
});
it('should allow duplicate labels with derived colIds', async function() {
// Now clicking the derive box should be leave the labels the same
// but the conflicting Id should be sanitized
await deriveIdCheckbox.click();
await gu.waitForServer();
assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]'));
await deriveIdCheckbox.click();
await gu.waitForServer();
assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]'));
await $("$GridView_columnLabel:nth-child(1)").scrollIntoView({inline: "end"}).click();
await $("$GridView_columnLabel:nth-child(1)").wait(async function(el) { return assert.equal(await el.text(), "foo"); });
assert(await $(".test-field-col-id").val(), "foo");
await $("$GridView_columnLabel:nth-child(4)").click();
await $("$GridView_columnLabel:nth-child(4)").wait(async function(el) { return assert.equal(await el.text(), "foo"); });
assert(await $(".test-field-col-id").val(), "foo2");
});
it('should not change the derived id unnecessarly', async function() {
// Toggling the box should not change the derived Id
await deriveIdCheckbox.click();
await gu.waitForServer();
assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]'));
await deriveIdCheckbox.click();
await gu.waitForServer();
assert.isFalse(await deriveIdCheckbox.is('[class*=-selected]'));
assert(await $(".test-field-col-id").val(), "foo2");
});
it('should not automatically modify the derived checkbox', async function() {
// When derived labels are changed to an existing Id, the derived box should remain checked
// even if the id and label are different
await $("$GridView_columnLabel:nth-child(1)").scrollIntoView({inline: "end"}).click();
await $("$GridView_columnLabel:nth-child(1)").wait(async function(el) { return assert.equal(await el.text(), "foo"); });
assert(await $(".test-field-col-id").val(), "foo");
assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]'));
await fieldLabel.sendNewText("foo2");
await gu.waitForServer();
assert.equal(await fieldLabel.val(), "foo2");
assert(await $(".test-field-col-id").val(), "foo2_2");
assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]'));
});
it('should allow out of sync colIds to still derive from labels', async function() {
// Entering a new label should still sync the Id
await fieldLabel.sendNewText("foobar");
await gu.waitForServer();
assert.isTrue(await deriveIdCheckbox.is('[class*=-selected]'));
await deriveIdCheckbox.click();
assert(await $(".test-field-col-id").val(), "foobar");
});
});
it("should allow editing column data after column rename", async function() {
await gu.actions.selectTabView('Table1');
await $("$GridView_columnLabel:nth-child(3)").click();
assert.equal(await $(".test-field-label").val(), "C");
// Switch type to numeric. This makes it easier to tell whether the value actually gets
// processed by the server.
await gu.setType('Numeric');
await $('.test-type-transform-apply').wait().click();
await gu.waitForServer();
var cell = await gu.getCellRC(0, 2);
await cell.click(); // row index 0, column index 2
await gu.sendKeys('17', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), '17');
await assert.hasClass(cell.find('.field_clip'), 'invalid', false);
// Rename the column, make sure we can still type into it, and get results from the server.
await $(".test-field-label").sendNewText("c2");
await gu.waitForServer();
assert.equal(await $("$GridView_columnLabel:nth-child(3)").text(), "c2");
await gu.waitForServer();
cell = await gu.getCellRC(0, 2);
await cell.click(); // row index 0, column index 2
await gu.sendKeys('23', $.ENTER);
await gu.waitForServer();
assert.equal(await cell.text(), '23');
await assert.hasClass(cell.find('.field_clip'), 'invalid', false);
});
});

@ -0,0 +1,324 @@
/**
* When a field is present in multiple views, the different copies of it may use common or
* separate settings. This test verifies these behaviors and switching between them.
*/
import { assert } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
describe('FieldSettings.ntest', function() {
const cleanup = test.setupTestSuite(this);
before(async function() {
await gu.supportOldTimeyTestCode();
await gu.useFixtureDoc(cleanup, "FieldSettings.grist", true);
await gu.actions.selectTabView("Rates");
await gu.waitForServer();
await gu.openSidePane('field');
});
afterEach(async function() {
await gu.userActionsCollect(false);
return gu.checkForErrors();
});
async function checkSections(position, settingsFunc, expectedBySection) {
await gu.waitForServer();
for (let sectionName in expectedBySection) {
let [cellText, settingsValue] = expectedBySection[sectionName];
const cell = await gu.getCell(Object.assign({section: sectionName}, position));
await gu.clickCell(cell);
assert.equal(await cell.text(), cellText);
assert.equal(await settingsFunc(), settingsValue);
}
}
it('should respect common settings for regular options', async function() {
await gu.userActionsCollect(true);
// Sections 'A' and 'B' use common settings, and 'C' uses separate.
// Check that changing the setting in A affects B, but does not affect C.
await gu.clickCell({section: 'A', rowNum: 1, col: 1});
assert.equal(await gu.dateFormat(), 'YYYY-MM-DD');
await gu.dateFormat('MM/DD/YYYY');
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['01/02/2012', 'MM/DD/YYYY'],
B: ['01/02/2012', 'MM/DD/YYYY'],
C: ['2012-01-02', 'YYYY-MM-DD'],
});
// Check that changing C does not affect A or B.
await gu.dateFormat('MMMM Do, YYYY');
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['01/02/2012', 'MM/DD/YYYY'],
B: ['01/02/2012', 'MM/DD/YYYY'],
C: ['January 2nd, 2012', 'MMMM Do, YYYY'],
});
// Verify actions emitted. These are obtained from pasting the output, but the important thing
// about them is that it's one action for each change, one for the table, one for the field.
await gu.userActionsVerify([
["UpdateRecord", "_grist_Tables_column", 15, {"widgetOptions":
'{"widget":"TextBox","dateFormat":"MM/DD/YYYY","isCustomDateFormat":false,"alignment":"left"}'}],
["UpdateRecord", "_grist_Views_section_field", 145, {"widgetOptions":
'{"widget":"TextBox","dateFormat":"MMMM Do, YYYY","isCustomDateFormat":false,"alignment":"left"}'}],
]);
// Undo, checking that the 2 actions only require 2 undos, and verify.
await gu.undo(2);
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['2012-01-02', 'YYYY-MM-DD'],
B: ['2012-01-02', 'YYYY-MM-DD'],
C: ['2012-01-02', 'YYYY-MM-DD'],
});
});
it('should respect common settings for visibleCol', async function() {
// Same as above but for changing "visibleCol", which involves extra actions to update the
// display helper column.
await gu.userActionsCollect(true);
await gu.clickCell({section: 'A', rowNum: 1, col: 0});
assert.equal(await $('.test-fbuilder-ref-col-select .test-select-row').text(), 'Full Name');
await gu.setVisibleCol('Last Name');
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein', 'Last Name'],
B: ['Klein', 'Last Name'],
C: ['Klein, Cordelia', 'Full Name'],
});
await gu.userActionsVerify([
["UpdateRecord", "_grist_Tables_column", 12, {"visibleCol":3}],
["SetDisplayFormula", "Rates", null, 12, "$Person.Last_Name"],
]);
await gu.clickCell({section: 'C', rowNum: 1, col: 0});
await gu.setVisibleCol('First Name');
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein', 'Last Name'],
B: ['Klein', 'Last Name'],
C: ['Cordelia', 'First Name'],
});
await gu.userActionsVerify([
["UpdateRecord", "_grist_Views_section_field", 141, {"visibleCol":2}],
["SetDisplayFormula", "Rates", 141, null, "$Person.First_Name"],
]);
// Same for changing "visibleCol" to the special "RowID" value.
await gu.clickCell({section: 'A', rowNum: 1, col: 0});
await gu.setVisibleCol('Row ID');
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['People[14]', 'Row ID'],
B: ['People[14]', 'Row ID'],
C: ['Cordelia', 'First Name'],
});
await gu.userActionsVerify([
["UpdateRecord", "_grist_Tables_column", 12, {"visibleCol":0}],
["SetDisplayFormula", "Rates", null, 12, ""],
]);
// Undo here so we can verify that per-field "Row ID" choice overrides per-column choice.
await gu.undo();
await gu.userActionsCollect(true);
await gu.clickCell({section: 'C', rowNum: 1, col: 0});
await gu.setVisibleCol('Row ID');
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein', 'Last Name'],
B: ['Klein', 'Last Name'],
C: ['People[14]', 'Row ID'],
});
// Verify actions emitted.
await gu.userActionsVerify([
["UpdateRecord", "_grist_Views_section_field", 141, {"visibleCol":0}],
["SetDisplayFormula", "Rates", 141, null, ""],
]);
// Undo; we made 4 actions, but already ran one undo earlier.
await gu.undo(3);
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein, Cordelia', 'Full Name'],
B: ['Klein, Cordelia', 'Full Name'],
C: ['Klein, Cordelia', 'Full Name'],
});
});
it('should allow switching to separate settings', async function() {
// Switch B to use separate settings.
await gu.userActionsCollect(true);
await gu.clickCell({section: 'B', rowNum: 1, col: 1});
await gu.fieldSettingsUseSeparate();
await gu.userActionsVerify([
["UpdateRecord", "_grist_Views_section_field", 140, {"widgetOptions":
'{"widget":"TextBox","dateFormat":"YYYY-MM-DD","isCustomDateFormat":false,"alignment":"left"}'}],
]);
// Verify that options are preserved.
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['2012-01-02', 'YYYY-MM-DD'],
B: ['2012-01-02', 'YYYY-MM-DD'],
C: ['2012-01-02', 'YYYY-MM-DD'],
});
// Change option in B and see that A and C are not affected.
await gu.clickCell({section: 'B', rowNum: 1, col: 1});
await gu.dateFormat('MM/DD/YYYY');
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['2012-01-02', 'YYYY-MM-DD'],
B: ['01/02/2012', 'MM/DD/YYYY'],
C: ['2012-01-02', 'YYYY-MM-DD'],
});
// Change option in A and see that B is not affected.
await gu.clickCell({section: 'A', rowNum: 1, col: 1});
await gu.dateFormat('MMMM Do, YYYY');
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['January 2nd, 2012', 'MMMM Do, YYYY'],
B: ['01/02/2012', 'MM/DD/YYYY'],
C: ['2012-01-02', 'YYYY-MM-DD'],
});
await gu.undo(3);
});
it('should allow switching to separate settings for visibleCol', async function() {
// Same as above for changing 'visibleCol' option; after separating, try changing B, then A.
await gu.userActionsCollect(true);
await gu.clickCell({section: 'B', rowNum: 2, col: 0});
await gu.fieldSettingsUseSeparate();
await gu.userActionsVerify([
["UpdateRecord", "_grist_Views_section_field", 136, {"widgetOptions":'{"widget":"Reference"}'}],
["UpdateRecord", "_grist_Views_section_field", 136, {"visibleCol":4}],
["SetDisplayFormula", "Rates", 136, null, "$Person.Full_Name"],
]);
await gu.setVisibleCol('First Name');
await gu.clickCell({section: 'A', rowNum: 2, col: 0});
await gu.setVisibleCol('Last Name');
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein', 'Last Name'],
B: ['Cordelia', 'First Name'],
C: ['Klein, Cordelia', 'Full Name'],
});
await gu.undo(3);
});
it('should allow reverting to common settings', async function() {
// Change column in C to use different settings from A.
await gu.clickCell({section: 'C', rowNum: 1, col: 1});
await gu.dateFormat('MMMM Do, YYYY');
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['2012-01-02', 'YYYY-MM-DD'],
B: ['2012-01-02', 'YYYY-MM-DD'],
C: ['January 2nd, 2012', 'MMMM Do, YYYY'],
});
// Revert C to use common settings. Check that it matches A.
await gu.userActionsCollect(true);
await gu.fieldSettingsRevertToCommon();
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['2012-01-02', 'YYYY-MM-DD'],
B: ['2012-01-02', 'YYYY-MM-DD'],
C: ['2012-01-02', 'YYYY-MM-DD'],
});
await gu.userActionsVerify([
["UpdateRecord", "_grist_Views_section_field", 145, {"widgetOptions":""}],
]);
await gu.undo(2);
});
it('should allow reverting to common settings for visibleCol', async function() {
// Same as above for reverting 'visiblecCol'.
await gu.clickCell({section: 'C', rowNum: 2, col: 0});
await gu.setVisibleCol('Last Name');
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein, Cordelia', 'Full Name'],
B: ['Klein, Cordelia', 'Full Name'],
C: ['Klein', 'Last Name'],
});
await gu.userActionsCollect(true);
await gu.fieldSettingsRevertToCommon();
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein, Cordelia', 'Full Name'],
B: ['Klein, Cordelia', 'Full Name'],
C: ['Klein, Cordelia', 'Full Name'],
});
await gu.userActionsVerify([
["UpdateRecord", "_grist_Views_section_field", 141, {"widgetOptions":""}],
["UpdateRecord", "_grist_Views_section_field", 141, {"visibleCol":0}],
["SetDisplayFormula", "Rates", 141, null, ""],
]);
await gu.undo(2);
});
it('should allow saving separate settings as common', async function() {
// Change column C to use different settings from A.
await gu.clickCell({section: 'C', rowNum: 1, col: 1});
await gu.dateFormat('MMMM Do, YYYY');
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['2012-01-02', 'YYYY-MM-DD'],
B: ['2012-01-02', 'YYYY-MM-DD'],
C: ['January 2nd, 2012', 'MMMM Do, YYYY'],
});
// Save C settings as common settings. Check that A and B now match.
await gu.userActionsCollect(true);
await gu.fieldSettingsSaveAsCommon();
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['January 2nd, 2012', 'MMMM Do, YYYY'],
B: ['January 2nd, 2012', 'MMMM Do, YYYY'],
C: ['January 2nd, 2012', 'MMMM Do, YYYY'],
});
await gu.userActionsVerify([
["UpdateRecord", "_grist_Tables_column", 15, {"widgetOptions":
'{"widget":"TextBox","dateFormat":"MMMM Do, YYYY","isCustomDateFormat":false,"alignment":"left"}'}],
["UpdateRecord", "_grist_Views_section_field", 145, {"widgetOptions":""}],
]);
await gu.undo(2);
await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {
A: ['2012-01-02', 'YYYY-MM-DD'],
B: ['2012-01-02', 'YYYY-MM-DD'],
C: ['2012-01-02', 'YYYY-MM-DD'],
});
});
it('should allow saving separate settings as common for visibleCol', async function() {
// Same as above for saving 'visiblecCol'.
await gu.clickCell({section: 'C', rowNum: 2, col: 0});
await gu.setVisibleCol('Last Name');
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein, Cordelia', 'Full Name'],
B: ['Klein, Cordelia', 'Full Name'],
C: ['Klein', 'Last Name'],
});
await gu.userActionsCollect(true);
await gu.fieldSettingsSaveAsCommon();
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein', 'Last Name'],
B: ['Klein', 'Last Name'],
C: ['Klein', 'Last Name'],
});
await gu.userActionsVerify([
["UpdateRecord", "_grist_Tables_column", 12, {"visibleCol":3}],
["SetDisplayFormula", "Rates", null, 12, "$Person.Last_Name"],
["UpdateRecord", "_grist_Views_section_field", 141, {"widgetOptions":""}],
["UpdateRecord", "_grist_Views_section_field", 141, {"visibleCol":0}],
["SetDisplayFormula", "Rates", 141, null, ""],
]);
await gu.undo(2);
await checkSections({rowNum: 2, col: 0}, () => $('.test-fbuilder-ref-col-select .test-select-row').text(), {
A: ['Klein, Cordelia', 'Full Name'],
B: ['Klein, Cordelia', 'Full Name'],
C: ['Klein, Cordelia', 'Full Name'],
});
});
});

@ -0,0 +1,148 @@
import { assert } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
/**
* This test verifies that when a section is auto-filtered using section-linking, newly added
* records automatically get assigned the filter value.
*/
describe('FillLinkedRecords.ntest', function() {
const cleanup = test.setupTestSuite(this);
gu.bigScreen();
before(async function() {
await gu.supportOldTimeyTestCode();
await gu.useFixtureDoc(cleanup, "Favorite_Films.grist", true);
await gu.toggleSidePanel("left", "close");
});
afterEach(function() {
return gu.checkForErrors();
});
it('should auto-fill values when typing into add-row', async function() {
await gu.openSidePane('view');
await $('.test-config-data').click();
await gu.actions.selectTabView('All');
// Link the sections first since the sample document start with no links.
// Connect Friends -> Films
await gu.getSection('Films record').click();
await $('.test-right-select-by').click();
await $('.test-select-row:contains(Friends record)').click();
await gu.waitForServer();
// Connect Films -> Performances grid
await gu.getSection('Performances record').click();
await $('.test-right-select-by').click();
await $('.test-select-row:contains(Films record)').click();
await gu.waitForServer();
// Connect Films -> Performances detail
await gu.getSection('Performances detail').click();
await $('.test-right-select-by').click();
await $('.test-select-row:contains(Films record)').click();
await gu.waitForServer();
// Now pick a movie, and select the Performances grid.
await gu.clickCell({section: 'Films record', col: 0, rowNum: 2});
await gu.actions.viewSection('Performances record').selectSection();
// It should have just two records initially, with an Add-New row.
assert.equal(await gu.getGridLastRowText(), '3');
assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [2, 3]}), [
'Robin Wright', 'Forrest Gump',
'', '']);
// Add a record, and ensure it shows up, and has Film auto-filled in.
await gu.userActionsCollect(true);
await gu.addRecord(['Rebecca Williams']);
await gu.userActionsVerify([
["AddRecord", "Performances", null, {"Actor": "Rebecca Williams", "Film": 2}]
]);
assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [2, 3]}), [
'Robin Wright', 'Forrest Gump',
'Rebecca Williams', 'Forrest Gump']);
assert.equal(await gu.getGridLastRowText(), '4');
});
it('should auto-fill values when inserting records', async function() {
// Click another movie, and check the values we see.
await gu.clickCell({section: 'Films record', col: 0, rowNum: 5});
await gu.actions.viewSection('Performances record').selectSection();
assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [1, 2]}), [
'Christian Bale', 'The Dark Knight',
'Heath Ledger', 'The Dark Knight'
]);
assert.equal(await gu.getGridLastRowText(), '3');
// Add a couple of records in Performances grid using keyboard shortcuts.
await gu.clickCell({col: 0, rowNum: 3});
await gu.sendKeys([$.MOD, $.SHIFT, $.ENTER]);
await gu.clickCell({col: 0, rowNum: 1});
await gu.sendKeys([$.MOD, $.ENTER]);
await gu.waitForServer();
// Verify they are shown where expected with Film filled in.
assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [1, 2, 3, 4]}), [
'Christian Bale', 'The Dark Knight',
'', 'The Dark Knight',
'Heath Ledger', 'The Dark Knight',
'', 'The Dark Knight',
]);
assert.equal(await gu.getGridLastRowText(), '5');
// Add a record in Performances detail using keyboard shortcuts.
await gu.actions.viewSection('Performances detail').selectSection();
assert.deepEqual(await gu.getDetailValues({cols: ['Actor', 'Film'], rowNums: [1]}),
['Christian Bale', 'The Dark Knight']);
await gu.sendKeys([$.MOD, $.ENTER]);
await gu.waitForServer();
// Verify the record is shown with Film filled in, and added to the grid section too.
// Note: rowNum needs to be 1 now for card views without row numbers shown.
assert.deepEqual(await gu.getDetailValues({cols: ['Actor', 'Film'], rowNums: [1]}),
['', 'The Dark Knight']);
await gu.actions.viewSection('Performances record').selectSection();
assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [1, 2, 3, 4, 5]}), [
'Christian Bale', 'The Dark Knight',
'', 'The Dark Knight',
'', 'The Dark Knight',
'Heath Ledger', 'The Dark Knight',
'', 'The Dark Knight',
]);
assert.equal(await gu.getGridLastRowText(), '6');
// Undo the record insertions.
await gu.undo(3);
});
it('should auto-fill when pasting data', async function() {
// Click a movie, and check the values we expect to start with.
await gu.clickCell({section: 'Films record', col: 0, rowNum: 6});
await gu.actions.viewSection('Performances record').selectSection();
assert.deepEqual(await gu.getGridValues({cols: [0, 1, 2], rowNums: [1, 4]}), [
'Chris Evans', 'The Avengers', 'Steve Rogers',
'Scarlett Johansson', 'The Avengers', 'Natasha Romanoff',
]);
assert.equal(await gu.getGridLastRowText(), '5');
// Copy a range of three values, and paste them into the Add-New row.
await gu.clickCell({col: 2, rowNum: 1});
await gu.sendKeys([$.SHIFT, $.DOWN, $.DOWN], $.COPY);
await gu.clickCell({col: 2, rowNum: 5});
await gu.sendKeys($.PASTE);
await gu.waitForServer();
// Verify that three new rows now show up, with Film auto-filled.
assert.deepEqual(await gu.getGridValues({cols: [0, 1, 2], rowNums: [1, 4, 5, 6, 7]}), [
'Chris Evans', 'The Avengers', 'Steve Rogers',
'Scarlett Johansson', 'The Avengers', 'Natasha Romanoff',
'', 'The Avengers', 'Steve Rogers',
'', 'The Avengers', 'Tony Stark',
'', 'The Avengers', 'Bruce Banner',
]);
assert.equal(await gu.getGridLastRowText(), '8');
});
});

@ -0,0 +1,126 @@
/**
* NOTE: This test is migrated to new UI as test/nbrowser/GridOptions.ts.
* Remove this version once old UI is no longer supported.
*/
import { assert, driver } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
describe("GridOptions.ntest", function() {
const cleanup = test.setupTestSuite(this);
// ====== Some Helpers ======
let secNames = ["COUNTRY", "CITY", "COUNTRYLANGUAGE"];
let switchTo = (i) =>
gu.actions.viewSection(secNames[i]).selectSection();
/* Test that styles on the given section match the specified flags
* sec: index into secNames
* hor/vert/zebra: boolean flags
*/
async function assertHVZ(sec, hor, vert, zebra) {
let testClasses =
['record-hlines', 'record-vlines', 'record-zebra'];
let flags = [hor, vert, zebra];
let cell = await gu.getCell({rowNum: 1, col: 0, section: secNames[sec]});
let row = await cell.findClosest('.record');
const rowClasses = await row.classList();
testClasses.forEach( (cls, i) => {
if(flags[i]) { assert.include(rowClasses, cls);}
else { assert.notInclude(rowClasses, cls); }
});
}
// ====== Prepare Document ======
before(async function() {
await gu.supportOldTimeyTestCode();
await gu.useFixtureDoc(cleanup, "World-v10.grist", true);
await $('.test-gristdoc').wait();
});
beforeEach(async function() {
//Prepare consistent view
await gu.actions.selectTabView("Country");
await gu.openSidePane('view');
await $(".test-grid-options").wait(assert.isDisplayed);
});
afterEach(function() {
return gu.checkForErrors();
});
// ====== MAIN TESTS ======
it('should only be visible on grid view/summary view', async function() {
let getOptions = () => $(".test-grid-options");
await assert.isPresent(getOptions());
// check that it doesnt show up in detail view
await gu.actions.viewSection("COUNTRY Card List").selectSection();
await assert.isPresent(getOptions(), false);
// check that it shows up on the grid-views
await gu.actions.viewSection("COUNTRY").selectSection();
await assert.isDisplayed(getOptions());
await gu.actions.viewSection("CITY").selectSection();
await assert.isDisplayed(getOptions());
await gu.actions.viewSection("COUNTRYLANGUAGE").selectSection();
await assert.isDisplayed(getOptions());
});
it('should set and persist styles on a grid', async function() {
// get handles on elements
let h = ".test-h-grid-button input";
let v = ".test-v-grid-button input";
let z = ".test-zebra-stripe-button input";
// should start with v+h gridlines, no zebra
await assertHVZ(0, true, true, false);
// change values on all the sections
await switchTo(0);
await $(z).scrollIntoView().click();
await switchTo(1);
await $(h).click();
await $(v).click();
await switchTo(2);
await $(h).click(); // turn off
await $(z).click(); // turn on
await gu.waitForServer();
await assertHVZ(0, true, true, true); // all on
await assertHVZ(1, false, false, false); // all off
await assertHVZ(2, false, true, true); // -h +v +z
// ensure that values persist after reload
await driver.navigate().refresh();
//await $.injectIntoPage();
await gu.waitForDocToLoad();
await assertHVZ(0, true, true, true); // all on
await assertHVZ(1, false, false, false); // all off
await assertHVZ(2, false, true, true); // -h +v +z
});
it('should set .record-even on even-numbered rows', async function() {
let rowClasses = row =>
gu.getCell({rowNum: row, col: 0}).closest('.record').classList();
await switchTo(0);
assert.notInclude(await rowClasses(1), 'record-even', "row 1 should be odd");
assert.include(await rowClasses(2), 'record-even', "row 2 should be even");
});
});

@ -0,0 +1,17 @@
import { assert, driver } from 'mocha-webdriver';
import { gu, server, test } from 'test/nbrowser/gristUtil-nbrowser';
describe('Health.ntest', function() {
test.setupTestSuite(this);
before(async function() {
await gu.supportOldTimeyTestCode();
});
it('make sure the health check endpoint returns something', async function() {
await driver.get(server.getHost() + "/status")
const txt = await driver.getPageSource();
assert.match(txt, /Grist .* is alive/);
});
});

@ -9,7 +9,7 @@ import os from "os";
import path from 'path';
describe("Localization", function() {
this.timeout(20000);
this.timeout(60000);
setupTestSuite();
before(async function() {

@ -0,0 +1,161 @@
/* global window */
import { assert, driver } from 'mocha-webdriver';
import { $, gu, test } from 'test/nbrowser/gristUtil-nbrowser';
describe('NewDocument.ntest', function() {
test.setupTestSuite(this);
before(async function() {
await gu.supportOldTimeyTestCode();
});
afterEach(function() {
return gu.checkForErrors();
});
it('should create new Untitled document', async function() {
this.timeout(10000);
await gu.actions.createNewDoc('Untitled');
assert.equal(await gu.actions.getDocTitle(), 'Untitled');
assert.equal(await driver.getTitle(), 'Untitled - Grist');
assert.equal(await $('.active_section .test-viewsection-title').wait().text(), 'TABLE1');
await gu.waitForServer();
});
it('should start with a 1x3 grid', async function() {
await $('.record.record-add').wait();
assert.lengthOf(await $('.grid_view_data .record:not(.column_names)').array(), 1, 'should have 1 row ("add" row)');
assert.lengthOf(await $('.column_names .column_name').array(), 4, 'should have 3 columns and 1 "add" column');
});
it('should have first cell selected', async function() {
assert.isDisplayed(await gu.getCellRC(0, 0).find('.active_cursor'));
});
it('should open notify toasts on errors', async function() {
// Verify that uncaught exceptions and errors from server cause the notifications box to open.
// For a plain browser error, we attach an error-throwing handler to click-on-logo.
await driver.executeScript(
'setTimeout(() => window.gristApp.testTriggerError("Our fake error"))', 0);
// Wait for the notifications window to open and check it has the error we expect.
await $('.test-notifier-toast-message').wait(1, assert.isDisplayed);
assert.match(await $('.test-notifier-toast-message').last().text(), /Our fake error/);
// Close the notifications window.
await $(".test-notifier-toast-close").click();
await assert.isPresent($('.test-notifier-toast-message'), false);
// Try a server command that should fail. We need a reasonble timeout for executeAsyncScript.
await driver.manage().setTimeouts({script: 500});
let result = await driver.executeAsyncScript(() => {
var cb = arguments[arguments.length - 1];
window.gristApp.comm.getDocList()
.then(
newName => cb("unexpected success"),
err => { cb(err.toString()); throw err; }
);
});
assert.match(result, /Unknown method getDocList/);
// Now make sure the notifications window is open and has the error we expect.
await assert.isDisplayed($('.test-notifier-toast-message'));
assert.match(await $('.test-notifier-toast-message').last().text(), /Unknown method getDocList/);
// Close the notifications window.
await $(".test-notifier-toast-close").click();
await assert.isPresent($('.test-notifier-toast-message'), false);
assert.deepEqual(await driver.executeScript(() => window.getAppErrors()),
['Our fake error', 'Unknown method getDocList']);
await driver.executeScript(
'setTimeout(() => window.gristApp.topAppModel.notifier.clearAppErrors())');
});
describe('Cell editing', function() {
it('should add rows on entering new data', async function() {
assert.equal(await gu.getGridRowCount(), 1);
await gu.getCellRC(0, 0).click();
await gu.sendKeys('hello', $.ENTER);
await gu.waitForServer();
await gu.getCellRC(1, 1).click();
await gu.sendKeys('world', $.ENTER);
await gu.waitForServer();
assert.equal(await gu.getGridRowCount(), 3);
});
it('should edit on Enter, cancel on Escape, save on Enter', async function() {
var cell_1_b = gu.getCellRC(0, 1);
assert.equal(await cell_1_b.text(), '');
await cell_1_b.click();
await gu.sendKeys($.ENTER);
await $('.test-widget-text-editor').wait();
await gu.sendKeys('foo', $.ESCAPE);
await gu.waitForServer();
assert.equal(await cell_1_b.text(), '');
await gu.sendKeys($.ENTER);
await $('.test-widget-text-editor').wait();
await gu.sendKeys('bar', $.ENTER);
await gu.waitForServer();
assert.equal(await cell_1_b.text(), 'bar');
});
it('should append to cell with content on Enter', async function() {
var cell_1_a = gu.getCellRC(0, 0);
assert.equal(await cell_1_a.text(), 'hello');
await cell_1_a.click();
await gu.sendKeys($.ENTER);
await $('.test-widget-text-editor').wait();
assert.equal(await $('.test-widget-text-editor textarea').val(), 'hello');
await gu.sendKeys(', world!', $.ENTER);
await gu.waitForServer();
assert.equal(await cell_1_a.text(), 'hello, world!');
});
it('should clear data in selected cells on Backspace and Delete', async function() {
let testDelete = async function(delKey) {
// should clear a single cell
var cell_1_a = gu.getCellRC(0, 0);
await cell_1_a.click();
await gu.sendKeys('A1', $.ENTER);
await gu.waitForServer();
assert.equal(await cell_1_a.text(), 'A1');
await cell_1_a.click();
await gu.sendKeys(delKey);
await gu.waitForServer();
assert.equal(await cell_1_a.text(), '');
// should clear a selection of cells
await gu.enterGridValues(0, 0, [['A1', 'A2'], ['B1', 'B2']]);
await gu.waitForServer();
assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1] }), ['A1', 'B1', 'A2', 'B2']);
await cell_1_a.click();
await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN], delKey);
await gu.waitForServer();
assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1] }), ['', '', '', '']);
// should clear a selection of cells with a formula column
await gu.enterGridValues(0, 0, [['A1', 'A2'], ['B1', 'B2']]);
await gu.clickCellRC(0, 2);
await gu.sendKeys('=', '$A', $.ENTER);
await gu.waitForServer();
assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1, 2] }),
['A1', 'B1', 'A1', 'A2', 'B2', 'A2']);
await gu.clickCellRC(0, 1);
await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN], delKey);
await gu.waitForServer();
assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1, 2] }),
[ 'A1', '', 'A1', 'A2', '', 'A2' ]);
};
await testDelete($.BACK_SPACE);
await testDelete($.DELETE);
});
});
});

@ -7,7 +7,7 @@ import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import values = require('lodash/values');
describe('Pages', function() {
this.timeout(30000);
this.timeout(60000);
setupTestSuite();
let doc: DocCreationInfo;
let api: UserAPI;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save