Skip to content

Commit d165215

Browse files
committed
Add Playwright smoke tests, fix votekeeper seed bug, fix reveal cache
- Add end-to-end Playwright smoke test covering full voting flow - Fix seedVotekeeper using userId instead of email as rowKey (isVotekeeper lookup mismatch) - Fix RevealView not invalidating results query on reveal/complete - Add smoke test job to CI workflow with Azurite + Functions + Vite - Make vite.config.ts API proxy target configurable via API_TARGET env var
1 parent da47fd3 commit d165215

11 files changed

Lines changed: 383 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,66 @@ jobs:
5656
- name: Build
5757
working-directory: web
5858
run: npx vite build
59+
60+
smoke:
61+
name: Smoke Tests
62+
runs-on: ubuntu-latest
63+
needs: [functions, web]
64+
65+
steps:
66+
- uses: actions/checkout@v4
67+
68+
- uses: actions/setup-node@v4
69+
with:
70+
node-version: '20'
71+
72+
- name: Install Azurite
73+
run: npm install -g azurite
74+
75+
- name: Install Azure Functions Core Tools
76+
run: npm install -g azure-functions-core-tools@4 --unsafe-perm true
77+
78+
- name: Install all dependencies
79+
run: |
80+
npm ci
81+
cd functions && npm ci
82+
cd ../web && npm ci
83+
84+
- name: Build Functions
85+
working-directory: functions
86+
run: npm run build
87+
88+
- name: Install Playwright Chromium
89+
run: npx playwright install --with-deps chromium
90+
91+
- name: Start services
92+
run: |
93+
# Start Azurite in background
94+
azurite --silent --blobPort 10000 --queuePort 10001 --tablePort 10002 &
95+
96+
# Start Functions in background
97+
cd functions && npx func start --port 7071 &
98+
99+
# Start Vite dev server in background
100+
cd web && npx vite --port 5173 &
101+
102+
# Wait for all services to be ready
103+
echo "Waiting for services..."
104+
for i in $(seq 1 30); do
105+
if curl -sf http://localhost:5173/api/me > /dev/null 2>&1; then
106+
echo "Services ready after ${i}s"
107+
break
108+
fi
109+
sleep 1
110+
done
111+
112+
- name: Run smoke tests
113+
run: npm run test:smoke
114+
115+
- name: Upload test artifacts
116+
if: failure()
117+
uses: actions/upload-artifact@v4
118+
with:
119+
name: smoke-test-results
120+
path: test-results/
121+
retention-days: 7

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ functions/deploy/
1010

1111
# Azure Storage emulator
1212
.azurite/
13+
__azurite_db_*__*
14+
AzuriteConfig
1315
__blobstorage__/
1416
__queuestorage__/
1517
__tablestorage__/
@@ -31,3 +33,6 @@ npm-debug.log*
3133

3234
# Test coverage
3335
coverage/
36+
# Playwright
37+
test-results/
38+
playwright-report/

docs/plan.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,6 @@ event-vote/
607607

608608
### Phase 2: Core Voting
609609
- [x] Voting option management (add/edit/remove/reorder)
610-
- [ ] Projector-friendly setup view (prominent event title)
611610
- [x] QR code generation
612611
- [x] Voter name entry
613612
- [x] Attendee ballot view (mobile-first, vote reset + change)
@@ -631,7 +630,7 @@ event-vote/
631630
- [x] Error handling + edge cases (disconnect recovery, voting closed mid-edit)
632631
- [x] Mobile responsiveness fine-tuning
633632
- [x] Event expiration / cleanup
634-
- [ ] Smoke tests (Playwright — create event, vote, reveal flow)
633+
- [x] Smoke tests (Playwright — create event, vote, reveal flow)
635634
- [x] CI/CD pipeline (GitHub Actions — build + unit tests + smoke tests)
636635
- [x] Deployment to Azure
637636
- [x] README + docs

functions/src/functions/votekeepers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ async function seedVotekeeper(request: HttpRequest, context: InvocationContext):
104104
return { status: 401, jsonBody: { error: 'Authentication required' } };
105105
}
106106
const decoded = JSON.parse(Buffer.from(header, 'base64').toString('utf-8'));
107-
const userId = decoded.userId;
107+
const email = (decoded.userDetails || decoded.userId).toLowerCase();
108108
const displayName = decoded.userDetails || decoded.userId;
109109

110110
// Check if any votekeepers exist
@@ -122,7 +122,7 @@ async function seedVotekeeper(request: HttpRequest, context: InvocationContext):
122122

