You are looking at the exercise repo. Here is what you need to do before writing a single line of test code.
- Fork or clone this repository into your own GitHub account.
- Create a temporary GitHub repository that you own — this is the target your tests will run against.
- Create a fine-grained personal access token scoped only to that repository with Issues read/write permissions.
- Copy
.env.exampleto.envand fill in your own values. - Run
npm install && npm run install:browsers. - Run
npm run github:preflight— it must pass before you write any test.
Never commit your .env file or any token. The .gitignore already excludes it.
The solution will be reviewed by cloning your fork and running it with a different
.envpointing to a separate GitHub repository. If it only works with your specific credentials or your specific repo, it will fail review. Everything that varies must come from environment variables.
GitHub is a real production platform with a stable web UI and a documented REST API. For this exercise, the product under test is the GitHub Issues flow on a repository the candidate creates and controls.
| URL | |
|---|---|
| UI | https://github.com |
| API base | https://api.github.com |
| API reference | https://docs.github.com/en/rest/issues/issues |
Write an E2E test suite for an issue lifecycle flow on GitHub.
Candidate-facing brief: docs/candidate-brief.md
Local setup validation:
npm run github:discovernpm run github:preflight
A helper skeleton is provided in:
helpers/github/helpers.js
The spec files are not provided. The candidate must create them.
A domain helper file with at minimum.
All functions here must use the GitHub REST API via the request fixture. None of them should touch the browser or the UI.
| Function | API call |
|---|---|
_getIssueCreated(request, data) |
POST /repos/{owner}/{repo}/issues — creates an issue, returns the created object |
_getIssueData(request, issueNumber) |
GET /repos/{owner}/{repo}/issues/{issue_number} — fetches one issue |
_updateIssue(request, issueNumber, data) |
PATCH /repos/{owner}/{repo}/issues/{issue_number} — updates title, body, or state |
_getIssueComments(request, issueNumber) |
GET /repos/{owner}/{repo}/issues/{issue_number}/comments — fetches all comments |
_addIssueComment(request, issueNumber, body) |
POST /repos/{owner}/{repo}/issues/{issue_number}/comments — adds a comment |
_closeIssue(request, issueNumber) |
PATCH /repos/{owner}/{repo}/issues/{issue_number} with state: closed — cleanup |
Three tests:
after creating an issue via UI, it should be visible in the UIafter creating an issue via API, edit it and assert via APIafter creating an issue via API, close it and assert via API
Apply the same pattern to comments. The spec file contains a comment that guides you.
The candidate should configure these environment variables locally:
| Variable | Description |
|---|---|
GITHUB_OWNER |
Candidate-owned repository owner |
GITHUB_REPO |
Candidate-owned repository name |
GITHUB_TOKEN |
Candidate-owned token with issues read/write access on that repo |
Preferred UI-auth option:
| Variable | Description |
|---|---|
GITHUB_STORAGE_STATE |
Path to a Playwright storage state file for the candidate's authenticated GitHub session |
Strongly recommended for consistency:
- Use an English-language GitHub session.
- Use a temporary dedicated repository with Issues enabled.
- Run
npm run github:preflightlocally before starting the full implementation.
This project follows a flat, zero-overhead architecture. Every convention below is enforced during review.
// ✅ Correct
const { test, expect } = require('../../fixtures/fixtures.js')
const hlpPW = require('../../helpers/pw/helpers.js')
const hlpXxx = require('../../helpers/xxx/helpers.js')
test('after visiting [...] and doing X, Y should be Z', async ({ request, page, ids }) => {
// 1. Create state via API
// 2. Reuse your authenticated browser session
// 3. Navigate
// 4. UI interaction — always paired with waitForResponse
// 5. Assert via API
})// ❌ Wrong — never use these
describe('...', () => {})
beforeEach(async () => {})
class GitHubPage {} // no Page Object Model// ✅ Correct — atomic, race-free
await Promise.all([
page.waitForResponse(r => r.url().includes('/issues') && r.status() === 200),
page.getByRole('button', { name: 'Save' }).click()
])
// ❌ Wrong — race condition
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForResponse('/issues')
// ❌ Never
await page.waitForTimeout(2000)| Priority | Method | Use when |
|---|---|---|
| 1 | getByRole(role, { name }) |
Buttons, links, inputs, headings, dialogs |
| 2 | getByLabel('...') |
Form fields with a <label> |
| 3 | getByPlaceholder('...') |
Inputs with a placeholder but no label |
| 4 | getByText('...') |
Non-interactive display elements |
| 5 | getByTestId('...') |
Elements with data-testid |
| last | locator('[class*="..."]') |
Never — classes change |
// ✅
page.scanDOM()
page.getByRole('button', { name: '...' })
page.getByPlaceholder('...')
page.getByRole('link', { name: 'Issues' })
// ❌
page.locator('.js-issue-row')
page.locator('button:has-text("Save")')
page.locator('>> nth=0') // use .first() instead// ✅ Stable — API contract
const issue = await hlpGitHub._getIssueData(request, issueNumber)
expect(issue.title).toBe('My New Issue')
expect(issue.state).toBe('closed')
// ❌ Fragile — UI text changes
await expect(page.getByText('My New Issue')).toBeVisible()// ✅ Reuse
const issue = await hlpGitHub._getIssueCreated(request, data)
// ❌ Never inline API calls in test files
const res = await request.post('https://api.github.com/repos/OWNER/REPO/issues')const hlpPW = require('../../helpers/pw/helpers.js')
const letters = await hlpPW.getRandomLetters(10) // e.g. 'xkqmwfrbjz' — use for unique test data
const num = await hlpPW.getRandomNumber(1, 100) // e.g. 42Pre-configured dayjs instance with utc, timezone, customParseFormat, duration, isBetween.
const dayjs = require('../../plugins/index.js')
dayjs.utc('2025-01-01').format('DD/MM/YYYY') // '01/01/2025'Extended test with two additional fixtures:
page.scanDOM() — call it anywhere in a test, run, copy the printed locators, remove the call.
await page.goto('/')
await page.scanDOM() // prints all links, buttons, inputs, testIds to stdoutids — annotates resource IDs for debugging on failure.
test('...', async ({ request, page, ids }) => {
const issue = await hlpGitHub._getIssueCreated(request, data)
ids.set({ issue_number: issue.number }) // shows up in test report on failure
})npm install
npm run install:browsers
npm run run # all tests
export GITHUB_OWNER='your-github-login-or-org'
export GITHUB_REPO='your-temporary-exercise-repo'
export GITHUB_TOKEN='your-local-scoped-token'
export GITHUB_STORAGE_STATE='path/to/your/storage-state.json' # optional
npm run github:discover
npm run github:preflight
npx playwright test tests/github/issues_.spec.js # single file
npx playwright test --headed # with browser visible| Criterion | What we look for |
|---|---|
| Architecture | Flat test(), no describe, no POM, inline setup |
| Async | Promise.all([waitForResponse, action]) — no waitForTimeout |
| Locators | Semantic (getByRole, getByLabel, getByPlaceholder) |
| Assertions | API-based (_getIssueData, _getIssueComments) — not toBeVisible() |
| Helpers | Domain logic in helpers/github/helpers.js, not inline |
| Auth | Sensible handling of the candidate's own GitHub token and browser session |
| Cleanup | Issues closed via API at end of each test |