(core) move more tests to grist-core

Summary:
 * Tie build and run-time docker base images to a consistent version (buster)
 * Extend the test login system activated by GRIST_TEST_LOGIN to ease porting tests that currently rely on cognito (many)
 * Make org resets work in absence of billing endpoints
 * When in-memory session caches are used, add missing invalidation steps
 * Pass org information through sign-ups/sign-ins more carefully
 * For CORS, explicitly trust GRIST_HOST origin when set
 * Move some fixtures and tests to core, focussing on tests that cover existing failures or are in the set of tests run on deployments
 * Retain regular `test` target to run the test suite directly, without docker
 * Add a `test:smoke` target to run a single simple test without `GRIST_TEST_LOGIN` activated
 * Add a `test:docker` target to run the tests against a grist-core docker image - since tests rely on certain fixture teams/docs, added `TEST_SUPPORT_API_KEY` and `TEST_ADD_SAMPLES` flags to ease porting

The tests ported were `nbrowser` tests: `ActionLog.ts` (the first test I tend to port to anything, out of habit), `Fork.ts` (exercises a lot of doc creation paths), `HomeIntro.ts` (a lot of DocMenu exercise), and `DuplicateDocument.ts` (covers a feature known to be failing prior to this diff, the CORS tweak resolves it).

Test Plan: Manually tested via `buildtools/build_core.sh`. In follow up, I want to add running the `test:docker` target in grist-core's workflows. In jenkins, only the smoke test is run. There'd be an argument for running all tests, but they include particularly slow tests, and are duplicates of tests already run (in different configuration admittedly), so I'd like to try first just using them in grist-core to gate updates to any packaged version of Grist (the docker image currently).

Reviewers: alexmojaki

Reviewed By: alexmojaki

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D3176
pull/115/head
Paul Fitzpatrick 2 years ago
parent 307966e84f
commit d99db8d016

@ -2,7 +2,7 @@
## Build stage
################################################################################
FROM node:14 as builder
FROM node:14-buster as builder
# Install all node dependencies.
ADD package.json package.json

