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
126 changes: 119 additions & 7 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,130 @@
name: End-to-end tests

on:
schedule:
- cron: '0 */2 * * *'
pull_request:
branches:
- main
workflow_dispatch:

jobs:
e2e-tests:
runs-on: ubuntu-latest
env:
PGHOST: localhost
PGPORT: 5432
PGUSER: gitjobs
PGPASSWORD: password
PGDATABASE: gitjobs

services:
postgres:
image: postgis/postgis:17-3.5
env:
POSTGRES_USER: gitjobs
POSTGRES_PASSWORD: password
POSTGRES_DB: gitjobs
ports:
- 5432:5432

steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- name: Install Playwright
run: npm i -D @playwright/test
- name: Install Playwright browsers
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Rust environment
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.87.0
components: clippy, rustfmt

- name: Cache Cargo dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('''**/Cargo.lock''') }}

- name: Set up Node.js environment
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Initialize npm and install dependencies
run: |
npm init -y
npm install -D @playwright/test wait-on

- name: Install Playwright Browsers
run: npx playwright install --with-deps

- name: Install Tailwind CSS
run: |
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
sudo mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss

- name: Install tern
run: |
curl -sL https://github.com/jackc/tern/releases/download/v2.3.2/tern_2.3.2_linux_amd64.tar.gz -o tern.tar.gz
tar -xzf tern.tar.gz
sudo mv tern /usr/local/bin/

- name: Build server
run: cargo build --bin gitjobs-server

- name: Run database migrations
working-directory: database/migrations
env:
TERN_CONF: ${{ github.workspace }}/database/migrations/tern.conf
run: |
touch tern.conf
bash ./migrate.sh

- name: Insert test data
run: |
psql -f database/tests/data/e2e.sql

- name: Create Server Config File
run: |
cat <<EOF > config.testing.yml
db:
url: postgres://gitjobs:[email protected]:5432/gitjobs
email:
from_address: "[email protected]"
from_name: "Test"
smtp:
host: "127.0.0.1"
port: 2525
username: "test"
password: "password"
log:
format: "pretty"
server:
addr: "0.0.0.0:8080"
base_url: "http://127.0.0.1:8080"
cookie:
secure: false
login:
email: true
github: false
linuxfoundation: false
oauth2: {}
oidc: {}
EOF

- name: Start server and wait for it to be ready
uses: JarvusInnovations/background-action@v1
with:
run: ./target/debug/gitjobs-server --config-file config.testing.yml &
wait-on: http://localhost:8080
tail: true
log-output: "stdout,stderr"
log-output-if: true

- name: Run Playwright tests
run: npx playwright test --config tests/e2e/playwright.config.ts
env:
BASE_URL: http://127.0.0.1:8080
6 changes: 3 additions & 3 deletions database/migrations/schema/0001_initial.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
create extension pgcrypto;
create extension postgis;
create extension pg_trgm;
create extension if not exists pgcrypto;
create extension if not exists postgis;
create extension if not exists pg_trgm;

create or replace function i_array_to_string(text[], text)
returns text language sql immutable as $$select array_to_string($1, $2)$$;
Expand Down
31 changes: 31 additions & 0 deletions database/tests/data/e2e.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
INSERT INTO "user" (user_id, auth_hash, created_at, email, email_verified, name, username, password, moderator)
VALUES ('f39a95c8-9903-4537-8873-2d81bfb86b35', gen_random_bytes(32), '2025-08-25 08:43:11.605766+02', '[email protected]', true, 'test', 'test', '$argon2id$v=19$m=19456,t=2,p=1$vUCLsb/lDAepJiWB7VSFNw$yAYeJVIKW0gK3cOJAnpiV9H5uPZDATJh13fDWGivjZM', true);

INSERT INTO employer (employer_id, company, created_at, description, public)
VALUES ('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Test Inc.', '2025-08-25 09:20:05.88454+02', 'test', false);

INSERT INTO employer_team (user_id, employer_id, approved)
VALUES ('f39a95c8-9903-4537-8873-2d81bfb86b35', '18fff2d7-c794-4130-85e4-76b9d7c60b72', true);

