From a2dd5292db050841e64c6f8ae5e5666306347120 Mon Sep 17 00:00:00 2001 From: Spoffy Date: Mon, 15 Jul 2024 18:06:17 +0100 Subject: [PATCH] Adds working Boot.playwright.ts --- test/nbrowser/Boot.playwright.ts | 26 ++--- test/nbrowser/playwrightGristUtils.ts | 5 + test/nbrowser/playwrightHomeUtil.ts | 156 ++++++++++++++++++++++++++ test/nbrowser/playwrightTestUtils.ts | 5 +- test/nbrowser/testServer.ts | 5 +- 5 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 test/nbrowser/playwrightHomeUtil.ts diff --git a/test/nbrowser/Boot.playwright.ts b/test/nbrowser/Boot.playwright.ts index ae421920..525d9bd3 100644 --- a/test/nbrowser/Boot.playwright.ts +++ b/test/nbrowser/Boot.playwright.ts @@ -1,8 +1,8 @@ -import {assert, driver} from 'mocha-webdriver'; +import { assert } from 'mocha-webdriver'; import * as gu from 'test/nbrowser/playwrightGristUtils'; -import {server, setupTestSuite} from 'test/nbrowser/playwrightTestUtils'; +import { server, setupTestSuite } from 'test/nbrowser/playwrightTestUtils'; import * as testUtils from 'test/server/testUtils'; -import { test, expect, Page } from '@playwright/test'; +import { expect, Page, test } from '@playwright/test'; /** * The boot page functionality has been merged with the Admin Panel. @@ -21,16 +21,16 @@ test.describe('Boot', () => { expect(text).toContain('GRIST_BOOT_KEY=example-'); } - test('tells user about /admin', async function() { - await driver.get(`${server.getHost()}/boot`); - assert.match(await driver.getPageSource(), /\/admin/); + test('tells user about /admin', async function({ page }) { + await page.goto(`${server.getHost()}/boot`); + assert.match(await page.content(), /\/admin/); // Switch to a regular place to that gu.checkForErrors won't panic - // it needs a Grist page. - await driver.get(`${server.getHost()}`); + await page.goto(`${server.getHost()}`); }); test('gives prompt about how to enable boot page', async function({ page }) { - await driver.get(`${server.getHost()}/admin`); + await page.goto(`${server.getHost()}/admin`); await hasPrompt(page); }); @@ -47,18 +47,18 @@ test.describe('Boot', () => { }); test('gives prompt when key is missing', async function({ page }) { - await driver.get(`${server.getHost()}/admin`); + await page.goto(`${server.getHost()}/admin`); await hasPrompt(page); }); test('gives prompt when key is wrong', async function({ page }) { - await driver.get(`${server.getHost()}/admin?boot-key=bilbo`); + await page.goto(`${server.getHost()}/admin?boot-key=bilbo`); await hasPrompt(page); }); - test('gives page when key is right', async function() { - await driver.get(`${server.getHost()}/admin?boot-key=lala`); - await driver.findContentWait('div', /Is home page available/, 2000); + test('gives page when key is right', async function({ page }) { + await page.goto(`${server.getHost()}/admin?boot-key=lala`); + await expect(page.getByText(/Is home page available/)).toBeVisible(); }); }); }); diff --git a/test/nbrowser/playwrightGristUtils.ts b/test/nbrowser/playwrightGristUtils.ts index 275d9978..9915de34 100644 --- a/test/nbrowser/playwrightGristUtils.ts +++ b/test/nbrowser/playwrightGristUtils.ts @@ -1,4 +1,9 @@ import { expect, Page } from '@playwright/test'; +import { HomeUtil } from "./playwrightHomeUtil"; +import * as testUtils from "../server/testUtils"; +import { server } from "./testServer"; + +export const homeUtil = new HomeUtil(testUtils.fixturesRoot, server); export async function checkForErrors(page: Page) { const errors = await page.evaluate(() => (window as any).getAppErrors()); diff --git a/test/nbrowser/playwrightHomeUtil.ts b/test/nbrowser/playwrightHomeUtil.ts new file mode 100644 index 00000000..694afad7 --- /dev/null +++ b/test/nbrowser/playwrightHomeUtil.ts @@ -0,0 +1,156 @@ +/** + * 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 { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; +import { TestingHooksClient } from 'app/server/lib/TestingHooks'; +import { BrowserContext, Page } from "@playwright/test"; +import EventEmitter = require('events'); + +export interface Server extends EventEmitter { + driver: WebDriver; + getTestingHooks(): Promise; + getHost(): string; + getUrl(team: string, relPath: string): string; + getDatabase(): Promise; + isExternalServer(): boolean; +} + +export class HomeUtil { + // Cache api keys of test users. It is often convenient to have various instances + // of the home api available while making browser tests. + private _apiKey = new Map(); + + constructor(public fixturesRoot: string, public server: Server) { + server.on('stop', () => { + this._apiKey.clear(); + }); + } + + /** + * 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 + * to be more nuanced. + */ + public async removeLogin(page: Page, org: string = "") { + // If cursor is on field editor, escape before remove login + await page.keyboard.press('Escape'); + if (!this.server.isExternalServer()) { + const testingHooks = await this.server.getTestingHooks(); + const sid = await this.getGristSid(page.context()); + if (sid) { await testingHooks.setLoginSessionProfile(sid, null, org); } + } else { + await page.goto(`${this.server.getHost()}/logout`); + } + } + + /** + * Returns the current Grist session-id (for the selenium browser accessing this server), + * or null if there is no session. + */ + public async getGristSid(context: BrowserContext): Promise { + // Load a cheap page on our server to get the session-id cookie from browser. + const newPage = await context.newPage(); + await newPage.goto(`${this.server.getHost()}/test/session`); + const cookie = (await context.cookies()) + .find(cookie => cookie.name === (process.env.GRIST_SESSION_COOKIE || 'grist_sid')); + if (!cookie) { return null; } + return decodeURIComponent(cookie.value); + } + + /** + * Create a new document. + */ + public async createNewDoc(username: string, org: string, workspace: string, docName: string, + options: {email?: string} = {}) { + const homeApi = this.createHomeApi(username, org, options.email); + const workspaceId = await this.getWorkspaceId(homeApi, workspace); + return await homeApi.newDoc({name: docName}, workspaceId); + } + + /** + * Create a copy of a doc. Similar to importFixturesDoc, but starts with an existing docId. + */ + public async copyDoc(username: string, org: string, workspace: string, + docId: string, options: {newName?: string} = {}) { + const homeApi = this.createHomeApi(username, org); + const docWorker = await homeApi.getWorkerAPI('import'); + const workspaceId = await this.getWorkspaceId(homeApi, workspace); + const uploadId = await docWorker.copyDoc(docId); + return docWorker.importDocToWorkspace(uploadId, workspaceId); + } + + // A helper that find a workspace id by name for a given username and org. + public async getWorkspaceId(homeApi: UserAPIImpl, workspace: string): Promise { + return (await homeApi.getOrgWorkspaces('current')).find((w) => w.name === workspace)!.id; + } + + // A helper that returns the list of names of all documents within a workspace. + public async listDocs(homeApi: UserAPI, wid: number): Promise { + const workspace = await homeApi.getWorkspace(wid); + return workspace.docs.map(d => d.name); + } + + // A helper to create a UserAPI instance for a given useranme and org, that targets the home server + // Username can be null for anonymous access. + public createHomeApi(username: string|null, org: string, email?: string): UserAPIImpl { + const apiKey = this.getApiKey(username, email); + return this._createHomeApiUsingApiKey(apiKey, org); + } + + public getApiKey(username: string|null, email?: string): string | null { + const name = (username || '').toLowerCase(); + const apiKey = username && ((email && this._apiKey.get(email)) || `api_key_for_${name}`); + return apiKey; + } + + /** + * Returns whether we are currently on any login page (including the test page). + */ + public async isOnLoginPage(page: Page) { + return await this.isOnGristLoginPage(page) || await this.isOnTestLoginPage(page); + } + + /** + * Returns whether we are currently on a Grist login page. + */ + public async isOnGristLoginPage(page: Page) { + const isOnSignupPage = await page.locator('css=.test-sp-heading').count() > 0; + const isOnLoginPage = await page.locator('css=.test-lp-heading').count() > 0; + return isOnSignupPage || isOnLoginPage; + } + + /** + * Returns whether we are currently on the test login page. + */ + public async isOnTestLoginPage(page: Page) { + return await page.getByText('A Very Credulous Login Page').count() > 0; + } + + // @ts-ignore + private async _getApiKey(page: Page): Promise { + return page.evaluate(() => { + const app = (window as any).gristApp; + if (!app) { return ""; } + const api: UserAPI = app.topAppModel.api; + return api.fetchApiKey().then(key => { + if (key) { return key; } + return api.createApiKey(); + }).catch(() => ""); + }); + } + + // Make a home api instance with the given api key, for the specified org. + // If no api key given, work anonymously. + private _createHomeApiUsingApiKey(apiKey: string|null, org?: string): UserAPIImpl { + const headers = apiKey ? {Authorization: `Bearer ${apiKey}`} : undefined; + return new UserAPIImpl(org ? this.server.getUrl(org, '') : this.server.getHost(), { + headers, + fetch: fetch as any, + newFormData: () => new FormData() as any, // form-data isn't quite type compatible + }); + } +} diff --git a/test/nbrowser/playwrightTestUtils.ts b/test/nbrowser/playwrightTestUtils.ts index 8b0c0862..c9f5d32c 100644 --- a/test/nbrowser/playwrightTestUtils.ts +++ b/test/nbrowser/playwrightTestUtils.ts @@ -57,7 +57,8 @@ export function setupTestSuite(options?: TestSuiteOptions) { // Also, log out, to avoid logins interacting, unless NO_CLEANUP is requested (useful for // debugging tests). if (!process.env.NO_CLEANUP) { - test.afterAll(() => server.removeLogin()); + // Don't think this is needed, each test runs in its own browser context. + //test.afterAll(() => server.removeLogin()); } // If requested, clear user preferences for all test users after this suite. @@ -153,7 +154,7 @@ export function setupRequirement(options: TestSuiteOptions) { } } - before(async function() { + test.beforeAll(async function() { if (new URL(server.getHost()).hostname !== 'localhost') { // Non-dev servers should already meet the requirements; in any case we should not diff --git a/test/nbrowser/testServer.ts b/test/nbrowser/testServer.ts index 4928a253..29d85066 100644 --- a/test/nbrowser/testServer.ts +++ b/test/nbrowser/testServer.ts @@ -27,6 +27,7 @@ import * as path from 'path'; import {removeConnection} from 'test/gen-server/seed'; import {HomeUtil} from 'test/nbrowser/homeUtil'; import {getDatabase} from 'test/testUtils'; +import { expect } from "@playwright/test"; export class TestServerMerged extends EventEmitter implements IMochaServer { public testDir: string; @@ -253,10 +254,10 @@ export class TestServerMerged extends EventEmitter implements IMochaServer { * Wait for the server to be up and responsitve, for up to `ms` milliseconds. */ public async waitServerReady(ms: number): Promise { - await this.driver.wait(() => Promise.race([ + await expect.poll(() => Promise.race([ this.isServerReady(), this._exitPromise.then(() => { throw new Error("Server exited while waiting for it"); }), - ]), ms); + ]), { timeout: ms }).toBe(true); } /**