Problem statement
I usually go to a small local gym near where I live, and it's very common for group classes to reach full capacity, so they use a reservation system. You can only book a class 48 hours before it starts. I found it inconvenient always to remember to open the website and make a reservation, and just one minute later, all the spots would be taken. So, I started thinking about how to come up with a solution.
Code Structure
For this mini project, I used the page-object model to ensure the test looks clean and simple, as you can see here:
import { test } from '@playwright/test';
import { LoginPage } from '../page-object/login-po';
import { BookingPage } from '../page-object/booking-po';
test('Book Class', async ({ page }) => {
const loginPage = new LoginPage(page);
const bookingPage = new BookingPage(page);
await loginPage.goto();
await loginPage.login();
await bookingPage.goto();
await bookingPage.bookDesiredClass();
await bookingPage.verifyBookingConfirmationMessage();
});
This is the full structure:
├── page-object
│ ├── booking-po.ts
│ └── login-po.ts
├── playwright.config.ts
├── readme.md
└── tests
└── book-class.spec.ts
I used a couple of page objects, one for the login page and another for the reservation page. The desired class is specified at runtime using an environment variable, which makes it easy to create cron jobs for every class I want to book.
Challenges
- Not knowing which button corresponds to the desired activity to book.
Since the reservation website is not something I own, it doesn’t have the selectors I would prefer. For example, to select the activity to book, there is a booking button for each class, but I can't differentiate them in any way. To solve this, I had to iterate through all the divs and find the one that matches the activity and activity time I want to book. Once I find it, I use the index of the corresponding button to click it. This code, of course, is placed in the booking-po.ts class as follows:
async findDesiredClassIndex() {
if (process.env.ACTIVITY && process.env.ACTIVITY_TIME) {
const activity = process.env.ACTIVITY;
const activityTime = process.env.ACTIVITY_TIME;
const gymClasses = await this.listOfClasses.all();
let indexFound = -1;
for (let i = 0; i < gymClasses.length; i++) {
const classItemText = await gymClasses[i].textContent();
if (
classItemText !== null &&
classItemText.includes(activity) &&
classItemText.includes(activityTime)
) {
indexFound = i;
break;
}
}
return indexFound;
} else {
throw new Error(
'ACTIVITY & ACTIVITY_TIME env variables should be defined.'
);
}
}
- GitHub Actions don’t always start exactly at the time specified by the cron expression.
As mentioned, reservations reach full capacity in about one minute or even less, especially for CrossFit, so I have to act quickly once booking becomes available. Initially, I thought of scheduling the GitHub Action to run exactly when the booking window opened, but this didn’t work as expected. First, GitHub Actions can experience slight delays due to machine availability. Additionally, I hadn’t considered the time it takes to install dependencies and begin execution.
So, I had to rethink my approach. The solution I found was to schedule the job to start 10 minutes before the booking window opened and then add a shell command to wait until the window was actually open. This command runs right after the dependencies are installed, ensuring that the "test" starts exactly when the target time arrives.
Github Actions
In this case, the approach I followed was to create a separate action for each booking I needed. To do this, I created a template containing all the logic, and I simply call it with the required parameters: the activity to book and the activity time, as shown below:
name: Book CROSS TRAINING for Friday 17:30
on:
workflow_dispatch:
schedule:
- cron: '15 15 * * 3' # Runs every Wednesday at 17:15h // 2h difference with server.
jobs:
invoke-template:
uses: ./.github/workflows/template.yml
with:
activity: 'CROSS TRAINING'
activity_time: '17:30'
secrets:
base_url: ${{ secrets.BASE_URL }}
gymusername: ${{ secrets.GYMUSERNAME }}
password: ${{ secrets.PASSWORD }}
And that's it, folks! I no longer have to worry about keeping an eye on the booking window for activities at my gym. :)
Any suggestions or ideas for improvement are welcome!
Top comments (0)