diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..101089e1e --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,33 @@ +name: E2E + +on: + pull_request: + branches: ['**'] + paths-ignore: + - '**/*.md' + - 'static/**/*' + +env: + VITE_STRIPE_PUBLIC_KEY: ${{ vars.VITE_STRIPE_PUBLIC_KEY }} + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + - name: E2E Tests + run: npm run e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a0490076e..a93dc1709 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,6 @@ jobs: - name: Linter run: npm run lint - name: Unit Tests - run: npm test + run: npm run test - name: Build Console run: npm run build diff --git a/.gitignore b/.gitignore index 27cc4e27e..ec4979cd0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ node_modules .env .env.* !.env.example +test-results/ +playwright-report/ .DS_STORE .cache @@ -145,4 +147,4 @@ dist .stylelintcache # SvelteKit build / generate output -.svelte-kit \ No newline at end of file +.svelte-kit diff --git a/package-lock.json b/package-lock.json index 3635edfbe..4ba09abc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "devDependencies": { "@melt-ui/pp": "^0.1.4", "@melt-ui/svelte": "^0.61.2", - "@playwright/test": "^1.37.1", + "@playwright/test": "^1.44.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/kit": "^2.3.4", "@sveltejs/vite-plugin-svelte": "^3.0.1", @@ -1895,12 +1895,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", - "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.0.tgz", + "integrity": "sha512-rNX5lbNidamSUorBhB4XZ9SQTjAqfe5M+p37Z8ic0jPFBMo5iCtQz1kRWkEMg+rYOKSlVycpQmpqjSFq7LXOfg==", "dev": true, "dependencies": { - "playwright": "1.43.1" + "playwright": "1.44.0" }, "bin": { "playwright": "cli.js" @@ -7678,12 +7678,12 @@ } }, "node_modules/playwright": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", - "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.0.tgz", + "integrity": "sha512-F9b3GUCLQ3Nffrfb6dunPOkE5Mh68tR7zN32L4jCk4FjQamgesGay7/dAAe1WaMEGV04DkdJfcJzjoCKygUaRQ==", "dev": true, "dependencies": { - "playwright-core": "1.43.1" + "playwright-core": "1.44.0" }, "bin": { "playwright": "cli.js" @@ -7696,9 +7696,9 @@ } }, "node_modules/playwright-core": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", - "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.0.tgz", + "integrity": "sha512-ZTbkNpFfYcGWohvTTl+xewITm7EOuqIqex0c7dNZ+aXsbrLj0qI8XlGKfPpipjm0Wny/4Lt4CJsWJk1stVS5qQ==", "dev": true, "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index 6bdf258a6..d3cd60e05 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "test": "TZ=EST vitest run", "test:ui": "TZ=EST vitest --ui", "test:watch": "TZ=EST vitest watch", - "e2e": "playwright test tests/e2e" + "e2e": "playwright test tests/e2e", + "e2e:ui": "playwright test tests/e2e --ui" }, "dependencies": { "@appwrite.io/console": "^0.6.1", @@ -41,7 +42,7 @@ "devDependencies": { "@melt-ui/pp": "^0.1.4", "@melt-ui/svelte": "^0.61.2", - "@playwright/test": "^1.37.1", + "@playwright/test": "^1.44.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/kit": "^2.3.4", "@sveltejs/vite-plugin-svelte": "^3.0.1", diff --git a/playwright.config.ts b/playwright.config.ts index 58a0a3191..d330159b0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,15 @@ import { type PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { + timeout: 120000, + reportSlowTests: null, + reporter: [['html', { open: 'never' }]], webServer: { + timeout: 120000, + env: { + VITE_APPWRITE_ENDPOINT: 'http://console-tests.appwrite.org/v1', + VITE_CONSOLE_MODE: 'cloud' + }, command: 'npm run build && npm run preview', port: 4173 } diff --git a/tests/e2e/helpers/url.ts b/tests/e2e/helpers/url.ts new file mode 100644 index 000000000..f4d0e2b35 --- /dev/null +++ b/tests/e2e/helpers/url.ts @@ -0,0 +1,21 @@ +export function getOrganizationIdFromUrl(pathname: string) { + const regex = /\/console\/organization-([^/]+)(\/.*)?/; + const match = pathname.match(regex); + + if (match) { + return match[1]; + } + + throw new Error('Organization ID not found in pathname'); +} + +export function getProjectIdFromUrl(pathname: string) { + const regex = /\/console\/project-([^/]+)(\/.*)?/; + const match = pathname.match(regex); + + if (match) { + return match[1]; + } + + throw new Error('Project ID not found in pathname'); +} diff --git a/tests/e2e/journeys/onboarding-free.spec.ts b/tests/e2e/journeys/onboarding-free.spec.ts new file mode 100644 index 000000000..622088d97 --- /dev/null +++ b/tests/e2e/journeys/onboarding-free.spec.ts @@ -0,0 +1,8 @@ +import { test } from '@playwright/test'; +import { registerUserStep } from '../steps/account'; +import { createFreeProject } from '../steps/free-project'; + +test('onboarding - free tier', async ({ page }) => { + await registerUserStep(page); + await createFreeProject(page); +}); diff --git a/tests/e2e/journeys/onboarding-pro.spec.ts b/tests/e2e/journeys/onboarding-pro.spec.ts new file mode 100644 index 000000000..53c22ef05 --- /dev/null +++ b/tests/e2e/journeys/onboarding-pro.spec.ts @@ -0,0 +1,8 @@ +import { test } from '@playwright/test'; +import { registerUserStep } from '../steps/account'; +import { createProProject } from '../steps/pro-project'; + +test('onboarding - pro', async ({ page }) => { + await registerUserStep(page); + await createProProject(page); +}); diff --git a/tests/e2e/journeys/upgrade-free-tier.spec.ts b/tests/e2e/journeys/upgrade-free-tier.spec.ts new file mode 100644 index 000000000..5c60a3872 --- /dev/null +++ b/tests/e2e/journeys/upgrade-free-tier.spec.ts @@ -0,0 +1,20 @@ +import { test } from '@playwright/test'; +import { registerUserStep } from '../steps/account'; +import { createFreeProject } from '../steps/free-project'; +import { enterAddress, enterCreditCard } from '../steps/pro-project'; + +test('upgrade - free tier', async ({ page }) => { + await registerUserStep(page); + await createFreeProject(page); + await test.step('upgrade project', async () => { + await page.getByRole('button', { name: 'upgrade' }).click(); + await page.locator('input[value="tier-1"]').click(); + await page.getByRole('button', { name: 'next' }).click(); + await enterCreditCard(page); + await enterAddress(page); + // skip members + await page.getByRole('button', { name: 'next' }).click(); + await page.getByRole('button', { name: 'create' }).click(); + await page.waitForURL('/console/organization-**'); + }); +}); diff --git a/tests/e2e/login.spec.ts b/tests/e2e/login.spec.ts deleted file mode 100644 index be178d202..000000000 --- a/tests/e2e/login.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from '@playwright/test'; - -/*TODO: Things to test in login: - - presence of forgot password link - - validation message on wrong input - - correct response and redirect after login - - logout works - - back button does not log out user - - forward button does not log in user after logout - - limit to total number of login attempts - - -*/ - -test('login page has inputs and button', async ({ page }) => { - await page.goto('/login'); - const mail = page.locator('id=email'); - const pass = page.locator('id=password'); - const button = page.locator('button:has-text("Sign in")'); - expect(await mail.isVisible()); - expect(await pass.isVisible()); - expect(await button.isVisible()); -}); - -test('login page has a working sign up link', async ({ page }) => { - await page.goto('/login'); - await page.waitForTimeout(100); - const signup = page.locator('a[href="/register"]'); - expect(await signup.isVisible()); -}); - -test('login page inputs are navigable by keyboard', async ({ page }) => { - await page.goto('/login'); - const mail = page.locator('id=email'); - await mail.focus(); - await page.keyboard.type('wrongemail@apppwrite.io'); - await page.keyboard.press('Tab'); - await page.keyboard.type('password'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Enter'); - expect(await page.locator('.toaster-item').isVisible()); - expect(await page.locator('text=Invalid credentials').isVisible()); -}); - -test('login page shows error & response is 401 with wrong inputs', async ({ page }) => { - await page.goto('/login'); - await page.fill('id=email', 'wrongemail@apppwrite.io'); - await page.fill('id=password', 'wrongpassword'); - await page.click('button:has-text("Sign in")'); - page.on('response', (response) => { - expect(response.status()).toBe(401); - }); - expect(await page.locator('.toaster-item').isVisible()); - expect(await page.locator('text=Invalid credentials').isVisible()); -}); diff --git a/tests/e2e/register.spec.ts b/tests/e2e/register.spec.ts deleted file mode 100644 index 8976c3a8e..000000000 --- a/tests/e2e/register.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('register page has inputs', async ({ page }) => { - await page.goto('/register'); - const name = page.locator('id=name'); - const mail = page.locator('id=email'); - const pass = page.locator('id=password'); - expect(await name.isVisible()); - expect(await mail.isVisible()); - expect(await pass.isVisible()); -}); diff --git a/tests/e2e/steps/account.ts b/tests/e2e/steps/account.ts new file mode 100644 index 000000000..3c855a411 --- /dev/null +++ b/tests/e2e/steps/account.ts @@ -0,0 +1,34 @@ +import { test, type Page } from '@playwright/test'; + +type Metadata = { + name: string; + email: string; + password: string; +}; + +export function registerUserStep(page: Page): Promise { + return test.step('register user', async () => { + const seed = crypto.randomUUID(); + await page.goto('/register'); + await page.getByRole('button', { name: 'only required' }).click(); + const inputs = { + name: page.locator('id=name'), + email: page.locator('id=email'), + password: page.locator('id=password'), + terms: page.locator('id=terms') + }; + const values = { + name: 'testuser ' + seed, + email: 'testuser+' + seed + '@apppwrite.io', + password: 'testuser+' + seed + '@apppwrite.io' + }; + await inputs.name.fill(values.name); + await inputs.email.fill(values.email); + await inputs.password.fill(values.password); + await inputs.terms.check(); + await page.getByRole('button', { name: 'Sign up', exact: true }).click(); + await page.waitForURL('/console/onboarding'); + + return values; + }); +} diff --git a/tests/e2e/steps/free-project.ts b/tests/e2e/steps/free-project.ts new file mode 100644 index 000000000..901bda47d --- /dev/null +++ b/tests/e2e/steps/free-project.ts @@ -0,0 +1,37 @@ +import { test, expect, type Page } from '@playwright/test'; +import { getOrganizationIdFromUrl, getProjectIdFromUrl } from '../helpers/url'; + +type Metadata = { + id: string; + organizationId: string; +}; + +export async function createFreeProject(page: Page): Promise { + const organizationId = await test.step('create organization', async () => { + await page.goto('/console'); + await page.waitForURL('/console/onboarding'); + await page.locator('id=name').fill('test org'); + await page.locator('id=plan').selectOption('tier-0'); + await page.getByRole('button', { name: 'get started' }).click(); + await page.waitForURL('/console/organization-**'); + return getOrganizationIdFromUrl(page.url()); + }); + + const projectId = await test.step('create project', async () => { + await page.waitForURL('/console/organization-**'); + await page.getByRole('button', { name: 'create project' }).first().click(); + await page.locator('id=name').fill('test project'); + await page.getByRole('button', { name: 'next' }).click(); + await page.locator('label').filter({ hasText: 'Frankfurt' }).click(); + await page.getByRole('button', { name: 'create' }).click(); + await page.waitForURL('/console/project-**/overview/platforms'); + expect(page.url()).toContain('/console/project-'); + + return getProjectIdFromUrl(page.url()); + }); + + return { + id: projectId, + organizationId + }; +} diff --git a/tests/e2e/steps/pro-project.ts b/tests/e2e/steps/pro-project.ts new file mode 100644 index 000000000..571e85ca0 --- /dev/null +++ b/tests/e2e/steps/pro-project.ts @@ -0,0 +1,62 @@ +import { test, expect, type Page } from '@playwright/test'; +import { getOrganizationIdFromUrl, getProjectIdFromUrl } from '../helpers/url'; + +type Metadata = { + id: string; + organizationId: string; +}; + +export async function enterCreditCard(page: Page) { + await page.getByPlaceholder('cardholder').fill('Test User'); + const stripe = page.frameLocator('[title="Secure payment input frame"]'); + await stripe.locator('id=Field-numberInput').fill('4242424242424242'); + await stripe.locator('id=Field-expiryInput').fill('1250'); + await stripe.locator('id=Field-cvcInput').fill('123'); + await stripe.locator('id=Field-countryInput').selectOption('DE'); + await page.getByRole('button', { name: 'Next' }).click(); +} + +export async function enterAddress(page: Page) { + await page.locator('id=country').selectOption('US'); + await page.locator('id=address').fill('123 Test St'); + await page.locator('id=city').fill('Test City'); + await page.locator('id=state').fill('Test State'); + await page.getByRole('button', { name: 'Next' }).click(); +} + +export async function createProProject(page: Page): Promise { + const organizationId = await test.step('create organization', async () => { + await page.goto('/console'); + await page.waitForURL('/console/onboarding'); + await page.locator('id=name').fill('test org'); + await page.locator('id=plan').selectOption('tier-1'); + await page.getByRole('button', { name: 'get started' }).click(); + await enterCreditCard(page); + await enterAddress(page); + // skip members + await page.getByRole('button', { name: 'next' }).click(); + // start pro trial + await page.getByRole('button', { name: 'create' }).click(); + await page.waitForURL('/console/organization-**'); + + return getOrganizationIdFromUrl(page.url()); + }); + + const projectId = await test.step('create project', async () => { + await page.waitForURL('/console/organization-**'); + await page.getByRole('button', { name: 'create project' }).first().click(); + await page.getByPlaceholder('project name').fill('test project'); + await page.getByRole('button', { name: 'next' }).click(); + await page.locator('label').filter({ hasText: 'frankfurt' }).click(); + await page.getByRole('button', { name: 'create' }).click(); + await page.waitForURL('/console/project-**/overview/platforms'); + expect(page.url()).toContain('/console/project-'); + + return getProjectIdFromUrl(page.url()); + }); + + return { + id: projectId, + organizationId + }; +}