Skip to content
Draft
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
37 changes: 30 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,49 @@ jobs:
ci-job:
name: "CI Job"
runs-on: "ubuntu-latest"
strategy:
matrix:
index: [0, 1]
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: moonrepo/setup-toolchain@v0
with:
cache-base: main
- run: "moon ci --color --job ${{ matrix.index }} --jobTotal 2"
env:
PUBLIC_LANGGRAPH_API_URL: "http://127.0.0.1:2024"
- run: "moon ci --color"
- uses: moonrepo/run-report-action@v1
if: success() || failure()
with:
access-token: ${{ secrets.GITHUB_TOKEN }}
matrix: ${{ toJSON(matrix) }}
- uses: appthrust/moon-ci-retrospect@v1
if: success() || failure()

e2e-tests:
name: "E2E tests"
runs-on: "ubuntu-latest"
container:
image: mcr.microsoft.com/playwright:v1.54.1-noble
options: --user 1001
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: moonrepo/setup-toolchain@v0
with:
cache-base: main
- run: "moon :test-e2e --color"
- uses: moonrepo/run-report-action@v1
if: success() || failure()
with:
access-token: ${{ secrets.GITHUB_TOKEN }}
matrix: ${{ toJSON(matrix) }}
- uses: appthrust/moon-ci-retrospect@v1
if: success() || failure()
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: apps/frontend/playwright-report/
retention-days: 30

build:
name: "Docker Build"
Expand Down Expand Up @@ -76,7 +99,7 @@ jobs:
ci:
name: "CI"
runs-on: ubuntu-latest
needs: [ci-job, build]
needs: [ci-job, e2e-tests, build]
if: always()
steps:
- run: ${{!contains(needs.*.result, 'failure')}}
Expand Down
2 changes: 1 addition & 1 deletion .prototools
Original file line number Diff line number Diff line change
@@ -1 +1 @@
moon = "1.41.0"
moon = "1.41"
7 changes: 5 additions & 2 deletions apps/backend/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ tasks:
dev:
command: "langgraph dev --no-browser"
preset: "watcher"
deps:
- oidc-mock
build:
command: "langgraph build --tag svelte-langgraph-backend --pull"
deps:
Expand All @@ -39,3 +37,8 @@ tasks:
preset: "server"
deps:
- build
# Bogus task to ensure deps are installed
setup:
command: noop
options:
internal: true
1 change: 1 addition & 0 deletions apps/frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ src/lib/paraglide

# Sentry Config File
.env.sentry-build-plugin
playwright-report/
4 changes: 4 additions & 0 deletions apps/frontend/e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.auth/
screenshots/
traces/
videos/
148 changes: 148 additions & 0 deletions apps/frontend/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# E2E Tests with Page Object Models and Fixtures

This directory contains end-to-end tests using Playwright with Page Object Models (POMs) and custom fixtures for better maintainability and reusability.

## Structure

```
e2e/
├── pages/ # Page Object Models
│ ├── home.page.ts # Home page POM
│ ├── chat.page.ts # Chat page POM
│ ├── oidc.page.ts # OIDC authentication page POM
│ └── index.ts # Barrel export for all POMs
├── fixtures/ # Custom test fixtures
│ ├── pages.fixture.ts # Page object fixtures
│ ├── auth.fixture.ts # Authentication fixtures & helpers
│ ├── backend.fixture.ts # Backend integration fixtures
│ └── index.ts # Main fixture export
└── *.spec.ts # Test files
```

## Page Object Models (POMs)

Each page has its own POM class that encapsulates:

- Element locators as readonly properties
- Action methods for user interactions
- Helper methods for common assertions

### Example POM Usage

```typescript
export class HomePage {
readonly signInButton: Locator;

constructor(page: Page) {
this.page = page;
this.signInButton = page.getByRole('button', { name: /sign in/i });
}

async goto() {
await this.page.goto('/');
}

async clickSignIn() {
await this.signInButton.click();
}
}
```

## Fixtures

Fixtures provide isolated test environments and reusable setup/teardown logic. Our fixture hierarchy:

1. **Page Fixtures** (`pages.fixture.ts`)
- Provides POMs as fixtures (`homePage`, `chatPage`, `oidcPage`)

2. **Auth Fixtures** (`auth.fixture.ts`)
- `authHelpers`: Authentication helper methods
- `authenticatedPage`: Auto-authenticate before tests (when needed)

3. **Backend Fixtures** (`backend.fixture.ts`)
- `backendHelpers`: Backend API interaction helpers

## Writing Tests

Import the extended test and fixtures from the main export:

```typescript
import { test, expect } from './fixtures';

test('example test', async ({ homePage, authHelpers }) => {
await homePage.goto();
await authHelpers.authenticateUser();
await expect(homePage.avatarMenuButton).toBeVisible();
});
```

### Using Page Object Fixtures

```typescript
test('navigate to chat', async ({ chatPage }) => {
await chatPage.goto();
await expect(chatPage.chatTitle).toBeVisible();
});
```

### Using Authentication Helpers

```typescript
test('authenticated flow', async ({ authHelpers, chatPage }) => {
// Manually authenticate
await authHelpers.authenticateUser();

// Navigate to protected page
await chatPage.goto();

// Verify authenticated state
await authHelpers.expectAuthenticated();
});
```

