Skip to content

Commit

Permalink
chore: Playwright refinements. (#315)
Browse files Browse the repository at this point in the history
* chore: Reset db before all e2e tests.

* refactor: Use Playwright's baseURL setting instead of explicitly providing a full URL on all navigations.

* refactor: Don't use storageState to log users in. Instead create the user and directly set session cookies when needed.

* refactor: Stop using zod to verify config.
  • Loading branch information
cullylarson committed Apr 14, 2023
1 parent 7b6fd6f commit 9ea82a2
Show file tree
Hide file tree
Showing 12 changed files with 248 additions and 705 deletions.
47 changes: 18 additions & 29 deletions packages/create-bison-app/template/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { z } from 'zod';

import { notEmpty } from './lib/type-witchcraft';

const stages = ['production', 'development', 'test'] as const;
Expand Down Expand Up @@ -33,6 +31,16 @@ function envToBoolean(value: string | undefined, defaultValue = false): boolean
return ['1', 'true'].includes(value.trim().toLowerCase()) ? true : false;
}

function envToString(value: string | undefined, defaultValue = '') {
return value === undefined ? defaultValue : value;
}

/*
function envToNumber(value: string | undefined, defaultValue: number): number {
return value === undefined || value === '' ? defaultValue : Number(value);
}
*/

export function isProduction() {
return stage === 'production';
}
Expand All @@ -49,45 +57,26 @@ export function isLocal() {
return isDevelopment() || isTesting();
}

// a bit more versatile form of boolean coercion than zod provides
const coerceBoolean = z
.string()
.optional()
.transform((value) => envToBoolean(value))
.pipe(z.boolean());

const configSchema = z.object({
stage: z.enum(stages),
ci: z.object({
isCi: coerceBoolean,
isPullRequest: coerceBoolean,
}),
database: z.object({
url: z.string(),
shouldMigrate: coerceBoolean,
}),
});

const stage = getStage(
[process.env.NODE_ENV, process.env.NEXT_PUBLIC_APP_ENV].filter(notEmpty).filter(isStage)
);

// NOTE: Remember that only env variables that start with NEXT_PUBLIC or are
// listed in next.config.js will be available on the client.
export const config = configSchema.parse({
export const config = {
stage,
ci: {
isCi: process.env.CI,
isPullRequest: process.env.IS_PULL_REQUEST,
isCi: envToBoolean(process.env.CI),
isPullRequest: envToBoolean(process.env.IS_PULL_REQUEST),
},
database: {
url: process.env.DATABASE_URL,
shouldMigrate: process.env.SHOULD_MIGRATE,
url: envToString(process.env.DATABASE_URL),
shouldMigrate: envToBoolean(process.env.SHOULD_MIGRATE),
},
git: {
commit: process.env.FC_GIT_COMMIT_SHA || process.env.RENDER_GIT_COMMIT,
commit: envToString(process.env.FC_GIT_COMMIT_SHA || process.env.RENDER_GIT_COMMIT),
},
auth: {
secret: process.env.NEXTAUTH_SECRET,
secret: envToString(process.env.NEXTAUTH_SECRET),
},
});
};
7 changes: 5 additions & 2 deletions packages/create-bison-app/template/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { config as appConfig } from '@/config';
const TEST_SERVER_PORT = process.env.PORT ? Number(process.env.PORT) : 3001;
const IS_CI = appConfig.ci.isCi;

const baseUrl = `http://localhost:${TEST_SERVER_PORT}`;

