Skip to content
This repository was archived by the owner on Feb 9, 2026. It is now read-only.

Commit f45b68c

Browse files
authored
Merge pull request #532 from bounswe/feat/431-e2e-job-application
feat: add end-to-end tests for job application flow, including page o…
2 parents 953d343 + 96661b4 commit f45b68c

6 files changed

Lines changed: 465 additions & 0 deletions

File tree

Binary file not shown.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Job Application Page Object
3+
*
4+
* Page object for job application form submission.
5+
* Route: /jobs/:id/apply
6+
*/
7+
8+
import path from 'path';
9+
import { WebDriver, By } from 'selenium-webdriver';
10+
import { BasePage } from './BasePage.ts';
11+
12+
export class JobApplicationPage extends BasePage {
13+
private readonly coverLetter = By.id('coverLetter');
14+
private readonly specialNeeds = By.id('specialNeeds');
15+
private readonly fileInput = By.css('input[type="file"]');
16+
private readonly submitButton = By.css('button[type="submit"]');
17+
18+
// React-Toastify toasts
19+
private readonly successToast = By.css('.Toastify__toast--success');
20+
private readonly errorToast = By.css('.Toastify__toast--error');
21+
private readonly anyToastAlert = By.css('[role="alert"]');
22+
23+
constructor(driver: WebDriver) {
24+
super(driver);
25+
}
26+
27+
async waitForLoaded(timeout: number = 10000): Promise<void> {
28+
await this.waitForVisible(this.coverLetter, timeout);
29+
await this.waitForVisible(this.specialNeeds, timeout);
30+
await this.waitForElement(this.fileInput, timeout);
31+
await this.waitForVisible(this.submitButton, timeout);
32+
}
33+
34+
async fillCoverLetter(text: string): Promise<void> {
35+
await this.type(this.coverLetter, text);
36+
}
37+
38+
async fillSpecialNeeds(text: string): Promise<void> {
39+
await this.type(this.specialNeeds, text);
40+
}
41+
42+
/**
43+
* Upload CV by sending an absolute path to the hidden file input.
44+
* WebDriver requires an absolute path on the local filesystem.
45+
*/
46+
async uploadCv(filePath: string): Promise<void> {
47+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
48+
const input = await this.waitForElement(this.fileInput, 10000);
49+
await input.sendKeys(absolutePath);
50+
}
51+
52+
async submit(): Promise<void> {
53+
await this.click(this.submitButton, 10000);
54+
}
55+
56+
/**
57+
* Wait for a success toast after submitting the application.
58+
* If an error toast appears first, it throws with the toast text.
59+
*/
60+
async waitForSubmissionSuccess(timeout: number = 15000): Promise<void> {
61+
const endTime = Date.now() + timeout;
62+
let lastToastText = '';
63+
64+
while (Date.now() < endTime) {
65+
if (await this.elementExists(this.anyToastAlert)) {
66+
try {
67+
lastToastText = await this.getToastText();
68+
} catch {
69+
// ignore
70+
}
71+
}
72+
73+
if (await this.elementExists(this.errorToast)) {
74+
const errorText = await this.getToastText();
75+
throw new Error(`Application submission failed (toast): ${errorText}`);
76+
}
77+
78+
if (await this.elementExists(this.successToast)) {
79+
const successText = await this.getToastText();
80+
const lower = successText.toLowerCase();
81+
if (lower.includes('submitted')) {
82+
return;
83+
}
84+
// Success toasts sometimes contain only a short title, still accept it.
85+
return;
86+
}
87+
88+
await this.sleep(250);
89+
}
90+
91+
throw new Error(
92+
`Timed out waiting for application submission success toast. Last toast: "${lastToastText}"`
93+
);
94+
}
95+
96+
private async getToastText(): Promise<string> {
97+
try {
98+
const toast = await this.waitForVisible(this.anyToastAlert, 3000);
99+
return await toast.getText();
100+
} catch {
101+
return '';
102+
}
103+
}
104+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Job Detail Page Object
3+
*
4+
* Page object for a single job post detail page.
5+
* Route: /jobs/:id
6+
*/
7+
8+
import { WebDriver, By } from 'selenium-webdriver';
9+
import { BasePage } from './BasePage.ts';
10+
11+
export class JobDetailPage extends BasePage {
12+
private readonly breadcrumb = By.css('nav[aria-label="Breadcrumb"]');
13+
private readonly jobTitle = By.xpath('//h1[contains(@class,"font-bold") and contains(@class,"text-foreground")]');
14+
15+
// "Apply now" is rendered as a Link inside a Button; easiest stable hook is the href.
16+
private readonly applyLink = By.xpath('//a[contains(@href,"/jobs/") and contains(@href,"/apply")]');
17+
18+
constructor(driver: WebDriver) {
19+
super(driver);
20+
}
21+
22+
async waitForLoaded(timeout: number = 10000): Promise<void> {
23+
await this.waitForVisible(this.breadcrumb, timeout);
24+
await this.waitForVisible(this.jobTitle, timeout);
25+
}
26+
27+
async getJobTitle(): Promise<string> {
28+
return await this.getText(this.jobTitle, 10000);
29+
}
30+
31+
async clickApplyNow(): Promise<void> {
32+
await this.click(this.applyLink, 10000);
33+
}
34+
}
35+
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Jobs Page Object
3+
*
4+
* Page object for browsing job listings.
5+
* Route: /jobs/browse
6+
*/
7+
8+
import { WebDriver, By, WebElement } from 'selenium-webdriver';
9+
import { BasePage } from './BasePage.ts';
10+
11+
export class JobsPage extends BasePage {
12+
private readonly searchInput = By.id('search-input');
13+
14+
// JobCard uses a clickable Card (div) without an anchor, so we target the card root.
15+
// We intentionally use a structural XPath to avoid relying on translated text.
16+
private readonly jobCards = By.xpath(
17+
[
18+
'//div[contains(@class,"space-y-4")]',
19+
'//div[contains(@class,"cursor-pointer") and contains(@class,"bg-card")',
20+
' and .//div[contains(@class,"text-xl") and contains(@class,"font-semibold")]]',
21+
].join('')
22+
);
23+
24+
constructor(driver: WebDriver) {
25+
super(driver);
26+
}
27+
28+
/**
29+
* Navigate to jobs browse page
30+
*/
31+
async navigate(): Promise<void> {
32+
await this.goto('/jobs/browse');
33+
await this.waitForPageLoad();
34+
await this.waitForVisible(this.searchInput, 10000);
35+
}
36+
37+
/**
38+
* Return job cards currently visible on the page
39+
*/
40+
async getJobCards(): Promise<WebElement[]> {
41+
return await this.findElements(this.jobCards);
42+
}
43+
44+
/**
45+
* Get number of job cards
46+
*/
47+
async getJobCardCount(): Promise<number> {
48+
return await this.getElementCount(this.jobCards);
49+
}
50+
51+
/**
52+
* Click a job card at a given index (0-based)
53+
*/
54+
async openJobAtIndex(index: number): Promise<void> {
55+
const cards = await this.getJobCards();
56+
if (cards.length === 0) {
57+
throw new Error('No job cards found on /jobs/browse');
58+
}
59+
if (index < 0 || index >= cards.length) {
60+
throw new Error(`Job card index out of bounds: ${index} (found ${cards.length} cards)`);
61+
}
62+
63+
const card = cards[index];
64+
await this.executeScript('arguments[0].scrollIntoView({block: "center"});', card);
65+
await card.click();
66+
}
67+
}
68+
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* My Applications Page Object
3+
*
4+
* Page object for job seeker applications list.
5+
* Route: /jobs/applications
6+
*/
7+
8+
import { WebDriver, By } from 'selenium-webdriver';
9+
import { BasePage } from './BasePage.ts';
10+
11+
export class MyApplicationsPage extends BasePage {
12+
private readonly pageHeader = By.xpath('//h1[contains(@class,"text-3xl") and contains(@class,"font-bold")]');
13+
14+
constructor(driver: WebDriver) {
15+
super(driver);
16+
}
17+
18+
async navigate(): Promise<void> {
19+
await this.goto('/jobs/applications');
20+
await this.waitForPageLoad();
21+
await this.waitForVisible(this.pageHeader, 10000);
22+
}
23+
24+
async waitForApplicationWithTitle(title: string, timeout: number = 15000): Promise<void> {
25+
const endTime = Date.now() + timeout;
26+
const locator = By.xpath(
27+
`//h3[contains(@class,"text-lg") and contains(., ${this.toXPathLiteral(title)})]`
28+
);
29+
30+
while (Date.now() < endTime) {
31+
if (await this.elementExists(locator)) {
32+
return;
33+
}
34+
await this.refresh();
35+
await this.waitForVisible(this.pageHeader, 10000);
36+
await this.sleep(500);
37+
}
38+
39+
throw new Error(`Could not find application with title "${title}" on /jobs/applications`);
40+
}
41+
42+
private toXPathLiteral(value: string): string {
43+
if (!value.includes("'")) {
44+
return `'${value}'`;
45+
}
46+
if (!value.includes('"')) {
47+
return `"${value}"`;
48+
}
49+
const parts = value.split("'");
50+
const concatParts = parts
51+
.map((part) => `'${part}'`)
52+
.join(`, "'", `);
53+
return `concat(${concatParts})`;
54+
}
55+
}
56+

0 commit comments

Comments
 (0)