Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .github/workflows/ui-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: UI Tests

on:
pull_request:
paths:
- "app/**"
- "compose.test.yaml"
- "dev/dev-db.sql"
- "package.json"
- "package-lock.json"
- ".github/workflows/ui-tests.yml"

jobs:
ui-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: |
package-lock.json
app/package-lock.json
- run: docker compose -f compose.test.yaml up -d --wait
- run: npm ci
- run: npm ci --prefix app
- run: npx playwright install --with-deps chromium
- run: npm exec --prefix app -- prisma generate --schema app/prisma/schema.prisma
- run: npm run build --prefix app
- run: npm test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ node_modules
*/build
*/.svelte-kit
*/package
*/playwright-report
*/test-results
.env
.env.*
!.env.example
Expand Down
25 changes: 21 additions & 4 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"vite": "^5.0.0",
"vite-node": "^1.1.3",
"vitest": "^2.1.9",
"yaml": "^2.8.4",
"zod": "^3.22.4"
},
"dependencies": {
Expand Down
32 changes: 32 additions & 0 deletions app/playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const appDirectory = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
testDir: path.join(appDirectory, 'tests'),
fullyParallel: true,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry'
},
webServer: {
command: process.env.CI
? 'npm run preview -- --host 127.0.0.1 --port 4173'
: 'npm run dev -- --host 127.0.0.1 --port 4173',
cwd: appDirectory,
env: {
DATABASE_URL: 'postgresql://postgres:localdev@127.0.0.1:5432/postgres'
},
reuseExistingServer: !process.env.CI,
port: 4173
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
]
});
6 changes: 5 additions & 1 deletion app/src/lib/components/classCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
</script>