123123
const entity: VotekeeperEntity = {
124124
partitionKey: 'votekeeper',
125-
rowKey: userId,
125+
rowKey: email,
126126
displayName: displayName,
127127
addedAt: new Date().toISOString(),
128128
addedBy: 'system',

package-lock.json

Lines changed: 79 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "event-vote",
3+
"private": true,
4+
"version": "1.0.0",
5+
"description": "Live audience voting for events",
6+
"devDependencies": {
7+
"@playwright/test": "^1.58.2"
8+
},
9+
"scripts": {
10+
"test:smoke": "playwright test"
11+
}
12+
}

playwright.config.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './tests/smoke',
5+
globalSetup: './tests/smoke/global-setup.ts',
6+
timeout: 60_000,
7+
expect: { timeout: 10_000 },
8+
fullyParallel: false,
9+
retries: 0,
10+
reporter: 'list',
11+
use: {
12+
baseURL: 'http://localhost:5173',
13+
trace: 'on-first-retry',
14+
screenshot: 'only-on-failure',
15+
},
16+
projects: [
17+
{
18+
name: 'chromium',
19+
use: { browserName: 'chromium' },
20+
},
21+
],
22+
});

tests/smoke/full-flow.spec.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* Smoke test – Full voting flow
3+
*
4+
* Exercises the entire happy-path:
5+
* 1. Votekeeper signs in (mock auth)
6+
* 2. Seeds votekeeper if first time
7+
* 3. Creates an event
8+
* 4. Adds 3 voting options
9+
* 5. Opens voting
10+
* 6. Voter joins on a mobile viewport, enters name, allocates votes
11+
* 7. Votekeeper closes voting
12+
* 8. Votekeeper reveals results one-by-one
13+
* 9. Votekeeper completes the event
14+
* 10. Public results page loads
15+
*
16+
* Prerequisites: the local dev environment must be running
17+
* (Azurite + Azure Functions + Vite dev server).
18+
*/
19+
import { test, expect, type Page, type BrowserContext } from '@playwright/test';
20+
21+
// ── helpers ──────────────────────────────────────────────────────────────
22+
23+
/** Sign in via the mock auth page and land on the dashboard. */
24+
async function votekeeperSignIn(page: Page) {
25+
await page.goto('/.auth/login/aad?post_login_redirect_uri=/dashboard');
26+
await page.getByRole('button', { name: /sign in/i }).click();
27+
// Dashboard loads, fires /api/me, then shows content or redirects.
28+
// Wait for either "Create Event" link or "Become First Votekeeper" button.
29+
await expect(
30+
page.getByRole('link', { name: /new event/i }).first()
31+
.or(page.getByRole('button', { name: /become first votekeeper/i }))
32+
).toBeVisible({ timeout: 20_000 });
33+
}
34+
35+
/** If the "Become First Votekeeper" button is visible, click it and wait. */
36+
async function seedIfNeeded(page: Page) {
37+
const seedBtn = page.getByRole('button', { name: /become first votekeeper/i });
38+
if (await seedBtn.isVisible()) {
39+
await seedBtn.click();
40+
// After seeding the page refreshes and shows the dashboard
41+
await expect(page.getByRole('link', { name: /new event/i }).first()).toBeVisible({ timeout: 15_000 });
42+
}
43+
}
44+
45+
// ── test ─────────────────────────────────────────────────────────────────
46+
47+
test('create event → vote → reveal → complete', async ({ browser }) => {
48+
// ── 1. Votekeeper: sign in ──────────────────────────────────────────
49+
const vkContext = await browser.newContext();
50+
const vk = await vkContext.newPage();
51+
52+
await votekeeperSignIn(vk);
53+
await seedIfNeeded(vk);
54+
55+
// ── 2. Create event ─────────────────────────────────────────────────
56+
await vk.getByRole('link', { name: /new event/i }).first().click();
57+
await vk.waitForURL('**/create');
58+
59+
await vk.getByLabel(/event name/i).fill('Smoke Test Event');
60+
await vk.getByRole('button', { name: /create event/i }).click();
61+
62+
// Land on the manage page – URL contains /manage/<4-letter ID>
63+
await vk.waitForURL(/\/manage\/[A-Z]{4}$/);
64+
const eventId = vk.url().match(/\/manage\/([A-Z]{4})$/)?.[1];
65+
expect(eventId).toBeTruthy();
66+
67+
// ── 3. Add 3 voting options ─────────────────────────────────────────
68+
const options = ['Option Alpha', 'Option Beta', 'Option Gamma'];
69+
for (const title of options) {
70+
await vk.getByPlaceholder('Option title').fill(title);
71+
await vk.getByRole('button', { name: 'Add' }).click();
72+
// Wait for the option to appear in the list
73+
await expect(vk.getByText(title)).toBeVisible();
74+
}
75+
76+
// Verify all 3 options are listed
77+
await expect(vk.getByText('Voting Options (3)')).toBeVisible();
78+
79+
// ── 4. Open voting ──────────────────────────────────────────────────
80+
await vk.getByRole('button', { name: /open voting/i }).click();
81+
82+
// Status should change
83+
await expect(vk.getByText(/open/i).first()).toBeVisible();
84+
85+
// ── 5. Voter: join, enter name, cast votes ──────────────────────────
86+
const voterContext: BrowserContext = await browser.newContext({
87+
viewport: { width: 375, height: 812 }, // iPhone-like viewport
88+
});
89+
const voter = await voterContext.newPage();
90+
91+
await voter.goto(`/join/${eventId}`);
92+
await expect(voter.getByText('Smoke Test Event')).toBeVisible();
93+
94+
// Enter voter name
95+
await voter.getByPlaceholder(/enter your name/i).fill('Test Voter');
96+
97+
// Voting option buttons should appear
98+
await expect(voter.getByText('Option Alpha')).toBeVisible();
99+
100+
// Allocate votes: 2 on Alpha, 1 on Gamma (3 total, the default)
101+
// Use heading-based locator to find each option's + button
102+
const optionPlus = (name: string) =>
103+
voter.getByRole('heading', { name }).locator('..').locator('..').getByRole('button', { name: '+' });
104+
105+
await optionPlus('Option Alpha').click();
106+
await optionPlus('Option Alpha').click();
107+
await optionPlus('Option Gamma').click();
108+
109+
// Verify all votes allocated
110+
await expect(voter.getByText('All votes allocated!')).toBeVisible();
111+
112+
// Wait for auto-save (debounce 1.5s + network)
113+
await expect(voter.getByText('Saving...')).toBeVisible({ timeout: 5_000 });
114+
await expect(voter.getByText('✓ Saved')).toBeVisible({ timeout: 10_000 });
115+
116+
// ── 6. Votekeeper: close voting ─────────────────────────────────────
117+
// Accept the confirm dialog
118+
vk.on('dialog', (d) => d.accept());
119+
await vk.getByRole('button', { name: /close voting/i }).click();
120+
121+
// Status should become 'closed'
122+
await expect(vk.getByText(/closed/i).first()).toBeVisible({ timeout: 10_000 });
123+
124+
// ── 7. Reveal results one-by-one ────────────────────────────────────
125+
// Click "Reveal Results" to start the reveal
126+
await vk.getByRole('button', { name: /reveal results/i }).click();
127+
128+
// Now in the reveal view – dark background
129+
await expect(vk.locator('.bg-gray-900')).toBeVisible({ timeout: 10_000 });
130+
131+
// Reveal each option (3 options → 2 more "Reveal Next" clicks)
132+
for (let i = 0; i < 2; i++) {
133+
const revealNext = vk.getByRole('button', { name: /reveal next/i });
134+
await expect(revealNext).toBeVisible({ timeout: 10_000 });
135+
await revealNext.click();
136+
// Brief wait for animation/network
137+
await vk.waitForTimeout(500);
138+
}
139+
140+
// All revealed – "Complete Event" should appear
141+
await expect(vk.getByRole('button', { name: /complete event/i })).toBeVisible({ timeout: 10_000 });
142+
await vk.getByRole('button', { name: /complete event/i }).click();
143+
144+
// ── 8. Verify final results ─────────────────────────────────────────
145+
// Wait for "Final Results" label first, then check content
146+
await expect(vk.getByText('Final Results')).toBeVisible({ timeout: 10_000 });
147+
await expect(vk.getByRole('heading', { name: 'Option Alpha' }).first()).toBeVisible({ timeout: 10_000 });
148+
149+
// PDF link should be available
150+
await expect(vk.getByText('PDF')).toBeVisible();
151+
152+
// ── 9. Public results page ──────────────────────────────────────────
153+
const publicPage = await vkContext.newPage();
154+
await publicPage.goto(`/results/${eventId}`);
155+
await expect(publicPage.getByText('Smoke Test Event')).toBeVisible({ timeout: 10_000 });
156+
await expect(publicPage.getByRole('heading', { name: 'Option Alpha' }).first()).toBeVisible();
157+
158+
// ── Cleanup ─────────────────────────────────────────────────────────
159+
await voterContext.close();
160+
await vkContext.close();
161+
});

0 commit comments

Comments
 (0)