Makes first 2 AdminPanel tests pass

This commit is contained in:
Spoffy 2024-07-16 15:28:46 +01:00
parent a2dd5292db
commit 56eca3d558
4 changed files with 1165 additions and 8 deletions

View File

@ -0,0 +1,465 @@
import {TelemetryLevel} from 'app/common/Telemetry';
import * as gu from 'test/nbrowser/playwrightGristUtils';
import {server, setupTestSuite} from 'test/nbrowser/playwrightTestUtils';
import {Defer, serveSomething, Serving} from 'test/server/customUtil';
import * as testUtils from 'test/server/testUtils';
import express from 'express';
import { expect, Locator, Page, test } from '@playwright/test';
test.describe('AdminPanel', function() {
setupTestSuite();
let oldEnv: testUtils.EnvironmentSnapshot;
let fakeServer: FakeUpdateServer;
test.afterEach(({ page }) => gu.checkForErrors(page));
test.beforeAll(async ({ browser }) => {
oldEnv = new testUtils.EnvironmentSnapshot();
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core';
process.env.GRIST_DEFAULT_EMAIL = gu.session(await browser.newContext()).email;
fakeServer = await startFakeServer();
process.env.GRIST_TEST_VERSION_CHECK_URL = `${fakeServer.url()}/version`;
await server.restart(true);
});
test.afterAll(async () => {
await fakeServer.close();
oldEnv.restore();
await server.restart(true);
});
const loginNonAdminSession = async (page: Page) => {
const session = gu.session(page.context()).user('user2').personalSite;
await session.login();
return session;
};
const loginAdminSession = async (page: Page) => {
const session = gu.session(page.context()).personalSite;
await session.login();
return session;
};
test('should show an explanation to non-managers', async ({ page }) => {
const session = await loginNonAdminSession(page);
await session.loadDocMenu(page, '/');
await gu.openAccountMenu(page);
await expect(page.locator('css=.test-usermenu-admin-panel')).not.toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('css=.test-dm-admin-panel')).not.toBeVisible();
// Try loading the URL directly.
await page.goto(`${server.getHost()}/admin`);
await waitForAdminPanel(page);
await expect(page.locator('css=.test-admin-panel')).toBeVisible();
await expect(page.locator('css=.test-admin-panel')).toContainText(/Administrator Panel Unavailable/);
});
test('should be shown to managers', async ({ page }) => {
const session = await loginAdminSession(page);
console.log(process.env.GRIST_DEFAULT_EMAIL);
console.log(session.email);
await session.loadDocMenu(page, '/');
await expect(page.locator('css=.test-dm-admin-panel')).toBeVisible();
expect(await page.locator('css=.test-dm-admin-panel').getAttribute('href')).toMatch(/\/admin$/);
await gu.openAccountMenu(page);
await expect(page.locator('css=.test-usermenu-admin-panel')).toBeVisible();
expect(await page.locator('css=.test-usermenu-admin-panel').getAttribute('href')).toMatch(/\/admin$/);
await page.locator('css=.test-usermenu-admin-panel').click();
await waitForAdminPanel(page);
});
/*
it('should include support-grist section', async function() {
assert.match(page.locator('css=.test-admin-panel-item-sponsor').getText(), /Support Grist Labs on GitHub/);
await withExpandedItem('sponsor', async () => {
const button = page.locator('css=.test-support-grist-page-sponsorship-section');
assert.equal(await button.isDisplayed(), true);
assert.match(await button.getText(), /You can support Grist open-source/);
});
});
it('supports opting in to telemetry from the page', async function() {
await assertTelemetryLevel('off');
let toggle = page.locator('css=.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), false);
await withExpandedItem('telemetry', async () => {
assert.isFalse(page.locator('css=.test-support-grist-page-telemetry-section-message').isPresent());
await driver.findContentWait(
'.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000).click();
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
assert.equal(
page.locator('css=.test-support-grist-page-telemetry-section-message').getText(),
'You have opted in to telemetry. Thank you! 🙏'
);
assert.equal(await isSwitchOn(toggle), true);
});
// Check it's still on after collapsing.
assert.equal(await isSwitchOn(toggle), true);
// Reload the page and check that the Grist config indicates telemetry is set to "limited".
await driver.navigate().refresh();
await waitForAdminPanel();
toggle = page.locator('css=.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), true);
await toggleItem('telemetry');
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
assert.equal(
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
'You have opted in to telemetry. Thank you! 🙏'
);
await assertTelemetryLevel('limited');
});
it('supports opting out of telemetry from the page', async function() {
await driver.findContent('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/).click();
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
assert.isFalse(page.locator('css=.test-support-grist-page-telemetry-section-message').isPresent());
let toggle = page.locator('css=.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), false);
// Reload the page and check that the Grist config indicates telemetry is set to "off".
await driver.navigate().refresh();
await waitForAdminPanel();
await toggleItem('telemetry');
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
assert.isFalse(page.locator('css=.test-support-grist-page-telemetry-section-message').isPresent());
await assertTelemetryLevel('off');
toggle = page.locator('css=.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), false);
});
it('supports toggling telemetry from the toggle in the top line', async function() {
const toggle = page.locator('css=.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), false);
await toggle.click();
await gu.waitForServer();
assert.equal(await isSwitchOn(toggle), true);
assert.match(page.locator('css=.test-support-grist-page-telemetry-section-message').getText(),
/You have opted in/);
await toggle.click();
await gu.waitForServer();
assert.equal(await isSwitchOn(toggle), false);
await withExpandedItem('telemetry', async () => {
assert.equal(page.locator('css=.test-support-grist-page-telemetry-section-message').isPresent(), false);
});
});
it('shows telemetry opt-in status even when set via environment variable', async function() {
// Set the telemetry level to "limited" via environment variable and restart the server.
process.env.GRIST_TELEMETRY_LEVEL = 'limited';
await server.restart();
// Check that the Support Grist page reports telemetry is enabled.
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
const toggle = page.locator('css=.test-admin-panel-item-value-telemetry .widget_switch');
assert.equal(await isSwitchOn(toggle), true);
await toggleItem('telemetry');
assert.equal(
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
'You have opted in to telemetry. Thank you! 🙏'
);
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
/Opt out of Telemetry/).isPresent());
// Now set the telemetry level to "off" and restart the server.
process.env.GRIST_TELEMETRY_LEVEL = 'off';
await server.restart();
// Check that the Support Grist page reports telemetry is disabled.
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
await toggleItem('telemetry');
assert.equal(
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
'You have opted out of telemetry.'
);
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
/Opt in to Telemetry/).isPresent());
});
it('should show version', async function() {
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
assert.equal(page.locator('css=.test-admin-panel-item-version').isDisplayed(), true);
assert.match(page.locator('css=.test-admin-panel-item-value-version').getText(), /^Version \d+\./);
});
it('should show sandbox', async function() {
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
assert.equal(page.locator('css=.test-admin-panel-item-sandboxing').isDisplayed(), true);
await gu.waitToPass(
// unknown for grist-saas, unconfigured for grist-core.
async () => assert.match(page.locator('css=.test-admin-panel-item-value-sandboxing').getText(),
/^((unknown)|(unconfigured))/),
3000,
);
// It would be good to test other scenarios, but we are using
// a multi-server setup on grist-saas and the sandbox test isn't
// useful there yet.
});
it('should show various self checks', async function() {
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
await gu.waitToPass(
async () => {
assert.equal(page.locator('css=.test-admin-panel-item-name-probe-reachable').isDisplayed(), true);
assert.match(page.locator('css=.test-admin-panel-item-value-probe-reachable').getText(), /✅/);
},
3000,
);
assert.equal(page.locator('css=.test-admin-panel-item-name-probe-system-user').isDisplayed(), true);
await gu.waitToPass(
async () => assert.match(page.locator('css=.test-admin-panel-item-value-probe-system-user').getText(), /✅/),
3000,
);
});
const upperCheckNow = () => page.locator('css=.test-admin-panel-updates-upper-check-now');
const lowerCheckNow = () => page.locator('css=.test-admin-panel-updates-lower-check-now');
const autoCheckToggle = () => page.locator('css=.test-admin-panel-updates-auto-check');
const updateMessage = () => page.locator('css=.test-admin-panel-updates-message');
const versionBox = () => page.locator('css=.test-admin-panel-updates-version');
function waitForStatus(message: RegExp) {
return gu.waitToPass(async () => {
assert.match(await updateMessage().getText(), message);
});
}
it('should check for updates', async function() {
// Clear any cached settings.
await driver.executeScript('window.sessionStorage.clear(); window.localStorage.clear();');
await driver.navigate().refresh();
await waitForAdminPanel();
// By default don't have any info.
await waitForStatus(/No information available/);
// We see upper check-now button.
assert.isTrue(await upperCheckNow().isDisplayed());
// We can expand.
await toggleItem('updates');
// We see a toggle to update automatically.
assert.isTrue(await autoCheckToggle().isDisplayed());
assert.isFalse(await isSwitchOn(autoCheckToggle()));
// We can click it, Grist will turn on auto checks and do it right away.
fakeServer.pause();
await autoCheckToggle().click();
assert.isTrue(await isSwitchOn(autoCheckToggle()));
// It will first show "Checking for updates" message.
// (Request is blocked by fake server, so it will not complete until we resume it.)
await waitForStatus(/Checking for updates/);
// Upper check now button is removed.
assert.isFalse(await upperCheckNow().isPresent());
// Resume server and respond.
fakeServer.resume();
// It will show "New version available" message.
await waitForStatus(/Newer version available/);
// And a version number.
assert.isTrue(await versionBox().isDisplayed());
assert.match(await versionBox().getText(), /Version 9\.9\.9/);
// When we reload, we will auto check for updates.
fakeServer.pause();
fakeServer.latestVersion = await currentVersion();
await driver.navigate().refresh();
await waitForAdminPanel();
await waitForStatus(/Checking for updates/);
fakeServer.resume();
await waitForStatus(/Grist is up to date/);
// Disable auto-checks.
await toggleItem('updates');
assert.isTrue(await isSwitchOn(autoCheckToggle()));
await autoCheckToggle().click();
assert.isFalse(await isSwitchOn(autoCheckToggle()));
// Nothing should happen.
await waitForStatus(/Grist is up to date/);
assert.isTrue(await versionBox().isDisplayed());
assert.equal(await versionBox().getText(), `Version ${await currentVersion()}`);
// Refresh to see if we are disabled.
fakeServer.pause();
await driver.navigate().refresh();
await waitForAdminPanel();
await waitForStatus(/Last checked .+ ago/);
fakeServer.resume();
// Expand and see if the toggle is off.
await toggleItem('updates');
assert.isFalse(await isSwitchOn(autoCheckToggle()));
});
it('shows up-to-date message', async function() {
fakeServer.latestVersion = await currentVersion();
// Click upper check now.
await waitForStatus(/Last checked .+ ago/);
await upperCheckNow().click();
await waitForStatus(/Grist is up to date/);
// Update version once again.
fakeServer.latestVersion = '9.9.10';
// Click lower check now.
fakeServer.pause();
await lowerCheckNow().click();
await waitForStatus(/Checking for updates/);
fakeServer.resume();
await waitForStatus(/Newer version available/);
// Make sure we see the new version.
assert.isTrue(await versionBox().isDisplayed());
assert.match(await versionBox().getText(), /Version 9\.9\.10/);
});
it('shows error message', async function() {
fakeServer.failNext = true;
fakeServer.pause();
await lowerCheckNow().click();
await waitForStatus(/Checking for updates/);
fakeServer.resume();
await waitForStatus(/Error checking for updates/);
assert.match((await gu.getToasts())[0], /some error/);
await gu.wipeToasts();
});
it('should send telemetry data', async function() {
assert.deepEqual({...fakeServer.payload, installationId: 'test'}, {
installationId: 'test',
deploymentType: 'core',
currentVersion: await currentVersion(),
});
assert.isNotEmpty(fakeServer.payload.installationId);
});
it('should survive APP_HOME_URL misconfiguration', async function() {
process.env.APP_HOME_URL = 'http://misconfigured.invalid';
process.env.GRIST_BOOT_KEY = 'zig';
await server.restart(true);
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
});
it('should honor GRIST_BOOT_KEY fallback', async function() {
await gu.removeLogin();
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
assert.equal(page.locator('css=.test-admin-panel').isDisplayed(), true);
assert.match(page.locator('css=.test-admin-panel').getText(), /Administrator Panel Unavailable/);
process.env.GRIST_BOOT_KEY = 'zig';
await server.restart(true);
await driver.get(`${server.getHost()}/admin?boot-key=zig`);
await waitForAdminPanel();
assert.equal(page.locator('css=.test-admin-panel').isDisplayed(), true);
assert.notMatch(page.locator('css=.test-admin-panel').getText(), /Administrator Panel Unavailable/);
await driver.get(`${server.getHost()}/admin?boot-key=zig-wrong`);
await waitForAdminPanel();
assert.equal(page.locator('css=.test-admin-panel').isDisplayed(), true);
assert.match(page.locator('css=.test-admin-panel').getText(), /Administrator Panel Unavailable/);
});
*/
});
// @ts-ignore
async function assertTelemetryLevel(page: Page, level: TelemetryLevel) {
const telemetryLevel = await page.evaluate(() => (window as any).gristConfig.telemetry?.telemetryLevel);
expect(telemetryLevel).toEqual(level);
}
async function toggleItem(page: Page, itemId: string) {
const header = await page.locator(`css=.test-admin-panel-item-name-${itemId}`);
await header.click();
// Playwright should handle waiting for expand/collapse
return header;
}
// @ts-ignore
async function withExpandedItem(page: Page, itemId: string, callback: () => Promise<void>) {
const header = await toggleItem(page, itemId);
await callback();
await header.click();
// Playwright should wait for collapse.
}
// @ts-ignore
const isSwitchOn = async (switchElem: Locator) => await switchElem.locator('css=[class*=switch_on]').count() > 0;
const waitForAdminPanel = (page: Page) => page.locator('css=.test-admin-panel').waitFor();
interface FakeUpdateServer {
latestVersion: string;
failNext: boolean;
payload: any;
close: () => Promise<void>;
pause: () => void;
resume: () => void;
url: () => string;
}
async function startFakeServer() {
let mutex: Defer|null = null;
const API: FakeUpdateServer = {
latestVersion: '9.9.9',
failNext: false,
payload: null,
close: async () => {
mutex?.resolve();
mutex = null;
await server?.shutdown();
server = null;
},
pause: () => {
mutex = new Defer();
},
resume: () => {
mutex?.resolve();
mutex = null;
},
url: () => {
return server!.url;
}
};
let server: Serving|null = await serveSomething((app) => {
app.use(express.json());
app.post('/version', async (req, res, next) => {
API.payload = req.body;
try {
await mutex;
if (API.failNext) {
res.status(500).json({error: 'some error'});
API.failNext = false;
return;
}
res.json({latestVersion: API.latestVersion});
} catch(ex) {
next(ex);
}
});
});
return API;
}
// @ts-ignore
async function currentVersion(page: Page) {
const currentVersionText = await page.locator("css=.test-admin-panel-item-value-version").textContent() ?? '';
return currentVersionText.match(/Version (.+)/)![1];
}