@ -25,6 +25,7 @@
import {unsavedChanges} from 'app/client/components/UnsavedChanges';
import {UrlState} from 'app/client/lib/UrlState';
import {decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState, useNewUI} from 'app/common/gristUrls';
import {addOrgToPath} from 'app/common/urlUtils';
import {Document} from 'app/common/UserAPI';
import isEmpty = require('lodash/isEmpty');
import isEqual = require('lodash/isEqual');
@ -102,7 +103,7 @@ function _getCurrentUrl(): string {
// Helper for getLoginUrl()/getLogoutUrl().
function _getLoginLogoutUrl(method: 'login'|'logout'|'signin'|'signup', nextUrl: string): string {
const startUrl = new URL(window.location.href);
startUrl.pathname = '/' + method;
startUrl.pathname = addOrgToPath('', window.location.href) + '/' + method;
startUrl.searchParams.set('next', nextUrl);
return startUrl.href;
}

@ -11,8 +11,9 @@ export async function resetOrg(api: UserAPI, org: string|number) {
throw new Error('user must be an owner of the org to be reset');
}
const billing = api.getBillingAPI();
const account = await billing.getBillingAccount();
if (!account.managers.some(manager => (manager.id === session.user.id))) {
// If billing api is not available, don't bother setting billing manager.
const account = await billing.getBillingAccount().catch(e => null);
if (account && !account.managers.some(manager => (manager.id === session.user.id))) {
throw new Error('user must be a billing manager');
}
const wss = await api.getOrgWorkspaces(org);
@ -31,7 +32,7 @@ export async function resetOrg(api: UserAPI, org: string|number) {
await api.updateOrgPermissions(org, permissions);
// For non-individual accounts, update billing managers (individual accounts will
// throw an error if we try to do this).
if (!account.individual) {
if (account && !account.individual) {
const managers: ManagerDelta = { users: {} };
for (const user of account.managers) {
if (user.id !== session.user.id) {

@ -10,8 +10,8 @@ import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
import {expressWrap} from 'app/server/lib/expressWrap';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import * as log from 'app/server/lib/log';
import {addPermit, getDocScope, getScope, integerParam, isParameterOn, sendOkReply,
sendReply, stringParam} from 'app/server/lib/requestUtils';
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
isParameterOn, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils';
import {IWidgetRepository} from 'app/server/lib/WidgetRepository';
import {Request} from 'express';
@ -427,6 +427,7 @@ export class ApiServer {
// Modify session copy in request. Will be saved to persistent storage before responding
// by express-session middleware.
linkOrgWithEmail(mreq.session, req.body.email, domain || '');
clearSessionCacheIfNeeded(req, {sessionID: mreq.sessionID});
return sendOkReply(req, res, {email});
} catch (e) {
throw new ApiError('email not available', 403);

@ -33,7 +33,7 @@ import {DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
import {expressWrap, jsonErrorHandler} from 'app/server/lib/expressWrap';
import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg';
import {addGoogleAuthEndpoint} from "app/server/lib/GoogleAuth";
import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer';
import {GristLoginMiddleware, GristServer, RequestWithGrist} from 'app/server/lib/GristServer';
import {initGristSessions, SessionStore} from 'app/server/lib/gristSessions';
import {HostedStorageManager} from 'app/server/lib/HostedStorageManager';
import {IBilling} from 'app/server/lib/IBilling';
@ -45,7 +45,8 @@ import {IPermitStore} from 'app/server/lib/Permit';
import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places';
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
import {PluginManager} from 'app/server/lib/PluginManager';
import {adaptServerUrl, addOrgToPath, addPermit, getScope, optStringParam, RequestWithGristInfo, stringParam,
import {adaptServerUrl, addOrgToPath, addOrgToPathIfNeeded, addPermit, getScope,
optStringParam, RequestWithGristInfo, stringParam,
TEST_HTTPS_OFFSET, trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
import {getDatabaseUrl} from 'app/server/lib/serverUtils';
@ -53,6 +54,7 @@ import {Sessions} from 'app/server/lib/Sessions';
import * as shutdown from 'app/server/lib/shutdown';
import {TagChecker} from 'app/server/lib/TagChecker';
import {startTestingHooks} from 'app/server/lib/TestingHooks';
import {getTestLoginSystem} from 'app/server/lib/TestLogin';
import {addUploadRoute} from 'app/server/lib/uploads';
import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRepository';
import axios from 'axios';
@ -86,10 +88,6 @@ export interface FlexServerOptions {
pluginUrl?: string;
}
export interface RequestWithGrist extends express.Request {
gristServer?: GristServer;
}
export class FlexServer implements GristServer {
public readonly create = create;
public tagChecker: TagChecker;
@ -761,7 +759,7 @@ export class FlexServer implements GristServer {
// TODO: We could include a third mock provider of login/logout URLs for better tests. Or we
// could create a mock SAML identity provider for testing this using the SAML flow.
const loginSystem = await getLoginSystem();
const loginSystem = await (process.env.GRIST_TEST_LOGIN ? getTestLoginSystem() : getLoginSystem());
this._loginMiddleware = await loginSystem.getMiddleware(this);
this._getLoginRedirectUrl = tbind(this._loginMiddleware.getLoginRedirectUrl, this._loginMiddleware);
this._getSignUpRedirectUrl = tbind(this._loginMiddleware.getSignUpRedirectUrl, this._loginMiddleware);
@ -800,7 +798,7 @@ export class FlexServer implements GristServer {
}
public async addLoginRoutes() {
if (this._check('login', 'org', 'sessions', 'homedb')) { return; }
if (this._check('login', 'org', 'sessions', 'homedb', 'hosts')) { return; }
// TODO: We do NOT want Comm here at all, it's only being used for handling sessions, which
// should be factored out of it.
this.addComm();
@ -813,7 +811,7 @@ export class FlexServer implements GristServer {
// we'll need it when we come back from Cognito.
forceSessionChange(mreq.session);
// Redirect to "/" on our requested hostname (in test env, this will redirect further)
const next = req.protocol + '://' + req.get('host') + '/';
const next = getOrgUrl(req);
if (signUp === null) {
// Like redirectToLogin in Authorizer, redirect to sign up if it doesn't look like the
// user has ever logged in on this browser.
@ -839,20 +837,38 @@ export class FlexServer implements GristServer {
this.app.get('/test/login', expressWrap(async (req, res) => {
log.warn("Serving unauthenticated /test/login endpoint, made available because GRIST_TEST_LOGIN is set.");
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
const profile: UserProfile = {
email: optStringParam(req.query.email) || 'chimpy@getgrist.com',
name: optStringParam(req.query.name) || 'Chimpy McBanana',
};
await scopedSession.updateUserProfile(req, profile);
// Query parameter is called "username" for compatibility with Cognito.
const email = optStringParam(req.query.username);
if (email) {
const redirect = optStringParam(req.query.next);
const profile: UserProfile = {
email,
name: optStringParam(req.query.name) || email,
};
const url = new URL(redirect || getOrgUrl(req));
// Make sure we update session for org we'll be redirecting to.
const {org} = await this._hosts.getOrgInfoFromParts(url.hostname, url.pathname);
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req, { org });
await scopedSession.updateUserProfile(req, profile);
this._sessions.clearCacheIfNeeded({email, org});
if (redirect) { return res.redirect(redirect); }
}
res.send(`<!doctype html>
<html><body>
<p>Logged in as ${JSON.stringify(profile)}.<p>
<form>
<input type=text name=email placeholder=email>
<input type=text name=name placeholder=name>
<input type=submit value=login>
</form>
<div class="modal-content-desktop">
<h1>A Very Creduluous Login Page</h1>
<p>
A minimal login screen to facilitate testing.
I'll believe anything you tell me.
</p>
<form>
<div>Email <input type=text name=username placeholder=email /></div>
<div>Name <input type=text name=name placeholder=name /></div>
<div>Dummy password <input type=text name=password placeholder=unused ></div>
<input type=hidden name=next value="${req.query.next || ''}">
<div><input type=submit name=signInSubmitButton value=login></div>
</form>
</div>
</body></html>
`);
}));
@ -862,7 +878,7 @@ export class FlexServer implements GristServer {
const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);
// If 'next' param is missing, redirect to "/" on our requested hostname.
const next = optStringParam(req.query.next) || (req.protocol + '://' + req.get('host') + '/');
const next = optStringParam(req.query.next) || getOrgUrl(req);
const redirectUrl = await this._getLogoutRedirectUrl(req, new URL(next));
// Clear session so that user needs to log in again at the next request.
@ -871,6 +887,8 @@ export class FlexServer implements GristServer {
const expressSession = (req as any).session;
if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; }
await scopedSession.clearScopedSession(req);
// TODO: limit cache clearing to specific user.
this._sessions.clearCacheIfNeeded();
resp.redirect(redirectUrl);
}));
@ -1639,6 +1657,11 @@ function trustOriginHandler(req: express.Request, res: express.Response, next: e
}
}
// Get url to the org associated with the request.
function getOrgUrl(req: express.Request) {
return req.protocol + '://' + req.get('host') + addOrgToPathIfNeeded(req, '/');
}
// Set Cache-Control header to "no-cache"
function noCaching(req: express.Request, res: express.Response, next: express.NextFunction) {
res.header("Cache-Control", "no-cache");

@ -47,3 +47,7 @@ export interface GristLoginMiddleware {
// Returns arbitrary string for log.
addEndpoints(app: express.Express): Promise<string>;
}
export interface RequestWithGrist extends express.Request {
gristServer?: GristServer;
}

@ -193,7 +193,7 @@ export class SamlConfig {
const samlNameId = samlUser.name_id;
log.info(`SamlConfig: got SAML response for ${profile.email} (${profile.name}) redirecting to ${redirectUrl}`);
const scopedSession = sessions.getOrCreateSessionFromRequest(req, state.sessionId);
const scopedSession = sessions.getOrCreateSessionFromRequest(req, {sessionId: state.sessionId});
await scopedSession.operateOnScopedSession(req, async (user) => Object.assign(user, {
profile,
samlSessionIndex,

@ -32,9 +32,12 @@ export class Sessions {
* Get the session id and organization from the request (or just pass it in if known), and
* return the identified session.
*/
public getOrCreateSessionFromRequest(req: Request, sessionId?: string): ScopedSession {
const sid = sessionId || this.getSessionIdFromRequest(req);
const org = (req as any).org;
public getOrCreateSessionFromRequest(req: Request, options?: {
sessionId?: string,
org?: string
}): ScopedSession {
const sid = options?.sessionId ?? this.getSessionIdFromRequest(req);
const org = options?.org ?? (req as any).org;
if (!sid) { throw new Error("session not found"); }
return this.getOrCreateSession(sid, org, ''); // TODO: allow for tying to a preferred user.
}
@ -51,6 +54,23 @@ export class Sessions {
return this._sessions.get(key)!;
}
/**
* Called when a session is modified, and any caching should be invalidated.
* Currently just removes all caching, if there is any. This caching is a bit
* of a weird corner of Grist, it is used in development for historic reasons
* but not in production.
* TODO: make more fine grained, or rethink.
*/
public clearCacheIfNeeded(options?: {
email?: string,
org?: string|null,
sessionID?: string,
}) {
if (!(process.env.GRIST_HOST || process.env.GRIST_HOSTED)) {
this._sessions.clear();
}
}
/**
* Returns the sessionId from the signed grist cookie.
*/

@ -0,0 +1,42 @@
import { GristLoginSystem, GristServer } from 'app/server/lib/GristServer';
import { Request } from 'express';
/**
* Return a login system for testing. Just enough to use the test/login endpoint
* available when GRIST_TEST_LOGIN=1 is set.
*/
export async function getTestLoginSystem(): Promise<GristLoginSystem> {
return {
async getMiddleware(gristServer: GristServer) {
async function getLoginRedirectUrl(req: Request, url: URL) {
// The "gristlogin" query parameter does nothing except make tests
// that expect hosted cognito happy (they check for gristlogin in url).
const target = new URL(gristServer.getHomeUrl(req, 'test/login?gristlogin=1'));
target.searchParams.append('next', url.href);
return target.href || url.href;
}
return {
getLoginRedirectUrl,
async getLogoutRedirectUrl(req: Request, url: URL) {
return url.href;
},
getSignUpRedirectUrl: getLoginRedirectUrl,
async addEndpoints() {
// Make sure support user has a test api key if needed.
if (process.env.TEST_SUPPORT_API_KEY) {
const dbManager = gristServer.getHomeDBManager();
const user = await dbManager.getUserByLogin('support@getgrist.com');
if (user) {
user.apiKey = process.env.TEST_SUPPORT_API_KEY;
await user.save();
}
}
return "test-login";
},
};
},
async deleteUser() {
// nothing to do
},
};
}

@ -78,7 +78,8 @@ export class TestingHooks implements ITestingHooks {
const sessionId = this._comm.getSessionIdFromCookie(gristSidCookie);
const scopedSession = this._comm.getOrCreateSession(sessionId, {org});
const req = {} as Request;
return await scopedSession.updateUserProfile(req, profile);
await scopedSession.updateUserProfile(req, profile);
this._server.getSessions().clearCacheIfNeeded({email: profile?.email, org});
}
public async setServerVersion(version: string|null): Promise<void> {

@ -4,6 +4,7 @@ import * as gutil from 'app/common/gutil';
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {RequestWithGrist} from 'app/server/lib/GristServer';
import * as log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit';
import {Request, Response} from 'express';
@ -74,6 +75,7 @@ export function trustOrigin(req: Request, resp: Response): boolean {
// Note that the request origin is undefined for non-CORS requests.
const origin = req.get('origin');
if (!origin) { return true; } // Not a CORS request.
if (process.env.GRIST_HOST && req.hostname === process.env.GRIST_HOST) { return true; }
if (!allowHost(req, new URL(origin))) { return false; }
// For a request to a custom domain, the full hostname must match.
@ -254,3 +256,16 @@ export function getOriginUrl(req: Request) {
const protocol = req.get("X-Forwarded-Proto") || req.protocol;
return `${protocol}://${host}`;
}
/**
* In some configurations, session information may be cached by the server.
* When session information changes, give the server a chance to clear its
* cache if needed.
*/
export function clearSessionCacheIfNeeded(req: Request, options?: {
email?: string,
org?: string|null,
sessionID?: string,
}) {
(req as RequestWithGrist).gristServer?.getSessions().clearCacheIfNeeded(options);
}

@ -4,9 +4,8 @@ import {FetchUrlOptions, FileUploadResult, UPLOAD_URL_PATH, UploadResult} from '
import {getAuthorizedUserId, getTransitiveHeaders, getUserId, isSingleUserMode,
RequestWithLogin} from 'app/server/lib/Authorizer';
import {expressWrap} from 'app/server/lib/expressWrap';
import {RequestWithGrist} from 'app/server/lib/FlexServer';
import {downloadFromGDrive, isDriveUrl} from 'app/server/lib/GoogleImport';
import {GristServer} from 'app/server/lib/GristServer';
import {GristServer, RequestWithGrist} from 'app/server/lib/GristServer';
import {guessExt} from 'app/server/lib/guessExt';
import * as log from 'app/server/lib/log';
import {optStringParam} from 'app/server/lib/requestUtils';

@ -6,11 +6,13 @@
"homepage": "https://github.com/gristlabs/grist-core",
"repository": "git://github.com/gristlabs/grist-core.git",
"scripts": {
"start": "tsc --build -w --preserveWatchOutput & catw app/client/*.css app/client/*/*.css -o static/bundle.css -v & webpack --config buildtools/webpack.config.js --mode development --watch --hide-modules & NODE_PATH=_build:_build/stubs nodemon -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js & wait",
"start": "tsc --build -w --preserveWatchOutput & catw app/client/*.css app/client/*/*.css -o static/bundle.css -v & webpack --config buildtools/webpack.config.js --mode development --watch --hide-modules & NODE_PATH=_build:_build/stubs nodemon --delay 1 -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js & wait",
"install:python": "buildtools/prepare_python.sh",
"build:prod": "tsc --build && webpack --config buildtools/webpack.config.js --mode production && cat app/client/*.css app/client/*/*.css > static/bundle.css",
"start:prod": "NODE_PATH=_build:_build/stubs node _build/stubs/app/server/server.js",
"test": "NODE_PATH=_build:_build/stubs 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 NODE_PATH=_build:_build/stubs mocha _build/test/nbrowser/*.js",
"test:smoke": "NODE_PATH=_build:_build/stubs mocha _build/test/nbrowser/Smoke.js",
"test:docker": "./test/test_under_docker.sh"
},
"keywords": [
"grist",
@ -96,7 +98,7 @@
"file-type": "14.1.4",
"fs-extra": "7.0.0",
"grain-rpc": "0.1.7",
"grainjs": "1.0.1",
"grainjs": "1.0.2",
"highlight.js": "9.13.1",
"i18n-iso-countries": "6.1.0",
"image-size": "0.6.3",

@ -18,6 +18,9 @@ if (!debugging) {
// Use a distinct cookie. Bump version to 2.
setDefaultEnv('GRIST_SESSION_COOKIE', 'grist_core2');
setDefaultEnv('GRIST_SERVE_SAME_ORIGIN', 'true');
setDefaultEnv('GRIST_SINGLE_PORT', 'true');
import {updateDb} from 'app/server/lib/dbUtils';
import {main as mergedServerMain} from 'app/server/mergedServerMain';
import * as fse from 'fs-extra';
@ -42,7 +45,7 @@ export async function main() {
}
// If SAML is not configured, there's no login system, so provide a default email address.
if (!process.env.GRIST_SAML_SP_HOST) {
if (!process.env.GRIST_SAML_SP_HOST && !process.env.GRIST_TEST_LOGIN) {
setDefaultEnv('GRIST_DEFAULT_EMAIL', 'you@example.com');
}
// Set directory for uploaded documents.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -0,0 +1,4 @@
fname,lname,start_year,end_year
george,washington,1789,1797
john,adams,1797,1801
thomas,jefferson,1801,1809
1 fname lname start_year end_year
2 george washington 1789 1797
3 john adams 1797 1801
4 thomas jefferson 1801 1809

@ -0,0 +1,175 @@
import {assert, driver, WebElement, WebElementPromise} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
describe('ActionLog', function() {
this.timeout(20000);
const cleanup = setupTestSuite();
afterEach(() => gu.checkForErrors());
async function getActionUndoState(limit: number): Promise<string[]> {
const state = await driver.findAll('.action_log .action_log_item', (el) => el.getAttribute('class'));
return state.slice(0, limit).map((s) => s.replace(/action_log_item/, '').trim());
}
function getActionLogItems(): Promise<WebElement[]> {
// Use a fancy negation of style selector to exclude hidden log entries.
return driver.findAll(".action_log .action_log_item:not([style*='display: none'])");
}
function getActionLogItem(index: number): WebElementPromise {
return new WebElementPromise(driver, getActionLogItems().then((elems) => elems[index]));
}
before(async function() {
const session = await gu.session().login();
await session.tempDoc(cleanup, 'Hello.grist');
await gu.dismissWelcomeTourIfNeeded();
});
it("should cross out undone actions", async function() {
// Open the action-log tab.
await driver.findWait('.test-tools-log', 1000).click();
await gu.waitToPass(() => // Click might not work while panel is sliding out to open.
driver.findContentWait('.test-doc-history-tabs .test-select-button', 'Activity', 500).click());
// Perform some actions and check that they all appear as default.
await gu.enterGridRows({rowNum: 1, col: 0}, [['a'], ['b'], ['c'], ['d']]);
assert.deepEqual(await getActionUndoState(4), ['default', 'default', 'default', 'default']);
// Undo and check that the most recent action is crossed out.
await gu.undo();
assert.deepEqual(await getActionUndoState(4), ['undone', 'default', 'default', 'default']);
await gu.undo(2);
assert.deepEqual(await getActionUndoState(4), ['undone', 'undone', 'undone', 'default']);
await gu.redo(2);
assert.deepEqual(await getActionUndoState(4), ['undone', 'default', 'default', 'default']);
});
it("should indicate that actions that cannot be redone are buried", async function() {
// Add an item after the undo actions and check that they get buried.
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('e');
assert.deepEqual(await getActionUndoState(4), ['default', 'buried', 'default', 'default']);
// Check that undos skip the buried actions.
await gu.undo(2);
assert.deepEqual(await getActionUndoState(4), ['undone', 'buried', 'undone', 'default']);
// Check that burying around already buried actions works.
await gu.enterCell('f');
await gu.waitForServer();
assert.deepEqual(await getActionUndoState(5), ['default', 'buried', 'buried', 'buried', 'default']);
});
it("should properly rebuild the action log on refresh", async function() {
// Undo past buried actions to add complexity to the current state of the log
// and refresh.
await gu.undo(2);
await driver.navigate().refresh();
await gu.waitForDocToLoad();
// refreshing browser will restore position on last cell
// switch active cell to the first cell in the first row
await gu.getCell(0, 1).click();
await driver.findWait('.test-tools-log', 1000).click();
await driver.findContentWait('.test-doc-history-tabs .test-select-button', 'Activity', 500).click();
await gu.waitForServer();
assert.deepEqual(await getActionUndoState(6), ['undone', 'buried', 'buried', 'buried', 'undone', 'default']);
});
it("should indicate to the user when they cannot undo or redo", async function() {
assert.equal(await driver.find('.test-undo').matches('[class*=-disabled]'), false);
assert.equal(await driver.find('.test-redo').matches('[class*=-disabled]'), false);
// Undo and check that undo button gets disabled.
await gu.undo();
assert.equal(await driver.find('.test-undo').matches('[class*=-disabled]'), true);
assert.equal(await driver.find('.test-redo').matches('[class*=-disabled]'), false);
// Redo to the top of the log and check that redo button gets disabled.
await gu.redo(3);
assert.equal(await driver.find('.test-undo').matches('[class*=-disabled]'), false);
assert.equal(await driver.find('.test-redo').matches('[class*=-disabled]'), true);
});
it("should show clickable tabular diffs", async function() {
const item0 = await getActionLogItem(0);
assert.equal(await item0.find('table caption').getText(), 'Table1');
assert.equal(await item0.find('table th:nth-child(2)').getText(), 'A');
assert.equal(await item0.find('table td:nth-child(2)').getText(), 'f');
assert.equal(await gu.getActiveCell().getText(), 'a');
await item0.find('table td:nth-child(2)').click();
assert.equal(await gu.getActiveCell().getText(), 'f');
});
it("clickable tabular diffs should work across renames", async function() {
// Add another table just to mix things up a bit.
await gu.addNewTable();
// Rename our old table.
await gu.renameTable('Table1', 'Table1Renamed');
await gu.getPageItem('Table1Renamed').click();
await gu.renameColumn({col: 'A'}, 'ARenamed');
// Check that it's still usable. (It doesn't reflect the new names in the content of prior
// actions though -- e.g. the action below still mentions 'A' for column name -- and it's
// unclear if it should.)
const item2 = await getActionLogItem(2);
assert.equal(await item2.find('table caption').getText(), 'Table1');
assert.equal(await item2.find('table td:nth-child(2)').getText(), 'f');
await gu.getCell({rowNum: 1, col: 0}).click();
assert.notEqual(await gu.getActiveCell().getText(), 'f');
await item2.find('table td:nth-child(2)').click();
assert.equal(await gu.getActiveCell().getText(), 'f');
// Delete the page and table for Table1Renamed.
await gu.openPageMenu('Table1Renamed');
await driver.find('.grist-floating-menu .test-docpage-remove').click();
await driver.findWait('.test-modal-confirm', 500).click();
await gu.waitForServer();
await driver.findContent('.action_log label', /All tables/).find('input').click();
const item4 = await getActionLogItem(4);
await gu.scrollIntoView(item4);
await item4.find('table td:nth-child(2)').click();
assert.include(await driver.findWait('.test-notifier-toast-wrapper', 1000).getText(),
'Table1Renamed was subsequently removed');
await driver.find('.test-notifier-toast-wrapper .test-notifier-toast-close').click();
await driver.findContent('.action_log label', /All tables/).find('input').click();
});
it("should filter cell changes and renames by table", async function() {
// Have Table2, now add some more
await gu.enterGridRows({rowNum: 1, col: 0}, [['2']]);
await gu.addNewTable(); // Table1
await gu.enterGridRows({rowNum: 1, col: 0}, [['1']]);
await gu.addNewTable(); // Table3
await gu.enterGridRows({rowNum: 1, col: 0}, [['3']]);
await gu.getPageItem('Table1').click();
assert.lengthOf(await getActionLogItems(), 2);
assert.equal(await getActionLogItem(0).find("table:not([style*='display: none']) caption").getText(), 'Table1');
assert.equal(await getActionLogItem(1).find('.action_log_rename').getText(), 'Add Table1');
await gu.renameTable('Table1', 'Table1Renamed');
assert.equal(await getActionLogItem(0).find('.action_log_rename').getText(),
'Rename Table1 to Table1Renamed');
await gu.renameColumn({col: 'A'}, 'ARenamed');
assert.equal(await getActionLogItem(0).find('.action_log_rename').getText(),
'Rename Table1Renamed.A to ARenamed');
await gu.getPageItem('Table2').click();
assert.equal(await getActionLogItem(0).find("table:not([style*='display: none']) caption").getText(), 'Table2');
await gu.getPageItem('Table3').click();
assert.equal(await getActionLogItem(0).find("table:not([style*='display: none']) caption").getText(), 'Table3');
// Now show all tables and make sure the result is a longer (visible) log.
const filteredCount = (await getActionLogItems()).length;
await driver.findContent('.action_log label', /All tables/).find('input').click();
const fullCount = (await getActionLogItems()).length;
assert.isAbove(fullCount, filteredCount);
});
});

@ -0,0 +1,203 @@
import * as gu from 'test/nbrowser/gristUtils';
import { setupTestSuite } from 'test/nbrowser/testUtils';
import { assert, driver, Key } from 'mocha-webdriver';
describe("DuplicateDocument", function() {
this.timeout(20000);
const cleanup = setupTestSuite({team: true});
it("should duplicate a document with the Duplicate-Document option", async function() {
const session = await gu.session().teamSite.login();
await session.tempDoc(cleanup, 'Hello.grist');
await session.tempWorkspace(cleanup, 'Test Workspace');
// Open the share menu and click item to work on a copy.
await driver.find('.test-tb-share').click();
await driver.find('.test-save-copy').click();
// Should not allow saving a copy to an empty name.
await driver.findWait('.test-modal-dialog', 1000);
const nameElem = await driver.findWait('.test-copy-dest-name:focus', 200);
await nameElem.sendKeys(Key.DELETE);
assert.equal(await driver.find('.test-modal-confirm').getAttribute('disabled'), 'true');
await nameElem.sendKeys(' ');
assert.equal(await driver.find('.test-modal-confirm').getAttribute('disabled'), 'true');
// As soon as the textbox is non-empty, the Save button should become enabled.
await nameElem.sendKeys('a');
assert.equal(await driver.find('.test-modal-confirm').getAttribute('disabled'), null);
// Save a copy with a proper name.
await gu.completeCopy({destName: 'DuplicateTest1'});
// check the breadcrumbs reflect new document name, and the doc is not empty.
assert.equal(await driver.find('.test-bc-doc').value(), 'DuplicateTest1');
assert.equal(await gu.getCell({col: 'A', rowNum: 1}).getText(), 'hello');
});
it("should create a fork with Work-on-a-Copy option", async function() {
// Main user logs in and import a document
const session = await gu.session().teamSite.login();
await session.tempDoc(cleanup, 'Hello.grist');
// Open the share menu and click item to work on a copy.
await driver.find('.test-tb-share').click();
await driver.find('.test-work-on-copy').click();
await gu.waitForUrl(/~/);
await gu.waitForDocToLoad();
// check document is a fork
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
});
// The URL ID of a document copy (named "DuplicateTest2") which we create below and then use in
// several subsequent test cases.
let urlId: string;
it("should allow saving the fork as a new copy", async function() {
// Make a change to the fork to ensure it's saved.
await gu.getCell({col: 'A', rowNum: 1}).click();
await driver.sendKeys('hello to duplicates', Key.ENTER);
await driver.find('.test-tb-share').click();
await driver.find('.test-save-copy').click();
await gu.completeCopy({destName: 'DuplicateTest2'});
urlId = (await gu.getCurrentUrlId())!;
// check the breadcrumbs reflect new document name, and the doc contains our change.
assert.equal(await driver.find('.test-bc-doc').value(), 'DuplicateTest2');
assert.equal(await gu.getCell({col: 'A', rowNum: 1}).getText(), 'hello to duplicates');
});
it("should offer a choice of orgs when user is owner", async function() {
await driver.find('.test-tb-share').click();
await driver.find('.test-save-copy').click();
await driver.findWait('.test-modal-dialog', 1000);
assert.equal(await driver.find('.test-copy-dest-name').value(), 'DuplicateTest2 (copy)');
assert.equal(await driver.find('.test-copy-dest-org').isPresent(), true);
await driver.find('.test-copy-dest-org .test-select-open').click();
assert.includeMembers(await driver.findAll('.test-select-menu li', (el) => el.getText()),
['Personal', 'Test Grist', 'Test2 Grist']);
await driver.sendKeys(Key.ESCAPE);
// Check the list of workspaces in org
await driver.findWait('.test-copy-dest-workspace .test-select-open', 1000).click();
assert.includeMembers(await driver.findAll('.test-select-menu li', (el) => el.getText()),
['Home', 'Test Workspace']);
await driver.sendKeys(Key.ESCAPE);
// Switch the org and check that workspaces get updated.
await driver.find('.test-copy-dest-org .test-select-open').click();
await driver.findContent('.test-select-menu li', 'Test2 Grist').click();
await driver.findWait('.test-copy-dest-workspace .test-select-open', 1000).click();
assert.sameMembers(await driver.findAll('.test-select-menu li', (el) => el.getText()),
['Home']);
await driver.sendKeys(Key.ESCAPE);
await driver.sendKeys(Key.ESCAPE);
});
it("should not offer a choice of org when user is not owner", async function() {
const api = gu.session().teamSite.createHomeApi();
const session2 = gu.session().teamSite.user('user2');
await api.updateDocPermissions(urlId, {users: {
[session2.email]: 'viewers',
}});
await session2.login();
await session2.loadDoc(`/doc/${urlId}`);
await driver.find('.test-tb-share').click();
await driver.find('.test-save-copy').click();
await driver.findWait('.test-modal-dialog', 1000);
assert.equal(await driver.find('.test-copy-dest-name').value(), 'DuplicateTest2 (copy)');
// No choice of orgs
await gu.waitForServer();
assert.equal(await driver.find('.test-copy-dest-org').isPresent(), false);
// We don't happen to have write access to any workspace either.
assert.equal(await driver.find('.test-copy-dest-workspace').isPresent(), false);
assert.match(await driver.find('.test-copy-warning').getText(),
/You do not have write access to this site/);
assert.equal(await driver.find('.test-modal-confirm').getAttribute('disabled'), 'true');
await driver.sendKeys(Key.ESCAPE);
});
it("should offer a choice of orgs when doc is public", async function() {
const session = await gu.session().teamSite.login();
const api = session.createHomeApi();
// But if the doc is public, then users can copy it out.
await api.updateDocPermissions(urlId, {users: {
'everyone@getgrist.com': 'viewers',
}});
const session2 = gu.session().teamSite.user('user2');
await gu.session().teamSite2.createHomeApi().updateOrgPermissions('current', {users: {
[session2.email]: 'owners',
}});
await session2.login();
await session2.loadDoc(`/doc/${urlId}`);
await driver.find('.test-tb-share').click();
await driver.find('.test-save-copy').click();
await driver.findWait('.test-modal-dialog', 1000);
assert.equal(await driver.find('.test-copy-dest-name').value(), 'DuplicateTest2 (copy)');
// We can now switch between orgs.
assert.equal(await driver.find('.test-copy-dest-org').isPresent(), true);
// But we still don't have any writable workspaces on the current site.
await gu.waitForServer();
assert.equal(await driver.find('.test-copy-dest-workspace').isPresent(), false);
assert.match(await driver.find('.test-copy-warning').getText(),
/You do not have write access to this site/);
assert.equal(await driver.find('.test-modal-confirm').getAttribute('disabled'), 'true');
// We see some good orgs.
await driver.find('.test-copy-dest-org .test-select-open').click();
assert.includeMembers(await driver.findAll('.test-select-menu li', (el) => el.getText()),
['Personal', 'Test Grist', 'Test2 Grist']);
// Switching to an accessible regular org shows workspaces.
await driver.findContent('.test-select-menu li', 'Test2 Grist').click();
await gu.waitForServer();
await driver.find('.test-copy-dest-workspace .test-select-open').click();
assert.sameMembers(await driver.findAll('.test-select-menu li', (el) => el.getText()),
['Home']);
assert.equal(await driver.find('.test-modal-confirm').getAttribute('disabled'), null);
// And saving to another org actually works.
await gu.completeCopy();
assert.equal(await driver.find('.test-dm-org').getText(), 'Test2 Grist');
assert.equal(await driver.find('.test-bc-doc').value(), 'DuplicateTest2 (copy)');
assert.equal(await driver.find('.test-bc-workspace').getText(), 'Home');
assert.equal(await gu.getCell({col: 'A', rowNum: 1}).getText(), 'hello to duplicates');
});
it("should allow saving a public doc to the personal org", async function() {
const session2 = gu.session().teamSite.user('user2');
await session2.login();
await session2.loadDoc(`/doc/${urlId}`);
// Open the "Duplicate Document" dialog.
await driver.find('.test-tb-share').click();
await driver.find('.test-save-copy').click();
await driver.findWait('.test-modal-dialog', 1000);
assert.equal(await driver.find('.test-copy-dest-name').value(), 'DuplicateTest2 (copy)');
// Switching to personal org shows no workspaces but no errors either.
await driver.find('.test-copy-dest-org .test-select-open').click();
await driver.findContent('.test-select-menu li', 'Personal').click();
await gu.waitForServer();
assert.equal(await driver.find('.test-copy-dest-workspace').isPresent(), false);
assert.equal(await driver.find('.test-copy-warning').isPresent(), false);
assert.equal(await driver.find('.test-modal-confirm').getAttribute('disabled'), null);
// Save; it should succeed and open a same-looking document in alternate user's personal org.
const name = session2.name;
await gu.completeCopy({destName: `DuplicateTest2 ${name} Copy`});
assert.equal(await driver.find('.test-dm-org').getText(), `@${name}`);
assert.equal(await driver.find('.test-bc-doc').value(), `DuplicateTest2 ${name} Copy`);
assert.equal(await driver.find('.test-bc-workspace').getText(), 'Home');
assert.equal(await gu.getCell({col: 'A', rowNum: 1}).getText(), 'hello to duplicates');
assert.notEqual(await gu.getCurrentUrlId(), urlId);
});
});

@ -0,0 +1,624 @@
import {DocCreationInfo} from 'app/common/DocListAPI';
import {UserAPI} from 'app/common/UserAPI';
import {assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import * as uuidv4 from "uuid/v4";
describe("Fork", function() {
// this is a relatively slow test in staging.
this.timeout(40000);
const cleanup = setupTestSuite();
let doc: DocCreationInfo;
let api: UserAPI;
let personal: gu.Session;
let team: gu.Session;
before(async function() {
personal = gu.session().personalSite;
team = gu.session().teamSite;
});
async function makeDocIfAbsent() {
if (doc && doc.id) { return; }
doc = await team.tempDoc(cleanup, 'Hello.grist', {load: false});
}
// Run tests with both regular docId and a custom urlId in URL, to make sure
// ids are kept straight during forking.
for (const idType of ['urlId', 'docId'] as Array<'docId'|'urlId'>) {
describe(`with ${idType} in url`, function() {
before(async function() {
// Chimpy imports a document
await team.login();
await makeDocIfAbsent();
// Chimpy invites anon to view this document as a viewer, and charon as an owner
api = team.createHomeApi();
const user2 = gu.session().user('user2').email;
const user3 = gu.session().user('user3').email;
await api.updateDocPermissions(doc.id, {users: {'anon@getgrist.com': 'viewers',
[user3]: 'viewers',
[user2]: 'owners'}});
// Optionally set a urlId
if (idType === 'urlId') {
await api.updateDoc(doc.id, {urlId: 'doc-ula'});
}
});
afterEach(() => gu.checkForErrors());
for (const mode of ['anonymous', 'logged in']) {
for (const content of ['empty', 'imported']) {
it(`can create an ${content} unsaved document when ${mode}`, async function() {
let name: string;
if (mode === 'anonymous') {
name = '@Guest';
await personal.anon.login();
} else {
name = `@${personal.name}`;
await personal.login();
}
const anonApi = personal.anon.createHomeApi();
const activeApi = (mode === 'anonymous') ? anonApi : api;
const id = await (content === 'empty' ? activeApi.newUnsavedDoc() :
activeApi.importUnsavedDoc(Buffer.from('A,B\n999,2\n'),
{filename: 'foo.csv'}));
await personal.loadDoc(`/doc/${id}`);
await gu.dismissWelcomeTourIfNeeded();
// check that the tag is there
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
// check that the org name area is showing the user (not @Support).
assert.equal(await driver.find('.test-dm-org').getText(), name);
if (content === 'imported') {
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '999');
} else {
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '');
}
// editing should work
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('123');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
// edits should persist across reloads
await personal.loadDoc(`/doc/${id}`);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
// edits should still work
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('234');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '234');
if (mode !== 'anonymous') {
// if we log out, access should now be denied
const anonSession = await personal.anon.login();
await anonSession.loadDoc(`/doc/${id}`, false);
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
// if we log in as a different user, access should be denied
const altSession = await personal.user('user2').login();
await altSession.loadDoc(`/doc/${id}`, false);
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
}
});
}
}
it('allows a document to be forked anonymously', async function() {
// Anon loads the document, tries to modify, and fails - no write access
const anonSession = await team.anon.login();
await anonSession.loadDoc(`/doc/${doc.id}`);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('123');
await gu.waitForServer();
assert.notEqual(await gu.getCell({rowNum: 1, col: 0}).value(), '123');
// check that there is no tag
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), false);
// Anon forks the document, tries to modify, and succeeds
await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('123');
// Check that we show some indicators of what's happening to the user in notification toasts
// (but allow for possibility that things are changing fast).
await driver.findContentWait('.test-notifier-toast-message',
/(Preparing your copy)|(You are now.*your own copy)/, 2000);
await gu.waitForServer();
await driver.findContentWait('.test-notifier-toast-message', /You are now.*your own copy/, 2000);
assert.equal(await driver.findContent('.test-notifier-toast-message', /Preparing/).isPresent(), false);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
await gu.getCell({rowNum: 2, col: 0}).click();
await gu.enterCell('234');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), '234');
// The url of the doc should now be that of a fork
const forkUrl = await driver.getCurrentUrl();
assert.match(forkUrl, /~/);
// check that the tag is there
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
// Open the original url and make sure the change we made is not there
await anonSession.loadDoc(`/doc/${doc.id}`);
assert.notEqual(await gu.getCell({rowNum: 1, col: 0}).value(), '123');
assert.notEqual(await gu.getCell({rowNum: 2, col: 0}).value(), '234');
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), false);
// Open the fork url and make sure the change we made is persisted there
await anonSession.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), '234');
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
});
it('allows a document to be forked anonymously multiple times', async function() {
// Anon forks the document, tries to modify, and succeeds
const anonSession = await team.anon.login();
await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('1');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
const fork1 = await driver.getCurrentUrl();
await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('2');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
const fork2 = await driver.getCurrentUrl();
await anonSession.loadDoc((new URL(fork1)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
await anonSession.loadDoc((new URL(fork2)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
});
it('allows an anonymous fork to be forked', async function() {
const anonSession = await team.anon.login();
await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('1');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
const fork1 = await driver.getCurrentUrl();
assert.match(fork1, /^[^~]*~[^~]*$/); // just one ~
await anonSession.loadDoc((new URL(fork1)).pathname + '/m/fork');
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('2');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
const fork2 = await driver.getCurrentUrl();
assert.match(fork2, /^[^~]*~[^~]*$/); // just one ~
await anonSession.loadDoc((new URL(fork1)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1');
await anonSession.loadDoc((new URL(fork2)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
});
it('shows the right page item after forking', async function() {
const anonSession = await team.anon.login();
await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table1/);
// Add a new page; this immediately triggers a fork, AND selects the new page in it.
await gu.addNewPage(/Table/, /New Table/);
const urlId1 = await gu.getCurrentUrlId();
assert.match(urlId1!, /~/);
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table2/);
});
for (const user of [{access: 'viewers', name: 'user3'},
{access: 'editors', name: 'user2'}]) {
it(`allows a logged in user with ${user.access} permissions to fork`, async function() {
const userSession = await gu.session().teamSite.user(user.name as any).login();
await userSession.loadDoc(`/doc/${doc.id}/m/fork`);
assert.equal(await gu.getEmail(), userSession.email);
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), false);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('123');
await gu.waitForServer();
assert.equal(await driver.findWait('.test-unsaved-tag', 4000).isPresent(), true);
await gu.getCell({rowNum: 2, col: 0}).click();
await gu.enterCell('234');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), '234');
// The url of the doc should now be that of a fork
const forkUrl = await driver.getCurrentUrl();
assert.match(forkUrl, /~/);
// Open the original url and make sure the change we made is not there
await userSession.loadDoc(`/doc/${doc.id}`);
assert.notEqual(await gu.getCell({rowNum: 1, col: 0}).value(), '123');
assert.notEqual(await gu.getCell({rowNum: 2, col: 0}).value(), '234');
// Open the fork url and make sure the change we made is persisted there
await userSession.loadDoc((new URL(forkUrl)).pathname);
assert.notEqual(await gu.getCell({rowNum: 2, col: 0}).value(), '234');
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), '234');
// Check we still have editing rights
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('1234');
await gu.waitForServer();
await userSession.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
// Check others with write access to trunk can view but not edit our
// fork
await team.login();
await team.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getEmail(), team.email);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('12345');
await gu.waitForServer();
await team.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
const anonSession = await team.anon.login();
await anonSession.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getEmail(), 'anon@getgrist.com');
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('12345');
await gu.waitForServer();
await anonSession.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
});
}
it('controls access to forks of a logged in user correctly', async function() {
await gu.removeLogin();
const doc2 = await team.tempDoc(cleanup, 'Hello.grist', {load: false});
await api.updateDocPermissions(doc2.id, {maxInheritedRole: null});
await team.login();
await team.loadDoc(`/doc/${doc2.id}/m/fork`);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('123');
await gu.waitForServer();
// The url of the doc should now be that of a fork
const forkUrl = await driver.getCurrentUrl();
assert.match(forkUrl, /~/);
// Open the original url and make sure the change we made is not there
await team.loadDoc(`/doc/${doc2.id}`);
assert.notEqual(await gu.getCell({rowNum: 1, col: 0}).value(), '123');
// Open the fork url and make sure the change we made is persisted there
await team.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '123');
// Check we still have editing rights
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('1234');
await gu.waitForServer();
await team.loadDoc((new URL(forkUrl)).pathname);
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '1234');
// Check others without view access to trunk cannot see fork
await team.user('user2').login();
await driver.get(forkUrl);
assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-error-header').getText(), /Access denied/);
await server.removeLogin();
await driver.get(forkUrl);
assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-error-header').getText(), /Access denied/);
});
it('fails to create forks with inconsistent user id', async function() {
// Note: this test is also valuable for triggering an error during openDoc flow even though
// the initial page load succeeds, and checking how such an error is shown.
const forkId = `${idType}fork${uuidv4()}`;
await team.login();
const userId = await team.getUserId();
const altSession = await team.user('user2').login();
await altSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, false);
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-modal-dialog').getText(), /Cannot create fork/);
// Ensure the user has a way to report the problem (although including the report button in
// the modal might be better).
assert.match(await driver.find('.test-notifier-toast-wrapper').getText(),
/Cannot create fork.*Report a problem/s);
// A new doc cannot be created either (because of access
// mismatch - for forks of the doc used in these tests, user2
// would have some access to fork via acls on trunk, but for a
// new doc user2 has no access granted via the doc, or
// workspace, or org).
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false);
assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-error-header').getText(), /Access denied/);
// Same, but as an anonymous user.
const anonSession = await altSession.anon.login();
await anonSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, false);
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-modal-dialog').getText(), /Cannot create fork/);
// A new doc cannot be created either (because of access mismatch).
await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, false);
assert.equal(await driver.findWait('.test-dm-logo', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-error-header').getText(), /Access denied/);
// Now as a user who *is* allowed to create the fork.
// But doc forks cannot be casually created this way anymore, so it still doesn't work.
await team.login();
await team.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, false);
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-modal-dialog').getText(), /Cannot create fork/);
// New document can no longer be casually created this way anymore either.
await team.loadDoc(`/doc/new~${forkId}~${userId}`, false);
assert.equal(await driver.findWait('.test-modal-dialog', 2000).isDisplayed(), true);
assert.match(await driver.find('.test-modal-dialog').getText(), /Cannot create fork/);
await gu.wipeToasts();
});
it("should include the unsaved tags", async function() {
await team.login();
// open a document
const trunk = await team.tempDoc(cleanup, 'World.grist');
// make a fork
await team.loadDoc(`/doc/${trunk.id}/m/fork`);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('123');
await gu.waitForServer();
const forkUrl = await driver.getCurrentUrl();
assert.match(forkUrl, /~/);
// check that there is no tag on trunk
await team.loadDoc(`/doc/${trunk.id}`);
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), false);
// open same document with the fork bit in the URL
await team.loadDoc((new URL(forkUrl)).pathname);
// check that the tag is there
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
});
it('handles url history correctly', async function() {
await team.login();
await makeDocIfAbsent();
await team.loadDoc(`/doc/${doc.id}/m/fork`);
const initialUrl = await driver.getCurrentUrl();
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), false);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('2');
await gu.waitForServer();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
const forkUrl = await driver.getCurrentUrl();
assert.equal(await driver.findWait('.test-unsaved-tag', 4000).isPresent(), true);
await driver.executeScript('history.back()');
await gu.waitForUrl(/\/m\/fork/);
assert.equal(await driver.getCurrentUrl(), initialUrl);
await gu.waitForDocToLoad();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), 'hello');
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), false);
await driver.executeScript('history.forward()');
await gu.waitForUrl(/~/);
assert.equal(await driver.getCurrentUrl(), forkUrl);
await gu.waitForDocToLoad();
assert.equal(await gu.getCell({rowNum: 1, col: 0}).getText(), '2');
assert.equal(await driver.find('.test-unsaved-tag').isPresent(), true);
});
it('disables document renaming for forks', async function() {
await team.login();
await team.loadDoc(`/doc/${doc.id}/m/fork`);
assert.equal(await driver.find('.test-bc-doc').getAttribute('disabled'), null);
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('2');
await gu.waitForServer();
await gu.waitToPass(async () => {
assert.equal(await driver.find('.test-bc-doc').getAttribute('disabled'), 'true');
});
});
it('navigating browser history play well with the add new menu', async function() {
await team.login();
await makeDocIfAbsent();
await team.loadDoc(`/doc/${doc.id}/m/fork`);
const count = await getAddNewEntryCount();
// edit one cell
await gu.getCell({rowNum: 1, col: 0}).click();
await gu.enterCell('2');
await gu.waitForServer();
// check we're on a fork
await gu.waitForUrl(/~/);
// navigate back history
await driver.navigate().back();
await gu.waitForDocToLoad();
// check number of entries in add new menu are the same
assert.equal(await getAddNewEntryCount(), count);
// helper that get the number of items in the add new menu
async function getAddNewEntryCount() {
await driver.find('.test-dp-add-new').click();
const items = await driver.findAll('.grist-floating-menu li', e => e.getText());
assert.include(items, "Import from file");
await driver.sendKeys(Key.ESCAPE);
return items.length;
}
});
it('can replace a trunk document with a fork via api', async function() {
await team.login();
await makeDocIfAbsent();
await team.loadDoc(`/doc/${doc.id}/m/fork`);
// edit one cell
await gu.getCell({rowNum: 2, col: 0}).click();
const v1 = await gu.getCell({rowNum: 2, col: 0}).getText();
const v2 = `${v1}_tweaked`;
await gu.enterCell(v2);
await gu.waitForServer();
// check we're on a fork
await gu.waitForUrl(/~/);
const urlId = await gu.getCurrentUrlId();
// open trunk again, to test that page is reloaded after replacement
await team.loadDoc(`/doc/${doc.id}`);
// replace the trunk with the fork
assert.notEqual(urlId, doc.id);
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), v1);
const docApi = team.createHomeApi().getDocAPI(doc.id);
await docApi.replace({sourceDocId: urlId!});
// check that replacement worked (giving a little time for page reload)
await gu.waitToPass(async () => {
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), v2);
}, 4000);
// have a different user make a doc we don't have access to
const altSession = await personal.user('user2').login();
const altDoc = await altSession.tempDoc(cleanup, 'Hello.grist');
await gu.dismissWelcomeTourIfNeeded();
await gu.getCell({rowNum: 2, col: 0}).click();
await gu.enterCell('altDoc');
await gu.waitForServer();
// replacement should fail for document not found
// (error is "not found" for document in a different team site
// or team site vs personal site)
await assert.isRejected(docApi.replace({sourceDocId: altDoc.id}), /not found/);
// replacement should fail for document not accessible
await assert.isRejected(personal.createHomeApi().getDocAPI(doc.id).replace({sourceDocId: altDoc.id}),
/access denied/);
// check cell content does not change
await altSession.loadDoc(`/doc/${altDoc.id}`);
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), 'altDoc');
await team.login();
await team.loadDoc(`/doc/${doc.id}`);
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), v2);
});
it('can replace a trunk document with a fork via UI', async function() {
await team.login();
await makeDocIfAbsent();
await team.loadDoc(`/doc/${doc.id}/m/fork`);
// edit one cell.
await gu.getCell({rowNum: 2, col: 0}).click();
const v1 = await gu.getCell({rowNum: 2, col: 0}).getText();
const v2 = `${v1}_tweaked`;
await gu.enterCell(v2);
await gu.waitForServer();
// check we're on a fork.
await gu.waitForUrl(/~/);
const forkUrlId = await gu.getCurrentUrlId();
assert.equal(await driver.findWait('.test-unsaved-tag', 4000).isPresent(), true);
// check Replace Original gives expected button, and press it.
await driver.find('.test-tb-share').click();
await driver.find('.test-replace-original').click();
let confirmButton = driver.findWait('.test-modal-confirm', 1000);
assert.equal(await confirmButton.getText(), 'Update');
await confirmButton.click();
// check we're no longer on a fork, but still have the change made on the fork.
await gu.waitForUrl(/^[^~]*$/, 6000);
await gu.waitForDocToLoad();
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), v2);
// edit the cell again.
await gu.getCell({rowNum: 2, col: 0}).click();
const v3 = `${v2}_tweaked`;
await gu.enterCell(v3);
await gu.waitForServer();
// revisit the fork.
await team.loadDoc(`/doc/${forkUrlId}`);
assert.equal(await driver.findWait('.test-unsaved-tag', 4000).isPresent(), true);
// check Replace Original gives a scarier button, and press it anyway.
await driver.find('.test-tb-share').click();
await driver.find('.test-replace-original').click();
confirmButton = driver.findWait('.test-modal-confirm', 1000);
assert.equal(await confirmButton.getText(), 'Overwrite');
await confirmButton.click();
// check we're no longer on a fork, but have the fork's content.
await gu.waitForUrl(/^[^~]*$/, 6000);
await gu.waitForDocToLoad();
assert.equal(await gu.getCell({rowNum: 2, col: 0}).getText(), v2);
// revisit the fork.
await team.loadDoc(`/doc/${forkUrlId}`);
assert.equal(await driver.findWait('.test-unsaved-tag', 4000).isPresent(), true);
// check Replace Original mentions that the document is the same as the trunk.
await driver.find('.test-tb-share').click();
await driver.find('.test-replace-original').click();
confirmButton = driver.findWait('.test-modal-confirm', 1000);
assert.equal(await confirmButton.getText(), 'Update');
assert.match(await driver.find('.test-modal-dialog').getText(),
/already identical/);
});
it('gives an error when replacing without write access via UI', async function() {
await team.login();
await makeDocIfAbsent();
// Give view access to a friend.
const altSession = await team.user('user2').login();
await api.updateDocPermissions(doc.id, {users: {[altSession.email]: 'viewers'}});
try {
await team.loadDoc(`/doc/${doc.id}/m/fork`);
// edit one cell.
await gu.getCell({rowNum: 2, col: 0}).click();
const v1 = await gu.getCell({rowNum: 2, col: 0}).getText();
const v2 = `${v1}_tweaked`;
await gu.enterCell(v2);
await gu.waitForServer();
// check we're on a fork.
await gu.waitForUrl(/~/);
await gu.waitForDocToLoad();
assert.equal(await driver.findWait('.test-unsaved-tag', 4000).isPresent(), true);
// check Replace Original does not let us proceed because we don't have
// editing rights on trunk.
await driver.find('.test-tb-share').click();
assert.equal(await driver.find('.test-replace-original').matches('.disabled'), true);
// Clicking the disabled element does nothing.
await driver.find('.test-replace-original').click();
assert.equal(await driver.find('.grist-floating-menu').isDisplayed(), true);
await assert.isRejected(driver.findWait('.test-modal-dialog', 500), /Waiting for element/);
} finally {
await api.updateDocPermissions(doc.id, {users: {[altSession.email]: null}});
}
});
});
}
});

