/* Helpers for the tests in `tests/` that does things like deal with [Playwright](https://playwright.dev) setup, waiting for things in /pages, fillng out forms, and making fake users. For a good example of using almost everything look at `tests/ui/registration.js`. ### Usage I like to pull out each function I use separately, then set everything up like this: ```javascript import {sleep, expect, tid, playstart, playstop, form, wait} from '../../lib/testing.js'; import { base_host } from "../../lib/config.js"; import {knex} from '../../lib/ormish.js'; test.before(async t => t.context = await playstart(`${base_host}/client/#/register/`)); test.after(async t => { knex.destroy(); await playstop(t.context.browser, t.context.p); }); ``` This gets `Playwright` setup to start at the right URL for the test, and makes sure that it's stopped with `playstop`. This is important so that coverage works. You'll also notice that I'm manually doing `knex.destroy()`. If you don't do this then your testing runs will hang while Node waits for `knex` to exit. */ import playwright from 'playwright'; import faker from 'faker'; import random from 'random'; import assert from "assert"; import { v4 as uuid } from "uuid"; import fs from "fs"; import url from "url"; import path from "path"; import { User } from "./models.js"; const WAIT_TIME = 5000; const HEADLESS = !process.env.PLAYVIEW; const COVERAGE = process.env.NODE_V8_COVERAGE; /* Generates the CSS selector to match the `data-testid` for finding HTML tags in tests. */ export const tid = (name) => `[data-testid="${name}"]`; /* Fills in a form using the id="" of each field, then click the button basedon the `tid(button_id)`. For example, here's how the stock registration form is tested: ```javascript await form(p, { "#email": user.email, "#full_name": user.full_name, "#initials": "XXX", "#password": user.password, "#password_repeat": user.password, "#tos_agree": true, }, tid("register-button")); ``` __FOOTGUN__: This only works with one form on the page. If you have multiple forms then it'll probably fill in fields randomly based on the ids. + `page Object` -- [Playwright](https://playwright.dev) page object. + `fields Object` -- Mapping of "#field-id" to value to type. + `button_id string` (___optional___) -- The name of the button to press to submit (or anything). This is passed to `tid()` so do not include the "#". */ export const form = async (page, fields, button_id) => { for(const [key, value] of Object.entries(fields)) { if(typeof value === "boolean") { if(value) { await page.check(key); } else { await page.uncheck(key); } } else { // it's another input to fill await page.fill(key, value); } } if(button_id) { await page.click(button_id); } } /* Sets up a [Playwright](https://playwright.dev) instance that uses Chromium, handles HEADLESS or not, starts at a starting URL, and sets up any coverage requirements. ### Headless PLAYVIEW The code for whether to show the browser window or not is based on if you set `PLAYVIEW=1` environment variables then you'll see the browser windows. This makes debugging easier. ### Coverage Coverage is determined by whether you set `NODE_V8_COVERAGE=.coverage` environment variables. This is specific to Node.js but that's the only thing I've tested. Setting this will enable coverage information in the __browser__, which is important for coverage analysis during testing. You also need to set this in the back APIs as well, but the `package.json` has setups for this too. + `url string` -- The starting URL for the Playwright instance. + `{browser, context, p: page}` -- Almost everything you need right away. */ export const playstart = async (url) => { if(!HEADLESS) console.log("Running playwright in visible mode (not HEADLESS)."); if(COVERAGE) console.log(`Coverage reports going to ${COVERAGE}.`); const browser = await playwright['chromium'].launch({headless: HEADLESS}); const context = await browser.newContext(); const p = await context.newPage(); if(COVERAGE) { await p.coverage.startJSCoverage(); } p.on('console', async msg => { console.debug(`CONSOLE ${msg.type()}>>`, msg) }); await p.goto(url); return {browser, context, p}; } /* Properly stops [Playwright](https://playwright.dev) if you have coverage enabled (see `playstop`). There's a lot of things you need to do when you stop coverage, so use this or your coverage will not actually be saved. The file is saved in the directory specified by `NODE_V8_COVERAGE`, which is usually `.coverage`. + `browser Object` -- Playwright browser object from `playstart`. + `p Object` -- Playwright page object from `playstart`. */ export const playstop = async (browser, p) => { if(COVERAGE) { const coverage = await p.coverage.stopJSCoverage(); for(const entry of coverage) { entry.url = path.join(process.cwd(), "public", url.parse(entry.url).pathname); } const v8out = {result: coverage, timestamp: Date.now()}; fs.writeFileSync(`${COVERAGE}/${uuid()}.json`, JSON.stringify(v8out)); } await browser.close(); } /* Waits for the `css_id` selector to be visible on the page. Most external browser controller systems are incredibly annoying because they're based on timing and aysnc waiting for things in the page. This does all the annoying waiting and logging an error message so you can figure out what's going on. + `p Object` -- Playwright page object. + `css_id string` -- CSS ID to wait for, use `tid()` to use a `data-testid`. */ export const wait = async (p, css_id) => { try { await p.waitForSelector(css_id, { timeout: WAIT_TIME }); } catch(error) { console.error(error, `!!!!!!!!!!!!!!!!!!!!!!!!!!!! Failed waiting for id ${css_id}`); throw error; } } /* Uses the [Ava](https://github.com/avajs/ava) test variable `t` to confirm that a tag with the given `css_id` exists. It will wait for this `css_id`, get the tag for it, then return the `tag.textContent()`. You can then do additional tests to make sure it contains the right content. + `t Object` -- Ava test object. + `p Object` -- Playwright page object. + `css_id string` -- Use a `tid()` to target a `data-testid`. */ export const expect = async (t, p, css_id) => { await wait(p, css_id); let tag = await p.$(css_id); let text = tag ? await tag.textContent() : "NULL!"; t.true(text !== null); return text; } /* Due to the timing of requests from the browser to the backend you sometimes have to wait for data to get stored and things to happen. This will do that by returning a `Promise` that resolves after a certain `ms` miliseconds. + `ms number` -- Miliseconds to wait, passed to `setTimeout`. + ___return___ `Promise` -- await on this. */ export const sleep = (ms) => { return new Promise(resolve => setTimeout(resolve, ms)); } /* Generate a random user for use in login forms and other things. + return {email, full_name, initials, password} */ export const random_user = () => { let email = faker.internet.email(); let rnd = random.int(0, 10 * 1000); email = email.replace('@', `${rnd}@`); let full_name = faker.name.findName(); let initials = full_name.split(' ').map(x => x[0]).join(''); let password = faker.internet.password(); assert(password, "Looks like faker is failing at passwords again."); return { email, full_name, initials, password } } /* Generates a `random_user` and then register it in the database. It will add the field `user.raw_password` to the user that's returned so you can use the password in testing. + `is_admin boolean (false)` -- Should this user be an admin or not? + ___return___ `User` -- The `User` object from `lib/models.js`. */ export const register_user = async (is_admin=false) => { const rando = random_user(); rando.password_repeat = rando.password; // copy it to keep the original password const user = await User.register({...rando}); if(is_admin) { // make this user an admin user.admin = true; await User.update({id: user.id}, {admin: true}); } // then replace it so we can use it later user.raw_password = rando.password; return user; } /* A helper function for other tests to use logins. ___FOOTGUN___: It must have `email` and `raw_password` in it for this to work. The `register_user` function sets this up for you but if you want to query or craft your own `User` you'll need to ensure that. + `t Object` -- Ava testing object. + `p Object` -- Playwright page object. + `with_user User` -- A `User` object from `lib/models.js`. */ export const login = async (t, p, with_user) => { const user = with_user ? with_user : await register_user(); await expect(t, p, tid("login-form")); // fill out correct form, and redirect to watch await p.fill('#email', user.email); await p.fill("#password", user.raw_password); await p.click(tid('login-button')); await sleep(1000); return user; }