Skip to content

Commit 195afdd

Browse files
authored
tests: add e2e tests package (#525)
* tests: add e2e tests package * docs(tests): update README.md with additional information * docs(tests): Update README.md
1 parent e0e1b97 commit 195afdd

29 files changed

+1048
-26
lines changed

.prettierignore

+5-1
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,8 @@ packages-licenses.json
1414
/packages/marble-api/src/generated
1515

1616
# ui-design-system
17-
/packages/ui-design-system/storybook-static/
17+
/packages/ui-design-system/storybook-static/
18+
19+
# tests
20+
/packages/tests/playwright-report
21+
/packages/tests/test-results

packages/app-builder/src/components/Auth/SignInWithEmailAndPassword.tsx

+10-2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { FormProvider, useForm, useFormContext } from 'react-hook-form';
2525
import toast from 'react-hot-toast';
2626
import { useTranslation } from 'react-i18next';
2727
import { ClientOnly } from 'remix-utils/client-only';
28+
import { useHydrated } from 'remix-utils/use-hydrated';
2829
import { Button, Input } from 'ui-design-system';
2930
import * as z from 'zod';
3031

@@ -76,6 +77,7 @@ function SignInWithEmailAndPasswordForm({
7677
}) {
7778
const { t } = useTranslation(['auth', 'common']);
7879
const { control } = useFormContext<EmailAndPasswordFormValues>();
80+
const hydrated = useHydrated();
7981
return (
8082
<form noValidate className="flex w-full flex-col gap-4" {...props}>
8183
<FormField
@@ -85,7 +87,12 @@ function SignInWithEmailAndPasswordForm({
8587
<FormItem className="flex flex-col items-start gap-2">
8688
<FormLabel>{t('auth:sign_in.email')}</FormLabel>
8789
<FormControl>
88-
<Input className="w-full" type="email" {...field} />
90+
<Input
91+
disabled={!hydrated}
92+
className="w-full"
93+
type="email"
94+
{...field}
95+
/>
8996
</FormControl>
9097
<FormError />
9198
</FormItem>
@@ -102,6 +109,7 @@ function SignInWithEmailAndPasswordForm({
102109
className="w-full"
103110
type="password"
104111
autoComplete="current-password"
112+
disabled={!hydrated}
105113
{...field}
106114
/>
107115
</FormControl>
@@ -114,7 +122,7 @@ function SignInWithEmailAndPasswordForm({
114122
name="credentials"
115123
render={() => <FormError />}
116124
/>
117-
<Button type="submit">
125+
<Button type="submit" disabled={!hydrated}>
118126
{loading ? <Spinner className="size-4" /> : t('auth:sign_in')}
119127
</Button>
120128
</form>

packages/app-builder/src/routes/_builder+/lists+/$listId.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export default function Lists() {
8787
tabIndex={-1}
8888
>
8989
<Icon icon="delete" className="size-6 shrink-0" />
90+
<span className="sr-only">{t('common:delete')}</span>
9091
</button>
9192
</DeleteListValue>
9293
) : null}

packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/i+/$iterationId+/_edit-view+/decision.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { type ActionFunctionArgs, json } from '@remix-run/node';
2424
import { Form, useActionData } from '@remix-run/react';
2525
import { type Namespace, type TFunction } from 'i18next';
2626
import { type ScenarioValidationErrorCodeDto } from 'marble-api';
27-
import React from 'react';
27+
import * as React from 'react';
2828
import { Trans, useTranslation } from 'react-i18next';
2929
import * as R from 'remeda';
3030
import { Button, Collapsible } from 'ui-design-system';

packages/app-builder/src/routes/ressources+/lists+/create.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useFetcher } from '@remix-run/react';
1717
import { type Namespace } from 'i18next';
1818
import { Form, FormProvider, useForm } from 'react-hook-form';
1919
import { useTranslation } from 'react-i18next';
20+
import { useHydrated } from 'remix-utils/use-hydrated';
2021
import { Button, Input, Modal } from 'ui-design-system';
2122
import { Icon } from 'ui-icons';
2223
import { z } from 'zod';
@@ -86,6 +87,7 @@ export async function action({ request }: ActionFunctionArgs) {
8687
export function CreateList() {
8788
const { t } = useTranslation(handle.i18n);
8889
const fetcher = useFetcher<typeof action>();
90+
const hydrated = useHydrated();
8991

9092
const formMethods = useForm<z.infer<typeof createListFormSchema>>({
9193
progressive: true,
@@ -100,7 +102,7 @@ export function CreateList() {
100102
return (
101103
<Modal.Root>
102104
<Modal.Trigger asChild>
103-
<Button>
105+
<Button disabled={!hydrated}>
104106
<Icon icon="plus" className="size-6" />
105107
{t('lists:create_list.title')}
106108
</Button>

packages/app-builder/src/routes/ressources+/lists+/delete.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type ActionFunctionArgs, redirect } from '@remix-run/node';
55
import { useFetcher } from '@remix-run/react';
66
import { type Namespace } from 'i18next';
77
import { useTranslation } from 'react-i18next';
8+
import { useHydrated } from 'remix-utils/use-hydrated';
89
import { Button, HiddenInputs, Modal } from 'ui-design-system';
910
import { Icon } from 'ui-icons';
1011
import { z } from 'zod';
@@ -36,11 +37,12 @@ export async function action({ request }: ActionFunctionArgs) {
3637
export function DeleteList({ listId }: { listId: string }) {
3738
const { t } = useTranslation(handle.i18n);
3839
const fetcher = useFetcher<typeof action>();
40+
const hydrated = useHydrated();
3941

4042
return (
4143
<Modal.Root>
4244
<Modal.Trigger asChild>
43-
<Button color="red" className="w-fit">
45+
<Button color="red" className="w-fit" disabled={!hydrated}>
4446
<Icon icon="delete" className="size-6" />
4547
<p>{t('lists:delete_list.button')}</p>
4648
</Button>

packages/app-builder/src/routes/ressources+/lists+/edit.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { type Namespace } from 'i18next';
1515
import { useEffect, useState } from 'react';
1616
import { Form, FormProvider, useForm } from 'react-hook-form';
1717
import { useTranslation } from 'react-i18next';
18+
import { useHydrated } from 'remix-utils/use-hydrated';
1819
import { Button, HiddenInputs, Input, Modal } from 'ui-design-system';
1920
import { Icon } from 'ui-icons';
2021
import { z } from 'zod';
@@ -87,11 +88,12 @@ export function EditList({
8788
setIsOpen(false);
8889
}
8990
}, [fetcher.data?.success, fetcher.state]);
91+
const hydrated = useHydrated();
9092

9193
return (
9294
<Modal.Root open={isOpen} onOpenChange={setIsOpen}>
9395
<Modal.Trigger asChild>
94-
<Button variant="secondary">
96+
<Button variant="secondary" disabled={!hydrated}>
9597
<Icon icon="edit" className="size-6" />
9698
<p>{t('lists:edit_list.button')}</p>
9799
</Button>

packages/app-builder/src/routes/ressources+/scenarios+/create.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useFetcher } from '@remix-run/react';
2121
import { type Namespace } from 'i18next';
2222
import * as React from 'react';
2323
import { Trans, useTranslation } from 'react-i18next';
24+
import { useHydrated } from 'remix-utils/use-hydrated';
2425
import { Button, ModalV2 } from 'ui-design-system';
2526
import { z } from 'zod';
2627

@@ -78,9 +79,10 @@ export async function action({ request }: ActionFunctionArgs) {
7879
}
7980

8081
export function CreateScenario({ children }: { children: React.ReactElement }) {
82+
const hydrated = useHydrated();
8183
return (
8284
<ModalV2.Root>
83-
<ModalV2.Trigger render={children} />
85+
<ModalV2.Trigger render={children} disabled={!hydrated} />
8486
<ModalV2.Content>
8587
<CreateScenarioContent />
8688
</ModalV2.Content>

packages/app-builder/src/routes/ressources+/scenarios+/update.tsx

+15-13
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import { useFetcher, useNavigation } from '@remix-run/react';
1818
import * as React from 'react';
1919
import { useTranslation } from 'react-i18next';
2020
import { redirectBack } from 'remix-utils/redirect-back';
21-
import { Button, Modal } from 'ui-design-system';
21+
import { useHydrated } from 'remix-utils/use-hydrated';
22+
import { Button, ModalV2 } from 'ui-design-system';
2223
import { z } from 'zod';
2324

2425
const updateScenarioFormSchema = z.object({
@@ -75,10 +76,11 @@ export function UpdateScenario({
7576
children,
7677
defaultValue,
7778
}: {
78-
children: React.ReactNode;
79+
children: React.ReactElement;
7980
defaultValue: UpdateScenarioForm;
8081
}) {
8182
const [open, setOpen] = React.useState(false);
83+
const hydrated = useHydrated();
8284

8385
const navigation = useNavigation();
8486
React.useEffect(() => {
@@ -88,12 +90,12 @@ export function UpdateScenario({
8890
}, [navigation.state]);
8991

9092
return (
91-
<Modal.Root open={open} onOpenChange={setOpen}>
92-
<Modal.Trigger asChild>{children}</Modal.Trigger>
93-
<Modal.Content>
93+
<ModalV2.Root open={open} setOpen={setOpen}>
94+
<ModalV2.Trigger render={children} disabled={!hydrated} />
95+
<ModalV2.Content>
9496
<UpdateScenarioContent defaultValue={defaultValue} />
95-
</Modal.Content>
96-
</Modal.Root>
97+
</ModalV2.Content>
98+
</ModalV2.Root>
9799
);
98100
}
99101

@@ -124,7 +126,7 @@ function UpdateScenarioContent({
124126
action={getRoute('/ressources/scenarios/update')}
125127
{...getFormProps(form)}
126128
>
127-
<Modal.Title>{t('scenarios:update_scenario.title')}</Modal.Title>
129+
<ModalV2.Title>{t('scenarios:update_scenario.title')}</ModalV2.Title>
128130
<div className="flex flex-col gap-6 p-6">
129131
<input
130132
{...getInputProps(fields.scenarioId, { type: 'hidden' })}
@@ -155,11 +157,11 @@ function UpdateScenarioContent({
155157
<FormErrorOrDescription />
156158
</FormField>
157159
<div className="flex flex-1 flex-row gap-2">
158-
<Modal.Close asChild>
159-
<Button className="flex-1" variant="secondary">
160-
{t('common:cancel')}
161-
</Button>
162-
</Modal.Close>
160+
<ModalV2.Close
161+
render={<Button className="flex-1" variant="secondary" />}
162+
>
163+
{t('common:cancel')}
164+
</ModalV2.Close>
163165
<Button className="flex-1" variant="primary" type="submit">
164166
{t('common:save')}
165167
</Button>

packages/tests/.eslintrc.cjs

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/** @type {import("eslint").Linter.Config} */
2+
module.exports = {
3+
root: true,
4+
extends: ['@marble/eslint-config/default'],
5+
rules: {
6+
// Not applicable for Playwright
7+
'testing-library/prefer-screen-queries': 'off',
8+
},
9+
};

packages/tests/.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules/
2+
/test-results/
3+
/playwright-report/
4+
/blob-report/
5+
/playwright/.cache/

packages/tests/README.md

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Tests
2+
3+
The `marble-front/tests` package is designed to provide a comprehensive set of end-to-end tests for the Marble front-end application. By running these tests, developers can ensure that the front-end application is functioning correctly and that any changes or updates to the codebase do not introduce regressions or bugs.
4+
5+
> **Note:** The tests in this package are written using the [Playwright](https://playwright.dev/) testing framework.
6+
7+
## Getting Started
8+
9+
To get started with running the tests in this package, follow these steps:
10+
11+
1. Install Playwright (if you have not already done so):
12+
13+
```bash
14+
pnpm --filter tests exec playwright install
15+
```
16+
17+
2. Create the test organisation (if it does not already exist):
18+
1. Open the backoffice application
19+
2. Create a new organisation with the name `e2e`. Do not forget to check "init with demo data"
20+
3. Create a new admin user on this org with the email `[email protected]`
21+
3. Start a local test environment:
22+
1. start the Firebase emulator suite (look at the backend README.md for more information)
23+
2. start the backend normally
24+
3. start the front-end application normally
25+
4. Run the tests using :
26+
1. the command `pnpm --filter tests test`, for cmd line "fast" run
27+
2. the command `pnpm --filter tests test:ui`, to open the browser and see the tests running
28+
29+
> **Note:** The tests in this package are designed to be run against a local test environment. Steps 2 and 3 are manual steps that will be automated in the future.
30+
31+
## Contributing
32+
33+
If you would like to contribute to the tests in this package, we recommend using the VS Code extension for Playwright to help you write and debug tests.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { getRoute } from '@app-builder/utils/routes';
2+
import { expect, test } from 'tests/fixtures';
3+
import { SignInPage } from 'tests/page-object-models/auth-pages';
4+
5+
const user = {
6+
7+
password: 'password',
8+
};
9+
// Since we do use the same user email for all tests, we need to run them serially.
10+
test.describe.configure({ mode: 'serial' });
11+
12+
test.afterEach(async ({ firebase }) => {
13+
await firebase.deleteUser(user.email);
14+
});
15+
16+
test('Users can sign in with Google', async ({ page, firebase }) => {
17+
const signInPage = new SignInPage(page);
18+
await signInPage.goto();
19+
const firebasePopup = firebase.popup(page);
20+
await signInPage.signInWithGoogle();
21+
await firebasePopup.signUpWithSSO(user.email);
22+
23+
// Ensure signed in user is redirected to the scenarios page
24+
await page.waitForURL(getRoute('/scenarios/'));
25+
await page.getByRole('button').first().click();
26+
await expect(page.getByText(user.email)).toBeVisible();
27+
});
28+
29+
test('Users can sign in with Microsoft', async ({ page, firebase }) => {
30+
const signInPage = new SignInPage(page);
31+
await signInPage.goto();
32+
const firebasePopup = firebase.popup(page);
33+
await signInPage.signInWithMicrosoft();
34+
await firebasePopup.signUpWithSSO(user.email);
35+
36+
// Ensure signed in user is redirected to the scenarios page
37+
await page.waitForURL(getRoute('/scenarios/'));
38+
await page.getByRole('button').first().click();
39+
await expect(page.getByText(user.email)).toBeVisible();
40+
});
41+
42+
test('Users can sign up with email and password', async ({
43+
page,
44+
firebase,
45+
}) => {
46+
const signInPage = new SignInPage(page);
47+
await signInPage.goto();
48+
await page.getByRole('link', { name: 'Sign up' }).click();
49+
await page.waitForURL(getRoute('/sign-up'));
50+
51+
await page.getByLabel('Email').fill(user.email);
52+
await page.getByLabel('Password').fill(user.password);
53+
await page.getByRole('button', { name: 'Sign up' }).click();
54+
55+
await page.waitForURL(getRoute('/email-verification'));
56+
await firebase.verifyUser(user.email);
57+
await page.getByRole('link', { name: 'Sign in' }).click();
58+
await page.waitForURL(getRoute('/sign-in'));
59+
60+
await signInPage.signInWithEmail(user);
61+
62+
// Ensure signed in user is redirected to the scenarios page
63+
await page.waitForURL(getRoute('/scenarios/'));
64+
await page.getByRole('button').first().click();
65+
await expect(page.getByText(user.email)).toBeVisible();
66+
});

0 commit comments

Comments
 (0)