/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
Expand Down Expand Up @@ -41,10 +43,11 @@ const config: PlaywrightTestConfig = {
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: baseUrl,

/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3001',

/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
Expand Down
34 changes: 21 additions & 13 deletions packages/create-bison-app/template/tests/e2e/auth.play.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,39 @@
// https://playwright.dev/docs/next/auth#multiple-signed-in-roles
import { Page, test, expect } from '@playwright/test';
import { Role } from '@prisma/client';

import { ADMIN, APP_URL, USER } from './constants';
import { loginAs } from './helpers';

import { resetDB } from '@/tests/helpers/db';
import { disconnect } from '@/lib/prisma';

test.beforeEach(async () => resetDB());
test.afterAll(async () => disconnect());

test.describe(() => {
test.use({ storageState: USER.storageState });
test('Can login as a User', async ({ page }: { page: Page }) => {
const firstName = 'Qwerty';
await loginAs(page, { roles: [Role.USER], firstName });

test('Can Login as a User', async ({ page }: { page: Page }) => {
await page.goto(APP_URL);
await page.waitForURL((url) => url.origin === APP_URL, { waitUntil: 'networkidle' });
await page.goto('/');
await page.waitForSelector('internal:attr=[data-testid="welcome-header"]');

const welcomeHeader = await page.getByTestId('welcome-header');
const welcomeMsg = `Welcome, ${USER.firstName}!`;
const welcomeHeader = page.getByTestId('welcome-header');
const welcomeMsg = `Welcome, ${firstName}!`;
await expect(welcomeHeader).toContainText(welcomeMsg, { ignoreCase: true });
});
});

test.describe(() => {
test.use({ storageState: ADMIN.storageState });
test('Can Login as an Admin', async ({ page }: { page: Page }) => {
await page.goto(APP_URL);
await page.waitForURL((url) => url.origin === APP_URL, { waitUntil: 'networkidle' });
test('Can login as an Admin', async ({ page }: { page: Page }) => {
const firstName = 'Zxcv';
await loginAs(page, { roles: [Role.ADMIN], firstName });

await page.goto('/');
await page.waitForSelector('internal:attr=[data-testid="welcome-header"]');

const welcomeHeader = await page.getByTestId('welcome-header');
const welcomeMsg = `Welcome, ${ADMIN.firstName}!`;
const welcomeHeader = page.getByTestId('welcome-header');
const welcomeMsg = `Welcome, ${firstName}!`;
await expect(welcomeHeader).toContainText(welcomeMsg, { ignoreCase: true });
});
});
19 changes: 0 additions & 19 deletions packages/create-bison-app/template/tests/e2e/constants.ts

This file was deleted.

76 changes: 6 additions & 70 deletions packages/create-bison-app/template/tests/e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,13 @@
import { chromium, FullConfig } from '@playwright/test';
import { Prisma, Role } from '@prisma/client';
import { promisify } from 'util';
import childProcess from 'child_process';

import { UserFactory } from '../factories';
import { FullConfig } from '@playwright/test';

import { ADMIN, APP_URL, LOGIN_URL, USER } from './constants';
const exec = promisify(childProcess.exec);

async function globalSetup(_config: FullConfig) {
// There's a case where a config/setup may fail in which case teardown doesn't fire
// Upsert here to avoid manual cleanup when testing locally.
const adminArgs: Prisma.UserCreateInput = {
email: ADMIN.email,
emailVerified: new Date().toISOString(),
roles: [Role.ADMIN],
password: ADMIN.password,
profile: {
create: {
firstName: ADMIN.firstName,
lastName: ADMIN.lastName,
},
},
};

const _adminUser = await UserFactory.upsert({
where: { email: ADMIN.email },
createArgs: adminArgs,
updateArgs: {
...adminArgs,
profile: { update: { firstName: ADMIN.firstName, lastName: ADMIN.lastName } },
},
}).catch((e) => console.log({ e }));

const userArgs: Prisma.UserCreateInput = {
email: USER.email,
emailVerified: new Date().toISOString(),
roles: [Role.USER],
password: USER.password,
accounts: {},
profile: {
create: {
firstName: USER.firstName,
lastName: USER.lastName,
},
},
};

const _user = await UserFactory.upsert({
where: { email: USER.email },
createArgs: userArgs,
updateArgs: {
...userArgs,
profile: { update: { firstName: USER.firstName, lastName: USER.lastName } },
},
}).catch((e) => console.log({ e }));

const browser = await chromium.launch();
const adminPage = await browser.newPage();
await adminPage.goto(LOGIN_URL);
await adminPage.getByTestId('login-email').fill(ADMIN.email);
await adminPage.getByTestId('login-password').fill(ADMIN.password);
await adminPage.getByTestId('login-submit').click();
await adminPage.waitForNavigation();
await adminPage.waitForURL((url) => url.origin === APP_URL, { waitUntil: 'networkidle' });
await adminPage.context().storageState({ path: ADMIN.storageState });

const userPage = await browser.newPage();
await userPage.goto(LOGIN_URL);
await userPage.getByTestId('login-email').fill(USER.email);
await userPage.getByTestId('login-password').fill(USER.password);
await userPage.getByTestId('login-submit').click();
await userPage.waitForNavigation();
await userPage.waitForURL((url) => url.origin === APP_URL, { waitUntil: 'networkidle' });
await userPage.context().storageState({ path: USER.storageState });
await browser.close();
// Run the migrations to ensure our schema has the required structure
await exec('yarn prisma migrate deploy', { env: process.env });
}

export default globalSetup;
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { FullConfig } from '@playwright/test';

import { prisma } from '@/lib/prisma';
import { getSchema } from '@/tests/helpers/db';
import { config } from '@/config';

async function globalTeardown(_config: FullConfig) {
prisma.$executeRaw`DROP SCHEMA public CASCADE`;
prisma.$executeRaw`CREATE SCHEMA public`;
const schema = getSchema(config.database.url);

prisma.$executeRaw`DROP SCHEMA ${schema} CASCADE`;
prisma.$executeRaw`CREATE SCHEMA ${schema}`;
}

export default globalTeardown;
91 changes: 88 additions & 3 deletions packages/create-bison-app/template/tests/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,40 @@
import Chance from 'chance';
import { BrowserContext, Locator, Page } from '@playwright/test';
import { Role } from '@prisma/client';
import { encode as encodeJwt } from 'next-auth/jwt';
import { nanoid } from 'nanoid';

import config from '@/playwright.config';
import { UserFactory } from '@/tests/factories/user';
import playwrightConfig from '@/playwright.config';
import { config } from '@/config';

const chance = new Chance();

export type SessionUser = {
id: string;
firstName?: string;
lastName?: string;
email: string;
roles: Role[];
};

const generateSessionToken = async (user: SessionUser, nowS: number): Promise<string> => {
return encodeJwt({
token: {
email: user.email,
sub: user.id,
user: {
id: user.id,
email: user.email,
roles: user.roles,
},
iat: nowS - 3600,
exp: nowS + 7200,
jti: nanoid(),
},
secret: config.auth.secret,
});
};

export async function clickNewPage(
context: BrowserContext,
Expand All @@ -15,8 +49,8 @@ export async function clickNewPage(

// the page doesn't seem to inherit the timeout from context, so setting it
// here too
newPage.setDefaultTimeout(config.timeout || 30000);
newPage.setDefaultNavigationTimeout(config.timeout || 30000);
newPage.setDefaultTimeout(playwrightConfig.timeout || 30000);
newPage.setDefaultNavigationTimeout(playwrightConfig.timeout || 30000);

await newPage.waitForLoadState();

Expand All @@ -35,3 +69,54 @@ export async function uploadFile(page: Page, clickableTarget: Locator, filePath:

await fileChooser.setFiles(filePath);
}

export async function loginAs(page: Page, userInitial: Partial<SessionUser>) {
const nowMs = Date.now();
const nowS = Math.floor(nowMs / 1000);

const user = {
email: chance.email(),
firstName: chance.first(),
lastName: chance.last(),
roles: [Role.USER],
...userInitial,
};

const createArgs = {
email: user.email,
roles: user.roles,
emailVerified: new Date().toISOString(),
profile: {
create: {
firstName: user.firstName,
lastName: user.lastName,
},
},
};

const userPrisma = await UserFactory.create(createArgs);

const sessionToken = await generateSessionToken(
{
...userPrisma,
...userPrisma.profile,
},
nowS
);

const context = page.context();

await context.addCookies([
{
name: 'next-auth.session-token',
value: sessionToken,
domain: 'localhost',
path: '/',
httpOnly: true,
sameSite: 'Lax',
expires: nowS + 7200,
},
]);

return userPrisma;
}
Loading

0 comments on commit 9ea82a2

Please sign in to comment.