{#if event}
<div class="card mx-2 mb-4 rounded-none bg-base-100 shadow-xl lg:card-side lg:max-h-72">
<div
class="card mx-2 mb-4 rounded-none bg-base-100 shadow-xl lg:card-side lg:max-h-72"
data-testid="class-card"
data-category={event.category}
>
<figure class="{filters.compact ? ' lg:w-1/3' : 'w-full lg:w-4/5 lg:flex'}">
<enhanced:img
class="object-cover h-full {filters.compact ? 'hidden lg:block' : 'lg:w-auto lg-flex-shrink-0 lg:object-center'}"
Expand Down
193 changes: 193 additions & 0 deletions app/tests/class-list.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { expect, test } from '@playwright/test';

// Requires the test database to be running locally:
// npm run test:db:up
// Then:
// npm run test:ui

const HOME = '/';

test.describe.configure({ mode: 'serial' });

async function visibleCardCount(page) {
return page.locator('[data-testid="class-card"]').count();
}

async function cardHeadings(page) {
return page.locator('[data-testid="class-card"] h2').allTextContents();
}

async function visibleCategories(page) {
return page
.locator('[data-testid="class-card"]')
.evaluateAll((cards) => cards.map((c) => c.dataset.category));
}

test.describe('class list page', () => {
test('renders correctly', async ({ page }) => {
const failedImageResponses = [];
page.on('response', (response) => {
if (response.request().resourceType() === 'image' && !response.ok()) {
failedImageResponses.push(`${response.status()} ${response.url()}`);
}
});

await page.goto(HOME);

await expect(page).toHaveTitle('Asmbly | Classes');

const cards = page.locator('[data-testid="class-card"]');
await expect(cards.first()).toBeVisible();
const count = await cards.count();
expect(count).toBeGreaterThan(0);
await expect(page.getByText('Sorry, no results found')).toBeHidden();

for (let i = 0; i < count; i++) {
const card = cards.nth(i);
await expect(card.locator('img[alt$=" image"]')).toBeVisible();
await expect(card.getByRole('heading', { level: 2 })).toBeVisible();
await expect(card.getByRole('link', { name: /^learn more about/i })).toHaveAttribute(
'href',
/^\/event\/\d+\?eventId=\d+$/
);
await expect(card.getByText(/^Price:/)).toBeVisible();
}

const brokenImages = await page
.locator('[data-testid="class-card"] img[alt$=" image"]')
.evaluateAll(async (images) => {
const failures = [];
for (const image of images) {
try {
await image.decode();
} catch {
failures.push(`${image.alt}: decode failed`);
continue;
}
if (!image.complete || image.naturalWidth === 0 || image.naturalHeight === 0) {
failures.push(`${image.alt}: loaded with zero dimensions`);
}
}
return failures;
});

expect(failedImageResponses).toEqual([]);
expect(brokenImages).toEqual([]);
});

test('search works', async ({ page }) => {
await page.goto(HOME);
const baseline = await visibleCardCount(page);
expect(baseline).toBeGreaterThan(0);

const firstWord = (await cardHeadings(page))[0].match(/[A-Za-z0-9]+/)?.[0];
expect(firstWord, 'first card heading should contain a word').toBeTruthy();

const searchInput = page.getByPlaceholder('Search by class name...');

await searchInput.fill(firstWord);
await expect
.poll(async () => {
const current = await cardHeadings(page);
return (
current.length > 0 &&
current.some((h) => h.toLowerCase().includes(firstWord.toLowerCase()))
);
})
.toBe(true);

await searchInput.fill('zzzqxnonsense');
await expect(page.getByText('Sorry, no results found')).toBeVisible();

const clearButton = page.getByRole('button', { name: 'Clear Search' });
await expect(clearButton).toBeVisible();
await clearButton.click();
await expect.poll(async () => visibleCardCount(page)).toBe(baseline);
});

test('sort works', async ({ page }) => {
await page.goto(HOME);

await page.locator('select').selectOption('Name');

await expect
.poll(async () => {
const current = await cardHeadings(page);
const sorted = [...current].sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
return current.length > 1 && JSON.stringify(current) === JSON.stringify(sorted);
})
.toBe(true);

const asc = await cardHeadings(page);
const expectedDesc = [...asc].reverse();

await page.locator('label').filter({ hasText: 'Sort Order' }).click();

await expect
.poll(async () => {
const current = await cardHeadings(page);
return JSON.stringify(current) === JSON.stringify(expectedDesc);
})
.toBe(true);
});

test('filters work', async ({ page }) => {
await page.goto(HOME);
const baseline = await visibleCardCount(page);
expect(baseline).toBeGreaterThan(0);

const targetCategory = [...new Set(await visibleCategories(page))].find(Boolean);
expect(targetCategory, 'expected at least one category among rendered cards').toBeTruthy();
const categoryCheckbox = page.locator(`input[name="${targetCategory}"]`);

await categoryCheckbox.check();
await page.waitForURL(/archCategories=/);
const filteredCategories = await visibleCategories(page);
expect(filteredCategories.length).toBeGreaterThan(0);
expect(filteredCategories.every((c) => c === targetCategory)).toBe(true);

await categoryCheckbox.uncheck();
await expect.poll(async () => visibleCardCount(page)).toBe(baseline);

await page.getByLabel('Group By Class Type').uncheck();
await expect.poll(async () => visibleCardCount(page)).toBeGreaterThanOrEqual(baseline);
await page.getByLabel('Group By Class Type').check();
await expect.poll(async () => visibleCardCount(page)).toBe(baseline);

await page.getByLabel('Show Unscheduled Classes').uncheck();
await expect.poll(async () => visibleCardCount(page)).toBeLessThanOrEqual(baseline);
});

test('navigation works', async ({ page }) => {
await page.goto(HOME);

await expect(page.getByRole('link', { name: 'Mentor Series' }).first()).toHaveAttribute(
'href',
'https://asmbly.org/classes-events/mentor-series/'
);
await expect(page.getByRole('link', { name: 'Classes FAQ' }).first()).toHaveAttribute(
'href',
'https://asmbly.org/faq/#classfaq'
);

const targetCategory = [...new Set(await visibleCategories(page))].find(Boolean);
expect(targetCategory).toBeTruthy();
await page.goto(`/?sortBy=Name&archCategories=${encodeURIComponent(targetCategory)}`);

await expect(page.locator('select')).toHaveValue('Name');
await expect(page.locator(`input[name="${targetCategory}"]`)).toBeChecked();
const restored = await visibleCategories(page);
expect(restored.length).toBeGreaterThan(0);
expect(restored.every((c) => c === targetCategory)).toBe(true);

await page.goto(HOME);
await page
.locator('[data-testid="class-card"]')
.first()
.getByRole('link', { name: /^learn more about/i })
.click();
await expect(page).toHaveURL(/^.*\/event\/\d+\?eventId=\d+$/);
});
});
Loading