@ -0,0 +1,324 @@
/**
* Test the HomeIntro screen for empty orgs and the special rendering of Examples & Templates
* page, both for anonymous and logged-in users.
*/
import {assert, driver, stackWrapFunc, WebElement} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
describe('HomeIntro', function() {
this.timeout(40000);
setupTestSuite({samples: true});
describe("Anonymous on merged-org", function() {
it('should show welcome for anonymous user', async function() {
// Sign out
const session = await gu.session().personalSite.anon.login();
// Open doc-menu
await session.loadDocMenu('/');
// Check message specific to anon
assert.equal(await driver.find('.test-welcome-title').getText(), 'Welcome to Grist!');
assert.match(await driver.find('.test-welcome-text').getText(), /without logging in.*need to sign up/);
// Check the sign-up link.
const signUp = await driver.findContent('.test-welcome-text a', 'sign up');
assert.include(await signUp.getAttribute('href'), '/signin');
// Check that the link takes us to a login page.
await signUp.click();
await gu.checkLoginPage();
await driver.navigate().back();
await gu.waitForDocMenuToLoad();
});
// Check intro screen.
it('should should intro screen for anon, with video thumbnail', async function() {
// Check image for first video.
assert.equal(await driver.find('.test-intro-image img').isPresent(), true);
await checkImageLoaded(driver.find('.test-intro-image img'));
// Check links to first video in image and title.
assert.include(await driver.find('.test-intro-image img').findClosest('a').getAttribute('href'),
'support.getgrist.com');
// Check link to Help Center
assert.include(await driver.findContent('.test-welcome-text a', /Help Center/).getAttribute('href'),
'support.getgrist.com');
});
it('should not show Other Sites section', testOtherSitesSection);
it('should allow create/import from intro screen', testCreateImport.bind(null, false));
it('should allow collapsing examples and remember the state', testExamplesCollapsing);
it('should show examples workspace with the intro', testExamplesSection);
it('should render selected Examples workspace specially', testSelectedExamplesPage);
});
describe("Logged-in on merged-org", function() {
it('should show welcome for logged-in user', async function() {
// Sign in as a new user who has no docs.
const session = gu.session().personalSite.user('user3');
await session.login({
isFirstLogin: false,
freshAccount: true,
});
// Open doc-menu
await session.loadDocMenu('/');
// Check message specific to logged-in user
assert.match(await driver.find('.test-welcome-title').getText(), new RegExp(`Welcome.* ${session.name}`));
assert.match(await driver.find('.test-welcome-text').getText(), /Watch video/);
assert.notMatch(await driver.find('.test-welcome-text').getText(), /sign up/);
});
it('should not show Other Sites section', testOtherSitesSection);
it('should show intro screen for empty org', testIntroScreenLoggedIn);
it('should allow create/import from intro screen', testCreateImport.bind(null, true));
it('should allow collapsing examples and remember the state', testExamplesCollapsing);
it('should show examples workspace with the intro', testExamplesSection);
it('should allow copying examples', testCopyingExamples.bind(null, undefined));
it('should render selected Examples workspace specially', testSelectedExamplesPage);
});
describe("Logged-in on team site", function() {
it('should show welcome for logged-in user', async function() {
// Sign in as to a team that has no docs.
const session = await gu.session().teamSite.user('user1').login();
await session.loadDocMenu('/');
await session.resetSite();
// Open doc-menu
await session.loadDocMenu('/');
// Check message specific to logged-in user
assert.match(await driver.find('.test-welcome-title').getText(), new RegExp(`Welcome.* ${session.name}`));
assert.match(await driver.find('.test-welcome-text').getText(), /Watch video/);
assert.notMatch(await driver.find('.test-welcome-text').getText(), /sign up/);
});
it('should not show Other Sites section', testOtherSitesSection);
it('should show intro screen for empty org', testIntroScreenLoggedIn);
it('should show examples workspace with the intro', testExamplesSection);
it('should allow copying examples', testCopyingExamples.bind(null, gu.session().teamSite.orgName));
it('should render selected Examples workspace specially', testSelectedExamplesPage);
});
async function testOtherSitesSection() {
// Check that the Other Sites section is not shown.
assert.isFalse(await driver.find('.test-dm-other-sites-header').isPresent());
}
async function testIntroScreenLoggedIn() {
// Check image for first video.
assert.equal(await driver.find('.test-intro-image img').isPresent(), true);
await checkImageLoaded(driver.find('.test-intro-image img'));
// Check link to first video in welcome text
assert.include(await driver.findContent('.test-welcome-text a', /creating a document/).getAttribute('href'),
'support.getgrist.com');
// Check link to Help Center
assert.include(await driver.findContent('.test-welcome-text a', /Help Center/).getAttribute('href'),
'support.getgrist.com');
}
async function testCreateImport(isLoggedIn: boolean) {
// Create doc from intro button
await driver.find('.test-intro-create-doc').click();
await checkDocAndRestore(isLoggedIn, async () => assert.equal(await gu.getCell('A', 1).getText(), ''));
// Import doc from intro button
await gu.fileDialogUpload('uploads/FileUploadData.csv', () => driver.find('.test-intro-import-doc').click());
await checkDocAndRestore(isLoggedIn, async () => assert.equal(await gu.getCell('fname', 1).getText(), 'george'));
// Check that add-new menu has enabled Create Empty and Import Doc items.
await driver.find('.test-dm-add-new').doClick();
assert.equal(await driver.find('.test-dm-new-doc').matches('[class*=-disabled]'), false);
assert.equal(await driver.find('.test-dm-import').matches('[class*=-disabled]'), false);
// Create doc from add-new menu
await driver.find('.test-dm-new-doc').doClick();
await checkDocAndRestore(isLoggedIn, async () => {
await gu.dismissWelcomeTourIfNeeded();
assert.equal(await gu.getCell('A', 1).getText(), '');
if (!isLoggedIn) {
assert.equal(await driver.find('.test-tb-share-action').getText(), 'Save Document');
await driver.find('.test-tb-share').click();
assert.equal(await driver.find('.test-save-copy').isPresent(), true);
// There is no original of this document.
assert.equal(await driver.find('.test-open-original').isPresent(), false);
} else {
assert.equal(await driver.find('.test-tb-share-action').isPresent(), false);
}
});
// Import doc from add-new menu
await gu.docMenuImport('uploads/FileUploadData.csv');
await checkDocAndRestore(isLoggedIn, async () => assert.equal(await gu.getCell('fname', 1).getText(), 'george'));
}
// Wait for image to load (or fail), then check naturalWidth to ensure it loaded successfully.
const checkImageLoaded = stackWrapFunc(async function(img: WebElement) {
await driver.wait(() => img.getAttribute('complete'), 10000);
assert.isAbove(Number(await img.getAttribute('naturalWidth')), 0);
});
// Wait for doc to load, check it, then return to home page, and remove the doc so that we
// can see the intro again.
const checkDocAndRestore = stackWrapFunc(async function(isLoggedIn: boolean, docChecker: () => Promise<void>,
stepsBackToDocMenu: number = 1) {
await gu.waitForDocToLoad();
await gu.dismissWelcomeTourIfNeeded();
await docChecker();
for (let i = 0; i < stepsBackToDocMenu; i++) {
await driver.navigate().back();
}
await gu.waitForDocMenuToLoad();
// If not logged in, we create docs "unsaved" and don't see them in doc-menu.
if (isLoggedIn) {
// Delete the first doc we find. We expect exactly one to exist.
assert.equal(await driver.find('.test-dm-doc').isPresent(), true);
await driver.find('.test-dm-doc').mouseMove().find('.test-dm-pinned-doc-options').click();
await driver.find('.test-dm-delete-doc').click();
await driver.find('.test-modal-confirm').click();
await driver.wait(async () => !(await driver.find('.test-modal-dialog').isPresent()), 3000);
}
assert.equal(await driver.find('.test-dm-doc').isPresent(), false);
});
async function testExamplesCollapsing() {
assert.equal(await driver.find('.test-dm-pinned-doc-name').isDisplayed(), true);
// Collapse the templates section, check it's collapsed
await driver.find('.test-dm-all-docs-templates-header').click();
assert.equal(await driver.find('.test-dm-pinned-doc-name').isPresent(), false);
// Reload and check it's still collapsed.
await driver.navigate().refresh();
await gu.waitForDocMenuToLoad();
assert.equal(await driver.find('.test-dm-pinned-doc-name').isPresent(), false);
// Expand back, and check.
await driver.find('.test-dm-all-docs-templates-header').click();
assert.equal(await driver.find('.test-dm-pinned-doc-name').isDisplayed(), true);
// Reload and check it's still expanded.
await driver.navigate().refresh();
await gu.waitForDocMenuToLoad();
assert.equal(await driver.find('.test-dm-pinned-doc-name').isDisplayed(), true);
}
async function testExamplesSection() {
// Check rendering and functionality of the examples and templates section
// Check titles.
assert.includeMembers(await driver.findAll('.test-dm-pinned-doc-name', (el) => el.getText()),
['Lightweight CRM']);
// Check the Discover More Templates button is shown.
const discoverMoreButton = await driver.find('.test-dm-all-docs-templates-discover-more');
assert(await discoverMoreButton.isPresent());
assert.include(await discoverMoreButton.getAttribute('href'), '/p/templates');
// Check that the button takes us to the templates page, then go back.
await discoverMoreButton.click();
await gu.waitForDocMenuToLoad();
assert(gu.testCurrentUrl(/p\/templates/));
await driver.navigate().back();
await gu.waitForDocMenuToLoad();
// Check images.
const docItem = await driver.findContent('.test-dm-pinned-doc', /Lightweight CRM/);
assert.equal(await docItem.find('img').isPresent(), true);
await checkImageLoaded(docItem.find('img'));
// Both the image and the doc title link to the doc.
const imgHref = await docItem.find('img').findClosest('a').getAttribute('href');
const docHref = await docItem.find('.test-dm-pinned-doc-name').findClosest('a').getAttribute('href');
assert.match(docHref, /lightweight-crm/i);
assert.equal(imgHref, docHref);
// Open the example.
await docItem.find('.test-dm-pinned-doc-name').click();
await gu.waitForDocToLoad();
assert.match(await gu.getCell('Company', 1).getText(), /Sporer/);
assert.match(await driver.find('.test-bc-doc').value(), /Lightweight CRM/);
await driver.navigate().back();
await gu.waitForDocMenuToLoad();
}
async function testCopyingExamples(destination?: string) {
// Open the example to copy it. Note that we no longer support copying examples from doc menu.
// Make full copy of the example.
await driver.findContent('.test-dm-pinned-doc-name', /Lightweight CRM/).click();
await gu.waitForDocToLoad();
await driver.findWait('.test-tb-share-action', 500).click();
await gu.completeCopy({destName: 'LCRM Copy', destOrg: destination ?? 'Personal'});
await checkDocAndRestore(true, async () => {
assert.match(await gu.getCell('Company', 1).getText(), /Sporer/);
assert.match(await driver.find('.test-bc-doc').value(), /LCRM Copy/);
}, 2);
// Make a template copy of the example.
await driver.findContent('.test-dm-pinned-doc-name', /Lightweight CRM/).click();
await gu.waitForDocToLoad();
await driver.findWait('.test-tb-share-action', 500).click();
await driver.findWait('.test-save-as-template', 1000).click();
await gu.completeCopy({destName: 'LCRM Template Copy', destOrg: destination ?? 'Personal'});
await checkDocAndRestore(true, async () => {
// No data, because the file was copied as a template.
assert.equal(await gu.getCell(0, 1).getText(), '');
assert.match(await driver.find('.test-bc-doc').value(), /LCRM Template Copy/);
}, 2);
}
async function testSelectedExamplesPage() {
// Click Examples & Templates in left panel.
await driver.findContent('.test-dm-templates-page', /Examples & Templates/).click();
await gu.waitForDocMenuToLoad();
// Check Featured Templates are shown at the top of the page.
assert.equal(await driver.findWait('.test-dm-featured-templates-header', 500).getText(), 'Featured');
assert.includeMembers(
await driver.findAll('.test-dm-pinned-doc-list .test-dm-pinned-doc-name', (el) => el.getText()),
['Lightweight CRM']);
assert.includeMembers(
await driver.findAll('.test-dm-pinned-doc-list .test-dm-pinned-doc-desc', (el) => el.getText()),
['CRM template and example for linking data, and creating productive layouts.']
);
// External servers may have additional templates beyond the 3 above, so stop here.
if (server.isExternalServer()) { return; }
// Check the CRM and Invoice sections are shown below Featured Templates.
assert.includeMembers(
await driver.findAll('.test-dm-templates-header', (el) => el.getText()),
['CRM', 'Other']);
// Check that each section has the correct templates (title and description).
const [crmSection, otherSection] = await driver.findAll('.test-dm-templates');
assert.includeMembers(
await crmSection.findAll('.test-dm-pinned-doc-name', (el) => el.getText()),
['Lightweight CRM']);
assert.includeMembers(
await otherSection.findAll('.test-dm-pinned-doc-name', (el) => el.getText()),
['Afterschool Program', 'Investment Research']);
assert.includeMembers(
await crmSection.findAll('.test-dm-pinned-doc-desc', (el) => el.getText()),
['CRM template and example for linking data, and creating productive layouts.']);
assert.includeMembers(
await otherSection.findAll('.test-dm-pinned-doc-desc', (el) => el.getText()),
[
'Example for how to model business data, use formulas, and manage complexity.',
'Example for analyzing and visualizing with summary tables and linked charts.'
]);
const docItem = await driver.findContent('.test-dm-pinned-doc', /Lightweight CRM/);
assert.equal(await docItem.find('img').isPresent(), true);
await checkImageLoaded(docItem.find('img'));
}
});