View File

@ -26,7 +26,7 @@ export interface Server extends EventEmitter {
isExternalServer(): boolean;
}
const ALL_TIPS_ENABLED = {
export const ALL_TIPS_ENABLED = {
behavioralPrompts: {
dontShowTips: false,
dismissedTips: [],
@ -34,7 +34,7 @@ const ALL_TIPS_ENABLED = {
dismissedWelcomePopups: [],
};
const ALL_TIPS_DISABLED = {
export const ALL_TIPS_DISABLED = {
behavioralPrompts: {
dontShowTips: true,
dismissedTips: BehavioralPrompt.values,

View File

@ -1,7 +1,15 @@
import { expect, Page } from '@playwright/test';
import { BrowserContext, expect, Page } from '@playwright/test';
import { HomeUtil } from "./playwrightHomeUtil";
import * as testUtils from "../server/testUtils";
import { server } from "./testServer";
import { resetOrg } from "app/common/resetOrg";
import { FullUser, UserProfile } from "app/common/LoginSessionAPI";
import { Organization as APIOrganization } from "app/common/UserAPI";
import type { Cleanup } from "./testUtils";
import * as fse from "fs-extra";
import { ImportOpts, noCleanup, TestUser, translateUser } from "./gristUtils";
import { decodeUrl } from 'app/common/gristUrls';
import { noop } from "lodash";
export const homeUtil = new HomeUtil(testUtils.fixturesRoot, server);
@ -9,3 +17,403 @@ export async function checkForErrors(page: Page) {
const errors = await page.evaluate(() => (window as any).getAppErrors());
expect(errors).toEqual([]);
}
/**
* Dismisses any tutorial card that might be active.
*/
export async function dismissTutorialCard(page: Page) {
// If there is something in our way, we can't do it.
if (await page.locator('css=.test-welcome-questions').count() > 0) {
return;
}
const cardClose = page.locator('css=.test-tutorial-card-close');
if (await cardClose.isVisible()) {
await cardClose.click();
}
}
export async function skipWelcomeQuestions(page: Page) {
if (await page.locator('css=.test-welcome-questions').isVisible()) {
await page.keyboard.press('Escape');
await expect(page.locator('css=.test-welcome-questions')).not.toBeVisible();
}
}
/**
* Returns the current org of gristApp in the currently-loaded page.
*/
export async function getOrg(page: Page, waitMs: number = 1000): Promise<APIOrganization> {
const org = await page.evaluate(() => {
const gristApp = (window as any).gristApp;
const appObs = gristApp && gristApp.topAppModel.appObs.get();
return appObs && appObs.currentOrg;
}) as APIOrganization;
if (!org) { throw new Error('could not find org'); }
return org;
}
/**
* Returns the current user of gristApp in the currently-loaded page.
*/
export async function getUser(page: Page, waitMs: number = 1000): Promise<FullUser> {
const user = await page.evaluate(() => {
const gristApp = (window as any).gristApp;
const appObs = gristApp && gristApp.topAppModel.appObs.get();
return appObs && appObs.currentUser;
}) as FullUser;
if (!user) { throw new Error('could not find user'); }
return user;
}
/**
* Waits for all pending comm requests from the client to the doc worker to complete. This taps into
* Grist's communication object in the browser to get the count of pending requests.
*
* Simply call this after some request has been made, and when it resolves, you know that request
* has been processed.
* @param page - Page to wait for
* @param optTimeout - Timeout in ms, defaults to 5000.
*/
export async function waitForServer(page: Page, optTimeout: number = 5000) {
await page.waitForFunction(() => {
const gristApp = (window as any).gristApp;
return gristApp && (!gristApp.comm || !gristApp.comm.hasActiveRequests()) &&
gristApp.testNumPendingApiRequests() === 0;
}, undefined, { timeout: optTimeout });
}
/**
* Wait for the doc to be loaded, to the point of finishing fetch for the data on the current
* page. If you navigate from a doc page, use e.g. waitForUrl() before waitForDocToLoad() to
* ensure you are checking the new page and not the old.
*/
export async function waitForDocToLoad(page: Page, timeoutMs: number = 10000): Promise<void> {
await page.locator('css=.viewsection_title').isVisible({ timeout: timeoutMs });
await waitForServer(page);
}
/**
* Wait for the doc list to show, to know that workspaces are fetched, and imports enabled.
*/
export async function waitForDocMenuToLoad(page: Page): Promise<void> {
await page.locator('css=.test-dm-doclist').waitFor({ timeout: 2000 });
}
/**
* Helper to get the urlId of the current document. Resolves to undefined if called while not
* on a document page.
*/
export async function getCurrentUrlId(page: Page) {
return decodeUrl({}, new URL(page.url())).doc;
}
/**
* Import a fixture doc into a workspace. Loads the document afterward unless `load` is false.
*
* Usage:
* > await importFixturesDoc('chimpy', 'nasa', 'Horizon', 'Hello.grist');
*/
// TODO New code should use {load: false} to prevent loading. The 'newui' value is now equivalent
// to the default ({load: true}), and should no longer be used in new code.
export async function importFixturesDoc(page: Page, username: string, org: string, workspace: string,
filename: string, options: ImportOpts|false|'newui' = {load: true}) {
if (typeof options !== 'object') {
options = {load: Boolean(options)}; // false becomes {load: false}, 'newui' becomes {load: true}
}
const doc = await homeUtil.importFixturesDoc(username, org, workspace, filename, options);
if (options.load !== false) {
await page.goto(server.getUrl(org, `/doc/${doc.id}`));
await waitForDocToLoad(page);
}
return doc;
}
export async function openAccountMenu(page: Page) {
await page.locator('css=.test-dm-account').click({ timeout: 1000 });
// Since the AccountWidget loads orgs and the user data asynchronously, the menu
// can expand itself. Wait for it to load.
await page.locator('css=.test-site-switcher-org').waitFor({ timeout: 1000 });
}
/**
* A class representing a user on a particular site, with a default
* workspaces. Tests written using this class can be more
* conveniently adapted to run locally, or against deployed versions
* of grist.
*/
export class Session {
public static get DEFAULT_SETTINGS() {
return {name: '', email: '', orgDomain: '', orgName: '', workspace: 'Home'};
}
// private constructor - access sessions via session() or Session.default
private constructor(
public context: BrowserContext,
public settings: { email: string, orgDomain: string,
orgName: string, name: string,
workspace: string }) {
}
// Get a session configured for the personal site of a default user.
public static default(context: BrowserContext) {
// Start with an empty session, then fill in the personal site (typically docs, or docs-s
// in staging), and then fill in a default user (currently gristoid+chimpy@gmail.com).
return new Session(
context,
Session.DEFAULT_SETTINGS
).personalSite.user();
}
// Return a session configured for the personal site of the current session's user.
public get personalSite() {
const orgName = this.settings.name ? `@${this.settings.name}` : '';
return this.customTeamSite('docs', orgName);
}
// Return a session configured for a default team site and the current session's user.
public get teamSite() {
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;
if (deployment) {
orgDomain = `${orgDomain}-${deployment}`;
}
return new Session(this.context, {...this.settings, orgDomain, orgName});
}
// Return a session configured to create and import docs in the given workspace.
public forWorkspace(workspace: string) {
return new Session(this.context, {...this.settings, workspace});
}
// Wipe the current site. The current user ends up being its only owner and manager.
public async resetSite() {
return resetOrg(this.createHomeApi(), this.settings.orgDomain);
}
// Return a session configured for the current session's site but a different user.
public user(userName: TestUser = 'user1') {
return new Session(this.context, {...this.settings, ...translateUser(userName)});
}
// Return a session configured for the current session's site and anonymous access.
public get anon() {
return this.user('anon');
}
public async addLogin() {
return this.login({retainExistingLogin: true});
}
// Make sure we are logged in to the current session's site as the current session's user.
public async login(options?: {loginMethod?: UserProfile['loginMethod'],
freshAccount?: boolean,
isFirstLogin?: boolean,
showTips?: boolean,
skipTutorial?: boolean, // By default true
userName?: string,
email?: string,
retainExistingLogin?: boolean
page?: Page }) {
const page = options?.page ?? await this.context.newPage()
if (options?.userName) {
this.settings.name = options.userName;
this.settings.email = options.email || '';
}
// Optimize testing a little bit, so if we are already logged in as the expected
// user on the expected org, and there are no options set, we can just continue.
if (!options && await this.isLoggedInCorrectly(page)) { return this; }
if (!options?.retainExistingLogin) {
await homeUtil.removeLogin(page);
if (this.settings.email === 'anon@getgrist.com') {
if (options?.showTips) {
await homeUtil.enableTips(page, this.settings.email);
} else {
await homeUtil.disableTips(page, this.settings.email);
}
return this;
}
}
await homeUtil.simulateLogin(page, this.settings.name, this.settings.email, this.settings.orgDomain,
{isFirstLogin: false, cacheCredentials: true, ...options});
if (options?.skipTutorial ?? true) {
await dismissTutorialCard(page);
}
return this;
}
// Check whether we are logged in to the current session's site as the current session's user.
public async isLoggedInCorrectly(page: Page) {
let currentUser: FullUser|undefined;
let currentOrg: APIOrganization|undefined;
try {
currentOrg = await getOrg(page);
} catch (err) {
// ok, we may not be in a page associated with an org.
}
try {
currentUser = await getUser(page);
} catch (err) {
// ok, we may not be in a page associated with a user.
}
return currentUser && currentUser.email === this.settings.email &&
currentOrg && (currentOrg.name === this.settings.orgName ||
// This is an imprecise check for personal sites, but adequate for tests.
(currentOrg.owner && (this.settings.orgDomain.startsWith('docs'))));
}
// Load a document on a site.
public async loadDoc(
page: Page,
relPath: string,
options: {
wait?: boolean,
} = {}
) {
const {wait = true} = options;
await this.loadRelPath(page, relPath);
if (wait) { await waitForDocToLoad(page); }
}
// Load a DocMenu on a site.
// If loading for a potentially first-time user, you may give 'skipWelcomeQuestions' for second
// argument to dismiss the popup with welcome questions, if it gets shown.
public async loadDocMenu(page: Page, relPath: string, wait: boolean|'skipWelcomeQuestions' = true) {
await this.loadRelPath(page, relPath);
if (wait) { await waitForDocMenuToLoad(page); }
if (wait === 'skipWelcomeQuestions') {
// When waitForDocMenuToLoad() returns, welcome questions should also render, so that we
// don't need to wait extra for them.
await skipWelcomeQuestions(page);
}
}
public async loadRelPath(page: Page, relPath: string) {
const part = relPath.match(/^\/o\/([^/]*)(\/.*)/);
if (part) {
if (part[1] !== this.settings.orgDomain) {
throw new Error(`org mismatch: ${this.settings.orgDomain} vs ${part[1]}`);
}
relPath = part[2];
}
await page.goto(server.getUrl(this.settings.orgDomain, relPath));
}
// Import a file into the current site + workspace.
public async importFixturesDoc(page: Page, fileName: string, options: ImportOpts = {load: true}) {
return importFixturesDoc(page, this.settings.name, this.settings.orgDomain, this.settings.workspace, fileName,
{email: this.settings.email, ...options});
}
// As for importFixturesDoc, but delete the document at the end of testing.
public async tempDoc(page: Page, cleanup: Cleanup, fileName: string, options: ImportOpts = {load: true}) {
const doc = await this.importFixturesDoc(page, fileName, options);
const api = this.createHomeApi();
if (!noCleanup) {
cleanup.addAfterAll(async () => {
await api.deleteDoc(doc.id).catch(noop);
doc.id = '';
});
}
return doc;
}
// As for importFixturesDoc, but delete the document at the end of each test.
public async tempShortDoc(page: Page, cleanup: Cleanup, fileName: string, options: ImportOpts = {load: true}) {
const doc = await this.importFixturesDoc(page, fileName, options);
const api = this.createHomeApi();
if (!noCleanup) {
cleanup.addAfterEach(async () => {
if (doc.id) {
await api.deleteDoc(doc.id).catch(noop);
}
doc.id = '';
});
}
return doc;
}
public async tempNewDoc(page: Page, cleanup: Cleanup, docName: string = '', {load} = {load: true}) {
docName ||= `Test${Date.now()}`;
const docId = await homeUtil.createNewDoc(this.settings.name, this.settings.orgDomain, this.settings.workspace,
docName, {email: this.settings.email});
if (load) {
await this.loadDoc(page, `/doc/${docId}`);
}
const api = this.createHomeApi();
if (!noCleanup) {
cleanup.addAfterAll(() => api.deleteDoc(docId).catch(noop));
}
return docId;
}
// Create a workspace that will be deleted at the end of testing.
public async tempWorkspace(cleanup: Cleanup, workspaceName: string) {
const api = this.createHomeApi();
const workspaceId = await api.newWorkspace({name: workspaceName}, 'current');
if (!noCleanup) {
cleanup.addAfterAll(async () => {
await api.deleteWorkspace(workspaceId).catch(noop);
});
}
return workspaceId;
}
// Get an appropriate home api object.
public createHomeApi() {
if (this.settings.email === 'anon@getgrist.com') {
return homeUtil.createHomeApi(null, this.settings.orgDomain);
}
return homeUtil.createHomeApi(this.settings.name, this.settings.orgDomain, this.settings.email);
}
public getApiKey(): string|null {
if (this.settings.email === 'anon@getgrist.com') {
return homeUtil.getApiKey(null);
}
return homeUtil.getApiKey(this.settings.name, this.settings.email);
}
// Get the id of this user.
public async getUserId(): Promise<number> {
await this.login();
const docPage = await this.context.newPage();
await this.loadDocMenu(docPage, '/');
const user = await getUser(docPage);
return user.id;
}
public get email() { return this.settings.email; }
public get name() { return this.settings.name; }
public get orgDomain() { return this.settings.orgDomain; }
public get orgName() { return this.settings.orgName; }
public get workspace() { return this.settings.workspace; }
public async downloadDoc(fname: string, urlId: string) {
const api = this.createHomeApi();
const doc = await api.getDoc(urlId);
const workerApi = await api.getWorkerAPI(doc.id);
const response = await workerApi.downloadDoc(doc.id);
await fse.writeFile(fname, Buffer.from(await response.arrayBuffer()));
}
}
// Configure a session, for the personal site of a default user.
export function session(context: BrowserContext): Session {
return Session.default(context);
}

View File

@ -2,16 +2,21 @@
* Contains some non-webdriver functionality needed by tests.
*/
import FormData from 'form-data';
import { WebDriver } from 'mocha-webdriver';
import fetch from 'node-fetch';
import { UserAPI, UserAPIImpl } from 'app/common/UserAPI';
import { DocWorkerAPI, UserAPI, UserAPIImpl, UserProfile } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { TestingHooksClient } from 'app/server/lib/TestingHooks';
import { BrowserContext, Page } from "@playwright/test";
import { BrowserContext, expect, Page } from "@playwright/test";
import { UserPrefs } from "app/common/Prefs";
import { ALL_TIPS_DISABLED, ALL_TIPS_ENABLED } from "./homeUtil";
import { normalizeEmail } from "app/common/emails";
import EventEmitter = require('events');
import defaults = require('lodash/defaults');
import { authenticator } from "otplib";
import path from "path";
import * as fse from "fs-extra";
export interface Server extends EventEmitter {
driver: WebDriver;
getTestingHooks(): Promise<TestingHooksClient>;
getHost(): string;
getUrl(team: string, relPath: string): string;
@ -30,6 +35,154 @@ export class HomeUtil {
});
}
/**
* Set current session to a simulated login with the given name and email. Available options
* include:
* - `loginMethod`: when provided will store in the database which method the
* user nominally logged in with (e.g. 'Email + Password' or 'Google').
* - `isFirstLogin`: when provided will cause user to be redirected or not to the
* welcome pages.
* - `freshAccount`: when true will cause the user account to be deleted and
* recreated if it already existed.
* - `cacheCredentials`: when true will result in the user's api key being stored
* (after having been created if necessary), so that their home api can be later
* instantiated without page loads.
* When testing against an external server, the simulated login is in fact genuine,
* done via the Grist login page.
*/
public async simulateLogin(page: Page, name: string, email: string, org: string = "", options: {
loginMethod?: UserProfile['loginMethod'],
freshAccount?: boolean,
isFirstLogin?: boolean,
showGristTour?: boolean,
showTips?: boolean,
cacheCredentials?: boolean,
} = {}) {
const {loginMethod, isFirstLogin, showTips} = defaults(options, {
loginMethod: 'Email + Password',
showTips: false,
});
const showGristTour = options.showGristTour ?? (options.freshAccount ?? isFirstLogin);
// For regular tests, we can log in through a testing hook.
if (!this.server.isExternalServer()) {
if (options.freshAccount) { await this._deleteUserByEmail(email); }
if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); }
if (showGristTour !== undefined) { await this._initShowGristTour(email, showGristTour); }
if (showTips) {
await this.enableTips(page, email);
} else {
await this.disableTips(page, email);
}
// 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();
const sid = await this.getGristSid(page.context());
if (!sid) { throw new Error('no session available'); }
await testingHooks.setLoginSessionProfile(
sid,
{name, email, loginEmail: normalizeEmail(email), loginMethod},
org
);
} else {
if (loginMethod && loginMethod !== 'Email + Password') {
throw new Error('only Email + Password logins supported for external server tests');
}
// Make sure we revisit page in case login is changing.
await page.goto('about:blank');
// When running against an external server, we log in through the Grist login page.
await page.goto(this.server.getUrl(org, ""));
if (!await this.isOnLoginPage(page)) {
// Explicitly click Sign In button if necessary.
await page.locator('css=.test-user-sign-in').click({ timeout: 4000 });
}
// Fill the login form (either test or Grist).
if (await this.isOnTestLoginPage(page)) {
await this.fillTestLoginForm(page, email, name);
} else {
await this.fillGristLoginForm(page, email);
}
if (!await this.isWelcomePage(page) && (options.freshAccount || options.isFirstLogin)) {
await this._recreateCurrentUser(page, email, org, name);
}
}
if (options.freshAccount) {
this._apiKey.delete(email);
}
if (options.cacheCredentials) {
// Take this opportunity to cache access info.
if (!this._apiKey.has(email)) {
await page.goto(this.server.getUrl(org || 'docs', ''));
const apiKey = await this._getApiKey(page);
this._apiKey.set(email, apiKey);
}
}
}
/**
* Fill the Grist test login page.
*
* TEST_ACCOUNT_PASSWORD must be set.
*/
public async fillTestLoginForm(page: Page, email: string, name?: string) {
const password = process.env.TEST_ACCOUNT_PASSWORD;
if (!password) { throw new Error('TEST_ACCOUNT_PASSWORD not set'); }
const form = page.locator('css=div.modal-content-desktop');
await form.locator('css=input[name="username"]').fill(email);
if (name) {
await form.locator('css=input[name="name"]').fill(name);
}
await form.locator('css=input[name="password"]').fill(password);
await form.locator('css=input[name="signInSubmitButton"]').click();
}
/**
* Fill up the Grist login page form, and submit. If called with a user that
* has TOTP-based 2FA enabled, TEST_ACCOUNT_TOTP_SECRET must be set for a valid
* code to be submitted on the following form.
*
* Should be on the Grist login or sign-up page before calling this method. If
* `password` is not passed in, TEST_ACCOUNT_PASSWORD must be set.
*/
public async fillGristLoginForm(page: Page, email: string, password?: string) {
if (!password) {
password = process.env.TEST_ACCOUNT_PASSWORD;
if (!password) {
throw new Error('TEST_ACCOUNT_PASSWORD not set');
}
}
await this.checkGristLoginPage(page);
if (page.url().match(/signup\?/)) {
await page.locator('css=a[href*="login?"]').click({ timeout: 4000 });
}
await page.locator('css=input[name="email"]').fill(email);
await page.locator('css=input[name="password"]').fill(password);
await page.locator('css=.test-lp-sign-in').click();
await this.checkGristLoginPage(page, 4000);
if (!(await (page.locator('css=.test-mfa-title').getByText('Almost there!').count()) > 0)) {
return;
}
const secret = process.env.TEST_ACCOUNT_TOTP_SECRET;
if (!secret) {
throw new Error('TEST_ACCOUNT_TOTP_SECRET not set');
}
const code = authenticator.generate(secret);
await page.locator('css=input[name="verificationCode"]').fill(code);
await page.locator('css=.test-mfa-submit').click();
await expect(
page.locator('css=.test-mfa-title:has-text("Almost there!")'),
'Possible reason: verification code is invalid or expired (i.e. was recently used to log in)'
).toBeAttached();
}
/**
* Remove any simulated login from the current session (for the given org, if specified).
* For testing against an external server, all logins are removed, since there's no way
@ -47,6 +200,16 @@ export class HomeUtil {
}
}
/**
* Delete the currently logged in user.
*/
public async deleteCurrentUser(page: Page) {
const apiKey = await this._getApiKey(page);
const api = this._createHomeApiUsingApiKey(apiKey);
const info = await api.getSessionActive();
await api.deleteUser(info.user.id, info.user.name);
}
/**
* Returns the current Grist session-id (for the selenium browser accessing this server),
* or null if there is no session.
@ -130,7 +293,70 @@ export class HomeUtil {
return await page.getByText('A Very Credulous Login Page').count() > 0;
}
// @ts-ignore
/**
* Waits for browser to navigate to a Grist login page.
*/
public async checkGristLoginPage(page: Page, waitMs: number = 2000) {
const isOnSignUpPage = await page.locator('css=.test-sp-heading').waitFor({ timeout: waitMs });
const isOnLoginPage = await page.locator('css=.test-lp-heading').waitFor({ timeout: waitMs });
await Promise.race([isOnSignUpPage, isOnLoginPage]);
}
/**
* Delete and recreate the user, via the specified org. The specified user must be
* currently logged in!
*/
private async _recreateCurrentUser(page: Page, email: string, org: string, name?: string) {
await this.deleteCurrentUser(page);
await this.removeLogin(page, org);
await page.goto(this.server.getUrl(org, ""));
await page.locator('css=.test-user-sign-in').click({ timeout: 4000 });
await this.checkGristLoginPage(page);
// Fill the login form (either test or Grist).
if (await this.isOnTestLoginPage(page)) {
await this.fillTestLoginForm(page, email, name);
} else {
await this.fillGristLoginForm(page, email);
}
}
public async enableTips(page: Page, email: string) {
await this._toggleTips(page, true, email);
}
public async disableTips(page: Page, email: string) {
await this._toggleTips(page, false, email);
}
// Check if the url looks like a welcome page. The check is weak, but good enough
// for testing.
public async isWelcomePage(page: Page) {
return Boolean(page.url().match(/\/welcome\//));
}
/**
* Import a fixture doc into a workspace.
*/
public async importFixturesDoc(username: string, org: string, workspace: string,
filename: string, options: {newName?: string, email?: string} = {}) {
const homeApi = this.createHomeApi(username, org, options.email);
const docWorker = await homeApi.getWorkerAPI('import');
const workspaceId = await this.getWorkspaceId(homeApi, workspace);
const uploadId = await this.uploadFixtureDoc(docWorker, filename, options.newName);
return docWorker.importDocToWorkspace(uploadId, workspaceId);
}
public async uploadFixtureDoc(docWorker: DocWorkerAPI, filename: string, newName: string = filename) {
const filepath = path.resolve(this.fixturesRoot, "docs", filename);
if (!await fse.pathExists(filepath)) {
throw new Error(`Can't find file: ${filepath}`);
}
const fileStream = fse.createReadStream(filepath);
// node-fetch can upload streams, although browser fetch can't
return docWorker.upload(fileStream as any, newName);
}
private async _getApiKey(page: Page): Promise<string> {
return page.evaluate(() => {
const app = (window as any).gristApp;
@ -153,4 +379,62 @@ export class HomeUtil {
newFormData: () => new FormData() as any, // form-data isn't quite type compatible
});
}
private async _toggleTips(page: Page, enabled: boolean, email: string) {
if (this.server.isExternalServer()) {
// Unsupported due to lack of access to the database.
return;
}
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (!user) { return; }
if (user.personalOrg) {
const org = await dbManager.getOrg({userId: user.id}, user.personalOrg.id);
const userPrefs = (org.data as any)?.userPrefs ?? {};
const newUserPrefs: UserPrefs = {
...userPrefs,
...(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED),
};
await dbManager.updateOrg({userId: user.id}, user.personalOrg.id, {userPrefs: newUserPrefs});
} else {
await page.evaluate(() => {
const userPrefs = JSON.parse(localStorage.getItem('userPrefs:u=${user.id}') || '{}');
localStorage.setItem('userPrefs:u=${user.id}', JSON.stringify({
...userPrefs,
...(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED),
}));
});
}
}
// Delete a user using their email address. Requires access to the database.
private async _deleteUserByEmail(email: string) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (user) { await dbManager.deleteUser({userId: user.id}, user.id, user.name); }
}
// Set whether this is the user's first time logging in. Requires access to the database.
private async _setFirstLogin(email: string, isFirstLogin: boolean) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (user) {
user.isFirstTimeUser = isFirstLogin;
await user.save();
}
}
private async _initShowGristTour(email: string, showGristTour: boolean) {
if (this.server.isExternalServer()) { throw new Error('not supported'); }
const dbManager = await this.server.getDatabase();
const user = await dbManager.getUserByLogin(email);
if (user && user.personalOrg) {
const userOrgPrefs = {showGristTour};
await dbManager.updateOrg({userId: user.id}, user.personalOrg.id, {userOrgPrefs});
}
}
}