mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
Makes first 2 AdminPanel tests pass
This commit is contained in:
parent
a2dd5292db
commit
56eca3d558
465
test/nbrowser/AdminPanel.playwright.ts
Normal file
465
test/nbrowser/AdminPanel.playwright.ts
Normal 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];
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user