### Auto-Authentication with beforeEach

```typescript
test.describe('When authenticated', () => {
test.beforeEach(async ({ authHelpers }) => {
await authHelpers.authenticateUser();
});

test('access protected content', async ({ chatPage }) => {
await chatPage.goto();
await expect(chatPage.loginModal).not.toBeVisible();
});
});
```

## Running Tests

```bash
# Run all E2E tests
moon frontend:test-e2e

# Run with Playwright UI
npx playwright test --ui

# Run specific test file
npx playwright test e2e/auth.spec.ts

# Debug mode
npx playwright test --debug
```

## Best Practices

1. **Use POMs for element access**: Access elements through POM properties rather than direct selectors
2. **Use fixtures for setup**: Leverage fixtures for common setup/teardown logic
3. **Keep POMs focused**: Each POM should represent a single page or component
4. **Compose fixtures**: Build complex fixtures from simpler ones
5. **Use helper methods**: Create reusable assertion helpers in fixtures

## Benefits of This Structure

- **Maintainability**: Changes to UI only require updates in POMs
- **Reusability**: Common actions and assertions are centralized
- **Type Safety**: TypeScript provides IntelliSense and compile-time checks
- **Isolation**: Each test runs in isolation with fresh fixtures
- **Readability**: Tests read like natural language with meaningful method names
137 changes: 137 additions & 0 deletions apps/frontend/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { test, expect, OIDC_CONFIG } from './fixtures';

test.describe('OIDC Provider', () => {
test('should handle OIDC well-known configuration', async ({ oidcPage }) => {
const config = await oidcPage.verifyWellKnownConfig();
expect(config.issuer).toBe(OIDC_CONFIG.issuer);
});
});

test.describe('When unauthenticated', () => {
test.describe('On home page', () => {
test.beforeEach(async ({ homePage }) => {
await homePage.goto();
});

test('should display sign-in button', async ({ authHelpers }) => {
await authHelpers.expectUnauthenticated();
});

test('should successfully sign in with OIDC provider', async ({ authHelpers, homePage }) => {
// Perform authentication
await authHelpers.authenticateUser();

// Verify successful authentication
await authHelpers.expectAuthenticated();

// Verify user info is displayed
await expect(homePage.avatarMenuButton).toContainText(/test-user/i);
});
});

test.describe('On chat page', () => {
test.beforeEach(async ({ chatPage }) => {
await chatPage.goto();
});

test('should show login modal', async ({ chatPage }) => {
await expect(chatPage.loginModal).toBeVisible();
});

test('should have a sign-in button in the modal', async ({ chatPage }) => {
await expect(chatPage.loginModalSignInButton).toBeVisible();
});

test('should not show greeting', async ({ chatPage }) => {
await expect(chatPage.greeting).not.toBeVisible();
});

test('should have navigation and body visible', async ({ page }) => {
// The app should not crash - check that basic UI is present
await expect(page.locator('body')).toBeVisible();

// Navigation should still work
await expect(page.getByRole('navigation')).toBeVisible();
});
});
});

test.describe('When authenticated', () => {
test.beforeEach(async ({ authHelpers }) => {
await authHelpers.authenticateUser();
});

test.describe('Session persistence', () => {
[{ location: '/' }, { location: '/chat' }].forEach(({ location }) => {
test(`should persist across navigation to ${location}`, async ({ page, authHelpers }) => {
await page.goto(location);
await authHelpers.expectAuthenticated();
});
});

test('should persist session on page reload', async ({ page, authHelpers }) => {
// Reload the page
await page.reload();

// Should still be authenticated
await authHelpers.expectAuthenticated();
});

test('should maintain session across browser context', async ({ context }) => {
// Open a new page in the same context
const newPage = await context.newPage();
await newPage.goto('/');

// Create a HomePage instance for the new page to check auth status
const { HomePage } = await import('./pages');
const newHomePage = new HomePage(newPage);

// Should be authenticated in the new page
await expect(newHomePage.avatarMenuButton).toBeVisible();
await expect(newHomePage.signInButton).not.toBeVisible();

await newPage.close();
});
});

test.describe('On chat page', () => {
test.beforeEach(async ({ chatPage }) => {
await chatPage.goto();
});

test('should not show login modal', async ({ chatPage }) => {
await expect(chatPage.loginModal).not.toBeVisible();
});

test('should show chat interface', async ({ chatPage }) => {
await chatPage.waitForChatInterface();
await expect(chatPage.chatTitle).toBeVisible();
});

test('should show greeting', async ({ chatPage }) => {
await expect(chatPage.greeting).toBeVisible();
});

test.describe('Signing out from chat', () => {
test('should navigate to home after sign out', async ({ authHelpers, page }) => {
await authHelpers.signOut();
await expect(page).toHaveURL('/');
});
});
});

test.describe('Signing out', () => {
test.beforeEach(async ({ authHelpers }) => {
await authHelpers.signOut();
});

test('should show unauthenticated state', async ({ authHelpers }) => {
await authHelpers.expectUnauthenticated();
});

test('should show login modal on chat page after sign out', async ({ chatPage }) => {
await chatPage.goto();
await expect(chatPage.loginModal).toBeVisible();
});
});
});
Loading
Loading