INSERT INTO job (employer_id, title, description, kind, seniority, workplace, status, salary, salary_max_usd_year, salary_currency, salary_period, skills, published_at) VALUES
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Frontend Developer', 'React expert', 'full-time', 'senior', 'remote', 'published', 120000, 120000, 'USD', 'year', '{"React", "TypeScript", "JavaScript"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Backend Developer', 'Node.js expert', 'full-time', 'senior', 'hybrid', 'published', 130000, 130000, 'USD', 'year', '{"Node.js", "PostgreSQL", "REST"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'DevOps Engineer', 'Kubernetes expert', 'full-time', 'lead', 'on-site', 'published', 150000, 150000, 'USD', 'year', '{"Kubernetes", "Docker", "AWS"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Data Scientist', 'Python expert', 'part-time', 'mid', 'remote', 'published', 80000, 80000, 'USD', 'year', '{"Python", "Pandas", "scikit-learn"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'UI/UX Designer', 'Figma expert', 'contractor', 'junior', 'remote', 'published', 60000, 60000, 'USD', 'year', '{"Figma", "UI", "UX"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Software Engineer in Test', 'Playwright expert', 'full-time', 'mid', 'hybrid', 'published', 110000, 110000, 'USD', 'year', '{"Playwright", "TypeScript", "CI/CD"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Product Manager', 'Agile expert', 'full-time', 'senior', 'on-site', 'published', 140000, 140000, 'USD', 'year', '{"Agile", "Scrum", "Jira"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Mobile Developer', 'React Native expert', 'internship', 'entry', 'remote', 'published', 40000, 40000, 'USD', 'year', '{"React Native", "iOS", "Android"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Full-stack Developer', 'Ruby on Rails expert', 'full-time', 'mid', 'hybrid', 'published', 115000, 115000, 'USD', 'year', '{"Ruby on Rails", "PostgreSQL", "React"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Security Engineer', 'Cybersecurity expert', 'full-time', 'lead', 'on-site', 'published', 160000, 160000, 'USD', 'year', '{"Cybersecurity", "Penetration Testing", "CISSP"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 11', 'Description for Job 11', 'full-time', 'junior', 'remote', 'published', 50000, 50000, 'USD', 'year', '{"React", "JavaScript"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 12', 'Description for Job 12', 'part-time', 'mid', 'on-site', 'published', 60000, 60000, 'USD', 'year', '{"Node.js", "REST"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 13', 'Description for Job 13', 'contractor', 'senior', 'hybrid', 'published', 70000, 70000, 'USD', 'year', '{"Kubernetes", "Docker"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 14', 'Description for Job 14', 'internship', 'entry', 'remote', 'published', 30000, 30000, 'USD', 'year', '{"Python", "scikit-learn"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 15', 'Description for Job 15', 'full-time', 'lead', 'on-site', 'published', 90000, 90000, 'USD', 'year', '{"Figma", "UX"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 16', 'Description for Job 16', 'part-time', 'junior', 'hybrid', 'published', 45000, 45000, 'USD', 'year', '{"Playwright", "CI/CD"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 17', 'Description for Job 17', 'full-time', 'mid', 'remote', 'published', 65000, 65000, 'USD', 'year', '{"Agile", "Jira"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 18', 'Description for Job 18', 'contractor', 'senior', 'on-site', 'published', 75000, 75000, 'USD', 'year', '{"React Native", "Android"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 19', 'Description for Job 19', 'internship', 'entry', 'hybrid', 'published', 35000, 35000, 'USD', 'year', '{"Ruby on Rails", "React"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 20', 'Description for Job 20', 'full-time', 'lead', 'remote', 'published', 95000, 95000, 'USD', 'year', '{"Cybersecurity", "CISSP"}', CURRENT_TIMESTAMP),
('18fff2d7-c794-4130-85e4-76b9d7c60b72', 'Job 21', 'Description for Job 21', 'full-time', 'senior', 'on-site', 'published', 100000, 100000, 'USD', 'year', '{"TypeScript", "PostgreSQL"}', CURRENT_TIMESTAMP);
2 changes: 1 addition & 1 deletion tests/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default defineConfig({
testDir: '.',
failOnFlakyTests: true,
use: {
baseURL: process.env.CI ? 'https://gitjobs.dev' : 'http://localhost:9000',
baseURL: process.env.BASE_URL || 'http://localhost:9000',
},
reporter: 'list',
projects: [
Expand Down
127 changes: 81 additions & 46 deletions tests/e2e/playwright.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ test.describe('GitJobs', () => {
console.log(`Failed to navigate to page, retrying... (${i + 1}/3)`);
}
}
// Handle cookie consent
try {
await page.getByRole('button', { name: 'Accept all' }).click({ timeout: 5000 });
} catch (error) {
// Ignore if the cookie consent is not visible
}
});

test('should have the correct title and heading', async ({ page }) => {
Expand All @@ -24,20 +18,13 @@ test.describe('GitJobs', () => {
});

test('should apply a filter and verify that the results are updated', async ({ page }) => {
const jobCount = await page.getByRole('button', { name: /Job type/ }).count();
if (jobCount === 0) {
console.log('No jobs found, skipping test.');
return;
}
const initialJobCount = await page.getByRole('button', { name: /Job type/ }).count();
await page.locator('div:nth-child(4) > div > .font-semibold').first().click();
await page.locator('label').filter({ hasText: 'Full Time' }).nth(1).click();
await page.waitForFunction(
(initialCount) => {
const currentCount = document.querySelectorAll('[role="button"][name*="Job type"]').length;
return currentCount < initialCount;
},
initialJobCount
() => {
const currentCount = document.querySelectorAll('[data-preview-job="true"]').length;
return currentCount === 12;
}
);

const jobCards = await page.getByRole('button', { name: /Job type/ }).all();
Expand All @@ -48,23 +35,35 @@ test.describe('GitJobs', () => {
}
}
});

test('should reset filters', async ({ page }) => {
const jobCount = await page.getByRole('button', { name: /Job type/ }).count();
if (jobCount === 0) {
console.log('No jobs found, skipping test.');
return;
}
const initialFirstJob = await page.getByRole('button', { name: /Job type/ }).first().textContent();
await page.locator('label').filter({ hasText: 'Full Time' }).nth(1).click();
await page.locator('label').filter({ hasText: 'Part Time' }).nth(1).click();

await page.waitForFunction(
() => {
const currentCount = document.querySelectorAll('[data-preview-job="true"]').length;
return currentCount === 3;
}
);
const firstJobAfterFilter = await page.locator('.text-base.font-stretch-condensed.font-medium.text-stone-900.line-clamp-2.md\\:line-clamp-1').first().textContent();
expect(firstJobAfterFilter!.trim()).toBe('Data Scientist');
await page.locator('#reset-desktop-filters').click();
const newFirstJob = await page.getByRole('button', { name: /Job type/ }).first().textContent();
expect(newFirstJob).toEqual(initialFirstJob);
await expect(page.locator('#results')).toHaveText('1 - 20 of 21 results');
const firstJobAfterReset = await page.locator('.text-base.font-stretch-condensed.font-medium.text-stone-900.line-clamp-2.md\\:line-clamp-1').first().textContent();
expect(firstJobAfterReset!.trim()).toBe('Frontend Developer');
});

test('should sort jobs', async ({ page }) => {
await page.locator('#sort-desktop').selectOption('open-source');
await expect(page).toHaveURL(/\?sort=open-source/);
const initialJobTitles = (await page.locator('.text-base.font-stretch-condensed.font-medium.text-stone-900.line-clamp-2.md\\:line-clamp-1').allTextContents()).map(title => title.trim());
await page.locator('#sort-desktop').selectOption('salary');
await expect(page).toHaveURL(/\?sort=salary/);
await page.waitForTimeout(500);
const sortedJobTitles = (await page.locator('.text-base.font-stretch-condensed.font-medium.text-stone-900.line-clamp-2.md\\:line-clamp-1').allTextContents()).map(title => title.trim());
expect(sortedJobTitles[0]).toBe('Security Engineer');
expect(sortedJobTitles[1]).toBe('DevOps Engineer');
expect(sortedJobTitles[2]).toBe('Product Manager');
expect(sortedJobTitles[3]).toBe('Backend Developer');
expect(sortedJobTitles[4]).toBe('Frontend Developer');
expect(sortedJobTitles).not.toEqual(initialJobTitles);
});

test('should navigate to the stats page and interact with charts', async ({ page, browserName }) => {
Expand Down Expand Up @@ -99,34 +98,70 @@ test.describe('GitJobs', () => {
await expect(page).toHaveURL(/\/sign-up/);
});

test('should log in a user', async ({ page }) => {
await page.locator('#user-dropdown-button').click();
await page.getByRole('link', { name: 'Log in' }).click();
await page.waitForURL('**/log-in');
await page.locator('#username').fill('test');
await page.locator('#password').fill('test');
await page.getByRole('button', { name: 'Submit' }).click();
});

test('should add a new job', async ({ page }) => {
await page.locator('#user-dropdown-button').click();
await page.getByRole('link', { name: 'Log in' }).click();
await page.waitForURL('**/log-in');
await page.locator('#username').fill('test');
await page.locator('#password').fill('test');
await page.getByRole('button', { name: 'Submit' }).click();
await page.goto('/');

await page.getByRole('link', { name: 'Post a job' }).click();
await page.waitForURL('**/dashboard/employer');
await page.getByRole('button', { name: 'Add Job' }).click();
await page.getByRole('textbox', { name: 'Title *' }).click();
await page.getByRole('textbox', { name: 'Title *' }).fill('job');
await page.locator('#description pre').nth(1).click();
await page.locator('#description').getByRole('application').getByRole('textbox').fill('description');
await page.getByRole('button', { name: 'Publish' }).click();
expect(page.url()).toContain('/dashboard/employer');
});

test('should display job details correctly', async ({ page }) => {
const jobCount = await page.getByRole('button', { name: /Job type/ }).count();
if (jobCount === 0) {
console.log('No jobs found, skipping test.');
return;
}
await page.getByRole('button', { name: /Job type/ }).first().click();
const expectedTitle = 'Frontend Developer';
const expectedDescription = 'React expert';
const expectedKind = 'full time';
const expectedSeniority = 'senior';
const expectedWorkplace = 'remote';
const expectedSalaryAmount = '120K';
const expectedSalaryCurrency = 'USD';
const expectedSalaryPeriod = '/ year';

await page.waitForSelector('[data-preview-job="true"]');
await page.locator('[data-preview-job="true"]').first().click();
await expect(page.locator('#preview-modal .text-xl')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#preview-content').getByText(/Job description/)).toBeVisible();

await expect(page.locator('.text-xl.lg\\:leading-tight.font-stretch-condensed.font-medium.text-stone-900.lg\\:truncate.my-1\\.5.md\\:my-0')).toHaveText(expectedTitle);
await expect(page.locator('div.text-lg.font-semibold.text-stone-800:has-text("Job description") + div.text-sm\\/6.text-stone-600.markdown p')).toHaveText(expectedDescription);
await expect(page.locator('div:has-text("Job type") + div.flex.items-center.text-xs > div.truncate.capitalize')).toHaveText(expectedKind);
await expect(page.locator('div:has-text("Workplace") + div.flex.items-center.text-xs > div.truncate.capitalize')).toHaveText(expectedWorkplace);
await expect(page.locator('div:has-text("Seniority level") + div.flex.items-center.text-xs > div.truncate.capitalize')).toHaveText(expectedSeniority);
await expect(page.locator('#preview-content div:has-text("Salary") div.flex.items-baseline.font-medium.text-stone-900.text-sm > div.text-xs.text-stone-500.me-1')).toHaveText(expectedSalaryCurrency);
await expect(page.locator('#preview-content div:has-text("Salary") div.flex.items-baseline.font-medium.text-stone-900.text-sm')).toContainText(expectedSalaryAmount);
await expect(page.locator('#preview-content div:has-text("Salary") div.flex.items-baseline > div.text-stone-900.text-xs.ms-1')).toHaveText(expectedSalaryPeriod);
await expect(page.getByRole('button', { name: 'Apply' })).toBeEnabled();
await expect(page.locator('#preview-content').getByText(/Published/)).toBeVisible();
await expect(page.locator('#preview-content').getByText(/Job type/)).toBeVisible();
await expect(page.locator('#preview-content').getByText(/Workplace/)).toBeVisible();
await expect(page.locator('#preview-content').getByText(/Seniority level/)).toBeVisible();
await expect(page.getByText('Share this job')).toBeVisible();
});

test('should allow paginating through jobs', async ({ page }) => {
const paginationVisible = await page.locator('[aria-label="pagination"]').isVisible();
if (!paginationVisible) {
console.log('Pagination not visible, skipping test.');
const nextButton = page.getByRole('link', { name: 'Next' });
if (!(await nextButton.isVisible())) {
console.log('Pagination next button not visible, skipping test.');
return;
}
const initialPageNumber = await page.locator('[aria-current="page"]').textContent();
await page.getByLabel(/Go to page/).last().click();
const newPageNumber = await page.locator('[aria-current="page"]').textContent();
expect(newPageNumber).not.toBe(initialPageNumber);
await nextButton.click();
await expect(page).toHaveURL(/offset=20/);
await expect(page.locator('#results')).toHaveText('21 - 21 of 21 results');
});
});