diff --git a/Dockerfile b/Dockerfile
index 20401bd4..0bab88d8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts
index 33a4c6cb..3c0665f8 100644
--- a/app/client/models/gristUrlState.ts
+++ b/app/client/models/gristUrlState.ts
@@ -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;
}
diff --git a/app/common/resetOrg.ts b/app/common/resetOrg.ts
index e10113f2..a384196d 100644
--- a/app/common/resetOrg.ts
+++ b/app/common/resetOrg.ts
@@ -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) {
diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts
index 08adeab8..a47e1744 100644
--- a/app/gen-server/ApiServer.ts
+++ b/app/gen-server/ApiServer.ts
@@ -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);
diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index 5c59bb92..a9f08e76 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -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(`
- Logged in as ${JSON.stringify(profile)}.
-
+
+
A Very Creduluous Login Page
+
+ A minimal login screen to facilitate testing.
+ I'll believe anything you tell me.
+
+
+
`);
}));
@@ -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");
diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts
index 76acc972..84a4ff45 100644
--- a/app/server/lib/GristServer.ts
+++ b/app/server/lib/GristServer.ts
@@ -47,3 +47,7 @@ export interface GristLoginMiddleware {
// Returns arbitrary string for log.
addEndpoints(app: express.Express): Promise;
}
+
+export interface RequestWithGrist extends express.Request {
+ gristServer?: GristServer;
+}
diff --git a/app/server/lib/SamlConfig.ts b/app/server/lib/SamlConfig.ts
index b190806c..8fe98c96 100644
--- a/app/server/lib/SamlConfig.ts
+++ b/app/server/lib/SamlConfig.ts
@@ -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,
diff --git a/app/server/lib/Sessions.ts b/app/server/lib/Sessions.ts
index 89278f7f..ce947932 100644
--- a/app/server/lib/Sessions.ts
+++ b/app/server/lib/Sessions.ts
@@ -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.
*/
diff --git a/app/server/lib/TestLogin.ts b/app/server/lib/TestLogin.ts
new file mode 100644
index 00000000..3462890b
--- /dev/null
+++ b/app/server/lib/TestLogin.ts
@@ -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 {
+ 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
+ },
+ };
+}
diff --git a/app/server/lib/TestingHooks.ts b/app/server/lib/TestingHooks.ts
index 9738098a..473a2d1d 100644
--- a/app/server/lib/TestingHooks.ts
+++ b/app/server/lib/TestingHooks.ts
@@ -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 {
diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts
index f4f132f4..4f9d4018 100644
--- a/app/server/lib/requestUtils.ts
+++ b/app/server/lib/requestUtils.ts
@@ -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);
+}
diff --git a/app/server/lib/uploads.ts b/app/server/lib/uploads.ts
index 506c849d..f750a150 100644
--- a/app/server/lib/uploads.ts
+++ b/app/server/lib/uploads.ts
@@ -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';
diff --git a/package.json b/package.json
index a93a16a4..40e5ad8b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts
index 2f6dc92d..039f5f70 100644
--- a/stubs/app/server/server.ts
+++ b/stubs/app/server/server.ts
@@ -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.
diff --git a/test/fixtures/docs/Hello.grist b/test/fixtures/docs/Hello.grist
new file mode 100644
index 00000000..a9bbd840
Binary files /dev/null and b/test/fixtures/docs/Hello.grist differ
diff --git a/test/fixtures/docs/Investment Research.grist b/test/fixtures/docs/Investment Research.grist
new file mode 100644
index 00000000..14e5013e
Binary files /dev/null and b/test/fixtures/docs/Investment Research.grist differ
diff --git a/test/fixtures/docs/World.grist b/test/fixtures/docs/World.grist
new file mode 100644
index 00000000..55344224
Binary files /dev/null and b/test/fixtures/docs/World.grist differ
diff --git a/test/fixtures/docs/video/Afterschool Program.grist b/test/fixtures/docs/video/Afterschool Program.grist
new file mode 100644
index 00000000..d10183e5
Binary files /dev/null and b/test/fixtures/docs/video/Afterschool Program.grist differ
diff --git a/test/fixtures/docs/video/Lightweight CRM.grist b/test/fixtures/docs/video/Lightweight CRM.grist
new file mode 100644
index 00000000..584b2db1
Binary files /dev/null and b/test/fixtures/docs/video/Lightweight CRM.grist differ
diff --git a/test/fixtures/uploads/FileUploadData.csv b/test/fixtures/uploads/FileUploadData.csv
new file mode 100644
index 00000000..8971c766
--- /dev/null
+++ b/test/fixtures/uploads/FileUploadData.csv
@@ -0,0 +1,4 @@
+fname,lname,start_year,end_year
+george,washington,1789,1797
+john,adams,1797,1801
+thomas,jefferson,1801,1809
diff --git a/test/nbrowser/ActionLog.ts b/test/nbrowser/ActionLog.ts
new file mode 100644
index 00000000..214bc61f
--- /dev/null
+++ b/test/nbrowser/ActionLog.ts
@@ -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 {
+ 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 {
+ // 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);
+ });
+});
diff --git a/test/nbrowser/DuplicateDocument.ts b/test/nbrowser/DuplicateDocument.ts
new file mode 100644
index 00000000..a5f27352
--- /dev/null
+++ b/test/nbrowser/DuplicateDocument.ts
@@ -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);
+ });
+});
diff --git a/test/nbrowser/Fork.ts b/test/nbrowser/Fork.ts
new file mode 100644
index 00000000..40640632
--- /dev/null
+++ b/test/nbrowser/Fork.ts
@@ -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}});
+ }
+ });
+ });
+ }
+});
diff --git a/test/nbrowser/HomeIntro.ts b/test/nbrowser/HomeIntro.ts
new file mode 100644
index 00000000..67678439
--- /dev/null
+++ b/test/nbrowser/HomeIntro.ts
@@ -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,
+ 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'));
+ }
+});
diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts
index 2d2a688f..16cd1a6a 100644
--- a/test/nbrowser/gristUtils.ts
+++ b/test/nbrowser/gristUtils.ts
@@ -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.
diff --git a/test/nbrowser/homeUtil.ts b/test/nbrowser/homeUtil.ts
index f01b0ff3..8d0a6c95 100644
--- a/test/nbrowser/homeUtil.ts
+++ b/test/nbrowser/homeUtil.ts
@@ -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 {
+ public async getGristSid(): Promise {
// 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());
}
/**
diff --git a/test/nbrowser/testUtils.ts b/test/nbrowser/testUtils.ts
index 0369ffff..0d96cd67 100644
--- a/test/nbrowser/testUtils.ts
+++ b/test/nbrowser/testUtils.ts
@@ -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',
+ 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;
+ }
}
- });
- // 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',
- }
- });
+ }
}
}
});
diff --git a/test/test_under_docker.sh b/test/test_under_docker.sh
new file mode 100755
index 00000000..8d00eb71
--- /dev/null
+++ b/test/test_under_docker.sh
@@ -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 "$@"
diff --git a/yarn.lock b/yarn.lock
index 637b1a1e..cc986e3a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"