mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
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);
|
|
|
|
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);
|
|
}
|