Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ src/server/db/generated
/test-results/
/playwright-report/
/playwright/.cache/
e2e/.auth

certificates

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,13 @@ If you want to use the same set of custom duotone icons that Start UI is already
E2E tests are setup with Playwright.

```sh
pnpm e2e # Run tests in headless mode, this is the command executed in CI
pnpm e2e:ui # Open a UI which allow you to run specific tests and see test execution
pnpm e2e # Run tests in headless mode, this is the command executed in CI
pnpm e2e:setup # Setup context to be used across test for more efficient execution
pnpm e2e:ui # Open a UI which allow you to run specific tests and see test execution
```

> [!WARNING]
> The generated e2e context files contain authentication logic. If you make changes to your local database instance, you should re-run `pnpm e2e:setup`. It will be run automatically in a CI context.
## Production

```bash
Expand Down
2 changes: 1 addition & 1 deletion e2e/api-schema.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { expect, test } from 'e2e/utils';

test.describe('App Rest API Schema', () => {
test(`App API schema is building without error`, async ({ request }) => {
Expand Down
18 changes: 7 additions & 11 deletions e2e/login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import { expect, test } from '@playwright/test';
import { expect, test } from 'e2e/utils';
import { ADMIN_EMAIL, USER_EMAIL } from 'e2e/utils/constants';
import { pageUtils } from 'e2e/utils/page-utils';

test.describe('Login flow', () => {
test('Login as admin', async ({ page }) => {
const utils = pageUtils(page);
await page.goto('/login');
await utils.login({ email: ADMIN_EMAIL });
await page.to('/login');
await page.login({ email: ADMIN_EMAIL });
await page.waitForURL('/manager');
await expect(page.getByTestId('layout-manager')).toBeVisible();
});

test('Login as user', async ({ page }) => {
const utils = pageUtils(page);
await page.goto('/login');
await utils.login({ email: USER_EMAIL });
await page.to('/login');
await page.login({ email: USER_EMAIL });
await page.waitForURL('/app');
await expect(page.getByTestId('layout-app')).toBeVisible();
});

test('Login with redirect', async ({ page }) => {
const utils = pageUtils(page);
await page.goto('/app');
await utils.login({ email: ADMIN_EMAIL });
await page.to('/app');
await page.login({ email: ADMIN_EMAIL });
await page.waitForURL('/app');
await expect(page.getByTestId('layout-app')).toBeVisible();
});
Expand Down
32 changes: 32 additions & 0 deletions e2e/setup/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect } from '@playwright/test';
import { test as setup } from 'e2e/utils';
import {
ADMIN_EMAIL,
ADMIN_FILE,
USER_EMAIL,
USER_FILE,
} from 'e2e/utils/constants';

/**
* @see https://playwright.dev/docs/auth#multiple-signed-in-roles
*/

setup('authenticate as admin', async ({ page }) => {
await page.to('/login');
await page.login({ email: ADMIN_EMAIL });

await page.waitForURL('/manager');
await expect(page.getByTestId('layout-manager')).toBeVisible();

await page.context().storageState({ path: ADMIN_FILE });
});

setup('authenticate as user', async ({ page }) => {
await page.to('/login');
await page.login({ email: USER_EMAIL });

await page.waitForURL('/app');
await expect(page.getByTestId('layout-app')).toBeVisible();

await page.context().storageState({ path: USER_FILE });
});
74 changes: 74 additions & 0 deletions e2e/users.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { expect, test } from 'e2e/utils';
import { ADMIN_FILE, USER_FILE } from 'e2e/utils/constants';
import { randomString } from 'remeda';

test.describe('User management as user', () => {
test.use({ storageState: USER_FILE });

test('Should not have access', async ({ page }) => {
await page.to('/manager/users');

await expect(
page.getByText("You don't have access to this page")
).toBeVisible();
});
});

test.describe('User management as manager', () => {
test.use({ storageState: ADMIN_FILE });

test.beforeEach(async ({ page }) => {
await page.to('/manager/users');
});

test('Create a user', async ({ page }) => {
await page.getByText('New User').click();

const randomId = randomString(8);
const uniqueEmail = `new-user-${randomId}@user.com`;

// Fill the form
await page.waitForURL('/manager/users/new');
await page.getByLabel('Name').fill('New user');
await page.getByLabel('Email').fill(uniqueEmail);
await page.getByText('Create').click();

await page.waitForURL('/manager/users');
await page.getByPlaceholder('Search...').fill('new-user');
await expect(page.getByText(uniqueEmail)).toBeVisible();
});

test('Edit a user', async ({ page }) => {
await page.getByText('admin@admin.com').click({
force: true,
});

await page.getByRole('link', { name: 'Edit user' }).click();

const randomId = randomString(8);
const newAdminName = `Admin ${randomId}`;
await page.getByLabel('Name').fill(newAdminName);
await page.getByText('Save').click();

await expect(page.getByText(newAdminName).first()).toBeVisible();
});

test('Delete a user', async ({ page }) => {
await page
.getByText('user', {
exact: true,
})
.first()
.click({ force: true });

await page.getByRole('button', { name: 'Delete' }).click();

await expect(
page.getByText('You are about to permanently delete this user.')
).toBeVisible();

await page.getByRole('button', { name: 'Delete' }).click();

await expect(page.getByText('User deleted')).toBeVisible();
});
});
5 changes: 5 additions & 0 deletions e2e/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
const AUTH_FILE_BASE = 'e2e/.auth';

export const USER_FILE = `${AUTH_FILE_BASE}/user.json`;
export const USER_EMAIL = 'user@user.com';

export const ADMIN_FILE = `${AUTH_FILE_BASE}/admin.json`;
export const ADMIN_EMAIL = 'admin@admin.com';
9 changes: 9 additions & 0 deletions e2e/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { test as base } from '@playwright/test';
import { ExtendedPage, pageWithUtils } from 'e2e/utils/page';

const test = base.extend<ExtendedPage>({
page: pageWithUtils,
});

export * from '@playwright/test';
export { test };
61 changes: 0 additions & 61 deletions e2e/utils/page-utils.ts

This file was deleted.

66 changes: 66 additions & 0 deletions e2e/utils/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { expect, Page } from '@playwright/test';
import { CustomFixture } from 'e2e/utils/types';

import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants';

import {
AUTH_EMAIL_OTP_MOCKED,
AUTH_SIGNUP_ENABLED,
} from '@/features/auth/config';
import locales from '@/locales';
import { FileRouteTypes } from '@/routeTree.gen';

interface PageUtils {
/**
* Utility used to authenticate a user on the app
*/
login: (input: { email: string; code?: string }) => Promise<void>;

/**
* Override of the `page.goto` method with typed routes from the app
*/
to: (
url: FileRouteTypes['to'],
options?: Parameters<Page['goto']>[1]
) => ReturnType<Page['goto']>;
}

export type ExtendedPage = { page: PageUtils };

export const pageWithUtils: CustomFixture<Page & PageUtils> = async (
{ page },
apply
) => {
page.login = async function login(input: { email: string; code?: string }) {
const routeLogin = '/login' satisfies FileRouteTypes['to'];
const routeLoginVerify = '/login/verify' satisfies FileRouteTypes['to'];
await page.waitForURL(`**${routeLogin}**`);

await expect(
page.getByText(
locales[DEFAULT_LANGUAGE_KEY].auth.pageLoginWithSignUp.title
)
).toBeVisible();

await page
.getByPlaceholder(locales[DEFAULT_LANGUAGE_KEY].auth.common.email.label)
.fill(input.email);

await page
.getByRole('button', {
name: locales[DEFAULT_LANGUAGE_KEY].auth[
AUTH_SIGNUP_ENABLED ? 'pageLoginWithSignUp' : 'pageLogin'
].loginWithEmail,
})
.click();

await page.waitForURL(`**${routeLoginVerify}**`);
await page
.getByText(locales[DEFAULT_LANGUAGE_KEY].auth.common.otp.label)
.fill(input.code ?? AUTH_EMAIL_OTP_MOCKED);
};

page.to = page.goto;

await apply(page);
};
11 changes: 11 additions & 0 deletions e2e/utils/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {
PlaywrightTestArgs,
PlaywrightTestOptions,
TestFixture,
} from '@playwright/test';
import { ExtendedPage } from 'e2e/utils/page';

export type CustomFixture<TReturn> = TestFixture<
TReturn,
PlaywrightTestArgs & PlaywrightTestOptions & ExtendedPage
>;
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@
"test": "vitest --browser.headless",
"test:ci": "vitest run",
"test:ui": "vitest",
"e2e:setup": "dotenv -- cross-env playwright test --project=setup",
"e2e": "dotenv -- cross-env playwright test",
"e2e:ui": "dotenv -- cross-env playwright test --ui",
"dk:init": "docker compose up -d",
"dk:start": "docker compose start",
"dk:stop": "docker compose stop",
"dk:clear": "docker compose down --volumes",
"db:init": "pnpm db:push && pnpm db:seed",
"db:reset": "pnpm dk:clear && pnpm dk:init && pnpm db:init",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that db:init can fail here if db is not ready in the docker after dk:init 😕

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was more of a quick workaround to get a "Clean db state" between e2e test runs when running them in local !

We should discuss what's the best path forward to have consistent e2e test runs

"db:push": "prisma db push",
"db:ui": "prisma studio",
"db:seed": "dotenv -- cross-env node ./run-jiti ./prisma/seed/index.ts"
Expand Down
5 changes: 5 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,23 @@ export default defineConfig({

/* Configure projects for major browsers */
projects: [
// eslint-disable-next-line sonarjs/slow-regex
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: process.env.CI ? ['setup'] : [],
},

{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
dependencies: process.env.CI ? ['setup'] : [],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
dependencies: process.env.CI ? ['setup'] : [],
},
],

Expand Down
14 changes: 14 additions & 0 deletions prisma/seed/book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ export async function createBooks() {

await Promise.all(
Array.from({ length: Math.max(0, 100 - existingCount) }, async () => {
const author = faker.book.author();
const title = faker.book.title();

// Avoid @unique([title, author]) constraint failure
const book = await db.book.findFirst({
where: {
author,
title,
},
});
if (book) {
return;
}

await db.book.create({
data: {
author: faker.book.author(),
Expand Down
Loading