@ -1185,14 +1185,13 @@ export function shareSupportWorkspaceForSuite() {
// test/gen-server/seed.ts creates a support user with a personal org and an "Examples &
// Templates" workspace, but doesn't share it (to avoid impacting the many existing tests).
// Share that workspace with @everyone and @anon, and clean up after this suite.
await server.simulateLogin("Support", "support@getgrist.com", "docs");
api = createHomeApi('Support', 'docs');
await addSupportUserIfPossible();
api = createHomeApi('Support', 'docs'); // this uses an api key, so no need to log in.
wss = await api.getOrgWorkspaces('current');
await api.updateWorkspacePermissions(wss[0].id, {users: {
'everyone@getgrist.com': 'viewers',
'anon@getgrist.com': 'viewers',
}});
await server.removeLogin();
});
after(async function() {
@ -1276,6 +1275,11 @@ export class Session {
return this.customTeamSite('test-grist', 'Test Grist');
}
// Return a session configured for an alternative team site and the current session's user.
public get teamSite2() {
return this.customTeamSite('test2-grist', 'Test2 Grist');
}
// Return a session configured for a particular team site and the current session's user.
public customTeamSite(orgDomain: string = 'test-grist', orgName = 'Test Grist') {
const deployment = process.env.GRIST_ID_PREFIX;
@ -1586,10 +1590,29 @@ export function bigScreen() {
}
export async function addSupportUserIfPossible() {
if (!server.isExternalServer() && process.env.TEST_SUPPORT_API_KEY) {
// Make sure we have a test support user.
const dbManager = await server.getDatabase();
const user = await dbManager.getUserByLoginWithRetry('support@getgrist.com', {
email: 'support@getgrist.com',
name: 'Support',
});
if (!user) {
throw new Error('Failed to create test support user');
}
if (!user.apiKey) {
user.apiKey = process.env.TEST_SUPPORT_API_KEY;
await user.save();
}
}
}
/**
* Adds samples to the Examples & Templates page.
*/
async function addSamples() {
await addSupportUserIfPossible();
const homeApi = createHomeApi('support', 'docs');
// Create the Grist Templates org.

@ -66,7 +66,9 @@ export class HomeUtil {
// TestingHooks communicates via JSON, so it's impossible to send an `undefined` value for org
// through it. Using the empty string happens to work though.
const testingHooks = await this.server.getTestingHooks();
await testingHooks.setLoginSessionProfile(await this.getGristSid(), {name, email, loginMethod}, org);
const sid = await this.getGristSid();
if (!sid) { throw new Error('no session available'); }
await testingHooks.setLoginSessionProfile(sid, {name, email, loginMethod}, org);
} else {
if (loginMethod && loginMethod !== 'Email + Password') {
throw new Error('only Email + Password logins supported for external server tests');
@ -109,7 +111,8 @@ export class HomeUtil {
public async removeLogin(org: string = "") {
if (!this.server.isExternalServer()) {
const testingHooks = await this.server.getTestingHooks();
await testingHooks.setLoginSessionProfile(await this.getGristSid(), null, org);
const sid = await this.getGristSid();
if (sid) { await testingHooks.setLoginSessionProfile(sid, null, org); }
} else {
await this.driver.get(`${this.server.getHost()}/logout`);
}
@ -155,12 +158,14 @@ export class HomeUtil {
}
/**
* Returns the current Grist session-id (for the selenium browser accessing this server).
* Returns the current Grist session-id (for the selenium browser accessing this server),
* or null if there is no session.
*/
public async getGristSid(): Promise<string> {
public async getGristSid(): Promise<string|null> {
// Load a cheap page on our server to get the session-id cookie from browser.
await this.driver.get(`${this.server.getHost()}/test/session`);
const cookie = await this.driver.manage().getCookie('grist_sid');
const cookie = await this.driver.manage().getCookie(process.env.GRIST_SESSION_COOKIE || 'grist_sid');
if (!cookie) { return null; }
return decodeURIComponent(cookie.value);
}
@ -247,7 +252,7 @@ export class HomeUtil {
* Returns whether we are currently on the Cognito login page.
*/
public async isOnLoginPage() {
return /gristlogin\./.test(await this.driver.getCurrentUrl());
return /gristlogin/.test(await this.driver.getCurrentUrl());
}
/**

@ -83,6 +83,8 @@ export function setupTestSuite(options?: TestSuiteOptions) {
// After every suite, clear sessionStorage and localStorage to avoid affecting other tests.
after(clearCurrentWindowStorage);
// Also, log out, to avoid logins interacting.
after(() => server.removeLogin());
// If requested, clear user preferences for all test users after this suite.
if (options?.clearUserPrefs) {
@ -202,7 +204,7 @@ export function setupCleanup() {
export function setupRequirement(options: TestSuiteOptions) {
const cleanup = setupCleanup();
if (options.samples) {
if (!server.isExternalServer()) {
if (process.env.TEST_ADD_SAMPLES || !server.isExternalServer()) {
gu.shareSupportWorkspaceForSuite(); // TODO: Remove after the support workspace is removed from the backend.
gu.addSamplesForSuite();
}
@ -218,31 +220,41 @@ export function setupRequirement(options: TestSuiteOptions) {
// Optionally ensure that a team site is available for tests.
if (options.team) {
await gu.addSupportUserIfPossible();
const api = gu.createHomeApi('support', 'docs');
let orgName = 'test-grist';
const deployment = process.env.GRIST_ID_PREFIX;
if (deployment) { orgName = `${orgName}-${deployment}`; }
let isNew: boolean = false;
try {
await api.newOrg({name: 'Test Grist', domain: orgName});
isNew = true;
} catch (e) {
// Assume the org already exists.
}
if (isNew) {
await api.updateOrgPermissions(orgName, {
users: {
'gristoid+chimpy@gmail.com': 'owners',
}
});
// Recreate the api for the correct org, then update billing.
const api2 = gu.createHomeApi('support', orgName);
const billing = api2.getBillingAPI();
await billing.updateBillingManagers({
users: {
'gristoid+chimpy@gmail.com': 'managers',
for (const suffix of ['', '2'] as const) {
let orgName = `test${suffix}-grist`;
const deployment = process.env.GRIST_ID_PREFIX;
if (deployment) { orgName = `${orgName}-${deployment}`; }
let isNew: boolean = false;
try {
await api.newOrg({name: `Test${suffix} Grist`, domain: orgName});
isNew = true;
} catch (e) {
// Assume the org already exists.
}
if (isNew) {
await api.updateOrgPermissions(orgName, {
users: {
'gristoid+chimpy@gmail.com': 'owners',
}
});
// Recreate the api for the correct org, then update billing.
const api2 = gu.createHomeApi('support', orgName);
const billing = api2.getBillingAPI();
try {
await billing.updateBillingManagers({
users: {
'gristoid+chimpy@gmail.com': 'managers',
}
});
} catch (e) {
// ignore if no billing endpoint
if (!String(e).match('404: Not Found')) {
throw e;
}
}
});
}
}
}
});

@ -0,0 +1,53 @@
#!/bin/bash
# This runs browser tests with the server started using docker, to
# catch any configuration problems.
# Run with MOCHA_WEBDRIVER_HEADLESS=1 for headless operation.
# Run with VERBOSE=1 for server logs.
# Settings for script robustness
set -o pipefail # trace ERR through pipes
set -o nounset # same as set -u : treat unset variables as an error
set -o errtrace # same as set -E: inherit ERR trap in functions
set -o errexit # same as set -e: exit on command failures
trap 'cleanup' EXIT
trap 'echo "Exiting on SIGINT"; exit 1' INT
trap 'echo "Exiting on SIGTERM"; exit 1' TERM
PORT=8585
DOCKER_CONTAINER=grist-core-test
DOCKER_PID=""
cleanup() {
docker rm -f $DOCKER_CONTAINER
if [ -n "$DOCKER_PID" ]; then
wait $DOCKER_PID || echo "docker container gone"
fi
echo "Cleaned up docker container, bye."
exit 0
}
docker run --name $DOCKER_CONTAINER --rm \
--env VERBOSE=${VERBOSE:-} \
-p $PORT:$PORT --env PORT=$PORT \
--env GRIST_SESSION_COOKIE=grist_test_cookie \
--env GRIST_TEST_LOGIN=1 \
--env TEST_SUPPORT_API_KEY=api_key_for_support \
gristlabs/grist &
DOCKER_PID="$!"
echo "[waiting for server]"
while true; do
curl -s http://localhost:$PORT/status && break
sleep 1
done
echo ""
echo "[server found]"
TEST_ADD_SAMPLES=1 TEST_ACCOUNT_PASSWORD=not-needed \
HOME_URL=http://localhost:8585 \
GRIST_SESSION_COOKIE=grist_test_cookie \
GRIST_TEST_LOGIN=1 \
NODE_PATH=_build:_build/stubs \
mocha _build/test/nbrowser/*.js "$@"

@ -3277,10 +3277,10 @@ grain-rpc@0.1.7:
events "^1.1.1"
ts-interface-checker "^1.0.0"
grainjs@1.0.1, grainjs@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/grainjs/-/grainjs-1.0.1.tgz#2eb7e1a628e550de1dcbe3f97358d28e4dbefff8"
integrity sha512-N5odOGRDsdKYs+M+WAtmlACuRZh60vKzzXhxCcWfRftriRCilWnLIowNNU0siFzex2qauK5i7YV3dZ3lXA1w7A==
grainjs@1.0.2, grainjs@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/grainjs/-/grainjs-1.0.2.tgz#5ab9b03de21bdff8da0f98408276fcbd31de8f17"
integrity sha512-wrj8TqpgxTGOKHpTlMBxMeX2uS3lTvXj4ROLKC+EZNM7J6RHQLGjMzMqWtiryBnMhGIBlbCicMNFppCrK1zv9w==
growl@1.10.5:
version "1.10.5"

Loading…
Cancel
Save