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)}.

-

- - - -
+ `); })); @@ -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"