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;
|
isExternalServer(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_TIPS_ENABLED = {
|
export const ALL_TIPS_ENABLED = {
|
||||||
behavioralPrompts: {
|
behavioralPrompts: {
|
||||||
dontShowTips: false,
|
dontShowTips: false,
|
||||||
dismissedTips: [],
|
dismissedTips: [],
|
||||||
@ -34,7 +34,7 @@ const ALL_TIPS_ENABLED = {
|
|||||||
dismissedWelcomePopups: [],
|
dismissedWelcomePopups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_TIPS_DISABLED = {
|
export const ALL_TIPS_DISABLED = {
|
||||||
behavioralPrompts: {
|
behavioralPrompts: {
|
||||||
dontShowTips: true,
|
dontShowTips: true,
|
||||||
dismissedTips: BehavioralPrompt.values,
|
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 { HomeUtil } from "./playwrightHomeUtil";
|
||||||
import * as testUtils from "../server/testUtils";
|
import * as testUtils from "../server/testUtils";
|
||||||
import { server } from "./testServer";
|
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);
|
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());
|
const errors = await page.evaluate(() => (window as any).getAppErrors());
|
||||||
expect(errors).toEqual([]);
|
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.
|
* Contains some non-webdriver functionality needed by tests.
|
||||||
*/
|
*/
|
||||||
import FormData from 'form-data';
|
import FormData from 'form-data';
|
||||||
import { WebDriver } from 'mocha-webdriver';
|
|
||||||
import fetch from 'node-fetch';
|
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 { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
||||||
import { TestingHooksClient } from 'app/server/lib/TestingHooks';
|
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 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 {
|
export interface Server extends EventEmitter {
|
||||||
driver: WebDriver;
|
|
||||||
getTestingHooks(): Promise<TestingHooksClient>;
|
getTestingHooks(): Promise<TestingHooksClient>;
|
||||||
getHost(): string;
|
getHost(): string;
|
||||||
getUrl(team: string, relPath: string): 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).
|
* 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
|
* 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),
|
* Returns the current Grist session-id (for the selenium browser accessing this server),
|
||||||
* or null if there is no session.
|
* 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;
|
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> {
|
private async _getApiKey(page: Page): Promise<string> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const app = (window as any).gristApp;
|
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
|
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