Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
48 changes: 42 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -120,15 +125,16 @@
- name: Install dependencies
run: yarn -D
- run: npx playwright install --with-deps
- run: yarn test:e2e
- run: yarn test:e2e --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.deployment-url }}
- uses: actions/upload-artifact@v4
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1

check_formatting:
if: github.event_name == 'pull_request'
Expand All @@ -151,3 +157,33 @@
yarn-modules-${{ runner.arch }}-${{ runner.os }}-
- run: yarn -D
- run: yarn format:check

merge-reports:
if: ${{ !cancelled() }}
needs: [e2e]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: lts/*

- name: Install dependencies
run: yarn -D

- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v5
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true

- name: Merge into HTML Report
run: npx playwright merge-reports --reporter html ./all-blob-reports

- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14
15 changes: 11 additions & 4 deletions e2e/home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ test.describe('Homepage', () => {
await page.goto('/');
});

test('hero renders with heading', async ({ page }) => {
const heading = page.locator('h1');
await expect(heading).toContainText("UIUC's Hub for");
await expect(heading).toContainText('Everything CS.');
test('Hero renders with heading', async ({ page }) => {
const heading = page.getByRole('heading', {
name: "UIUC's Hub for Everything CS.",
});
expect(heading).toBeVisible();
});

test('Join ACM and Donate buttons are visible', async ({ page }) => {
Expand All @@ -29,4 +30,10 @@ test.describe('Homepage', () => {
await expect(page.locator(`img[src*="${sponsor}"]`)).toBeVisible();
}
});
test('Copyright section is visible', async ({ page }) => {
const currentYear = new Date().getFullYear();
await expect(
page.getByText(`© ${currentYear} ACM @ UIUC. All rights reserved.`)
).toBeVisible();
});
});
33 changes: 33 additions & 0 deletions e2e/identitySync.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { test, expect } from '@playwright/test';

test.describe('Identity Sync', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin/sync');
});

test('Page renders with heading', async ({ page }) => {
const heading = page.locator('h1');
await expect(heading).toContainText('Sync Identity');
const desc = page.locator('p').first();
await expect(desc).toContainText(
'Use this tool to setup your account, or update your ACM account with details from your Illinois NetID.'
);
});
test('Page provides sync button which leads to login redirect', async ({
page,
}) => {
await expect(
page.getByRole('button', { name: 'Sync Identity' })
).toBeVisible();
await page.getByRole('button', { name: 'Sync Identity' }).click();
await page.waitForURL(
'https://login.microsoftonline.com/44467e6f-462c-4ea2-823f-7800de5434e3/oauth2/v2.0/authorize?client_id=d64e9c50-d144-4b4a-a315-ad2ed456c37**'
);
});
test('Copyright section is visible', async ({ page }) => {
const currentYear = new Date().getFullYear();
await expect(
page.getByText(`© ${currentYear} ACM @ UIUC. All rights reserved.`)
).toBeVisible();
});
});
56 changes: 56 additions & 0 deletions e2e/membership.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';

test.describe('Membership Purchase', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/membership');
});

test('Page renders with heading and perks', async ({ page }) => {
const heading = page.locator('h1');
await expect(heading).toContainText('Become a Paid Member');

const perksList = page.getByTestId('membership-perks');
await expect(perksList).toBeVisible();
const listItems = perksList.locator('li');
expect((await listItems.all()).length).toBeGreaterThan(0);
});
test('Page provides purchase button which leads to login redirect', async ({
page,
}) => {
await expect(
page.getByRole('button', { name: 'Purchase Membership' })
).toBeVisible();
await page.getByRole('button', { name: 'Purchase Membership' }).click();
await page.waitForURL(
'https://login.microsoftonline.com/44467e6f-462c-4ea2-823f-7800de5434e3/oauth2/v2.0/authorize?client_id=d64e9c50-d144-4b4a-a315-ad2ed456c37**'
);
});
});

test.describe('Membership Check', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/membership/check');
});

test('Page renders with heading', async ({ page }) => {
const heading = page.locator('h1');
await expect(heading).toContainText('Check Paid Membership');
});
test('Page provides check button which leads to login redirect', async ({
page,
}) => {
await expect(
page.getByRole('button', { name: 'Check Membership' })
).toBeVisible();
await page.getByRole('button', { name: 'Check Membership' }).click();
await page.waitForURL(
'https://login.microsoftonline.com/44467e6f-462c-4ea2-823f-7800de5434e3/oauth2/v2.0/authorize?client_id=d64e9c50-d144-4b4a-a315-ad2ed456c37**'
);
});
test('Copyright section is visible', async ({ page }) => {
const currentYear = new Date().getFullYear();
await expect(
page.getByText(`© ${currentYear} ACM @ UIUC. All rights reserved.`)
).toBeVisible();
});
});
19 changes: 10 additions & 9 deletions e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { test, expect } from '@playwright/test';

const internalPages = [
{ href: '/about', heading: /About/i },
{ href: '/calendar', heading: /Calendar/i },
{ href: '/store', heading: /All Products/i },
];

test.describe('Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
Expand All @@ -24,21 +30,16 @@ test.describe('Navigation', () => {
await expect(page.locator('nav a[href*="illinoiscs.wiki"]')).toBeVisible();
});

test('internal nav links navigate to correct pages', async ({ page }) => {
const pages = [
{ href: '/about', heading: /About/i },
{ href: '/calendar', heading: /Calendar/i },
];

for (const p of pages) {
for (const p of internalPages) {
test(`Internal nav link navigates to ${p.href}`, async ({ page }) => {
await page.goto('/');
await page.locator(`nav a[href="${p.href}"]`).click();
await expect(page).toHaveURL(new RegExp(p.href));
await expect(
page.getByRole('heading', { name: p.heading }).first()
).toBeVisible();
}
});
});
}

test('Join Now button opens Join modal', async ({ page }) => {
await page.getByRole('button', { name: /Join Now/i }).click();
Expand Down
23 changes: 23 additions & 0 deletions e2e/resources.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test';

test.describe('Resources', () => {
test.beforeEach(async ({ page }) => {
await page.goto('resources/');
});

test('Heading renders with description', async ({ page }) => {
const heading = page.getByTestId('content-title');
const description = page.getByTestId('content-description');
await expect(heading).toContainText('Resources');
await expect(description).toContainText(
'Learn about resources provided to ACM @ UIUC members'
);
});

test('Copyright section is visible', async ({ page }) => {
const currentYear = new Date().getFullYear();
await expect(
page.getByText(`© ${currentYear} ACM @ UIUC. All rights reserved.`)
).toBeVisible();
});
});
44 changes: 9 additions & 35 deletions src/authConfig.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import {
BrowserAuthError,
createStandardPublicClientApplication,
InteractionRequiredAuthError,
type IPublicClientApplication,
type PopupRequest,
type RedirectRequest,
} from '@azure/msal-browser';

export const loginRequest = {
Expand All @@ -27,54 +26,29 @@ export const initMsalClient = async () => {
};

export const getUserAccessToken = async (
pca: IPublicClientApplication
pca: IPublicClientApplication,
returnPath?: string
): Promise<string> => {
const account = pca.getActiveAccount() || pca.getAllAccounts()[0];
const request: PopupRequest = {
const request: RedirectRequest = {
...loginRequest,
account: account || undefined,
redirectUri: '/auth-redirect',
state: returnPath ?? window.location.pathname + window.location.search,
};

if (!account) {
try {
const response = await pca.loginPopup(request);
pca.setActiveAccount(response.account);
return response.accessToken;
} catch (error) {
if (
error instanceof BrowserAuthError &&
error.errorCode === 'popup_window_error'
) {
alert(
'Your browser is blocking popups. Please allow popups for this site and then try logging in again.'
);
}
console.error('MSAL login failed:', error);
throw error;
}
await pca.loginRedirect(request);
throw new Error('Redirecting to login');
}

try {
const response = await pca.acquireTokenSilent(request);
return response.accessToken;
} catch (e) {
if (e instanceof InteractionRequiredAuthError) {
try {
const response = await pca.acquireTokenPopup(request);
return response.accessToken;
} catch (popupError) {
if (
popupError instanceof BrowserAuthError &&
popupError.errorCode === 'popup_window_error'
) {
alert(
'Your browser is blocking popups, which are required for this action. Please allow popups for this site and try again.'
);
}
console.error('MSAL popup token acquisition failed:', popupError);
throw popupError;
}
await pca.acquireTokenRedirect(request);
throw new Error('Redirecting to login', { cause: e });
}
throw e;
}
Expand Down
20 changes: 18 additions & 2 deletions src/components/AuthActionButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { IPublicClientApplication } from '@azure/msal-browser';
import type { LucideIcon } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';

import { getUserAccessToken, initMsalClient } from '../authConfig.ts';
import ErrorPopup, { useErrorPopup } from './ErrorPopup.tsx';
Expand All @@ -19,6 +19,7 @@ interface AuthActionButtonProps {
accessToken: string,
showError: ShowErrorFunction
) => Promise<void>;
returnPath?: string;
buttonClassName?: string;
bgColorClass?: string;
textColorClass?: string;
Expand All @@ -30,6 +31,7 @@ export default function AuthActionButton({
defaultText,
workingText,
onAction,
returnPath,
buttonClassName,
bgColorClass = 'bg-tangerine-600 hover:bg-tangerine-700',
textColorClass = 'text-white',
Expand All @@ -39,17 +41,31 @@ export default function AuthActionButton({
const [isWorking, setIsWorking] = useState(false);
const [pca, setPca] = useState<IPublicClientApplication>();

const autoTriggered = useRef(false);

useEffect(() => {
initMsalClient().then(setPca).catch(console.error);
}, []);

useEffect(() => {
if (!pca || autoTriggered.current) return;
const params = new URLSearchParams(window.location.search);
if (!params.has('authButtonClick')) return;
autoTriggered.current = true;
params.delete('authButtonClick');
const qs = params.toString();
const newUrl = window.location.pathname + (qs ? `?${qs}` : '');
window.history.replaceState({}, '', newUrl);
handleClick();
}, [pca]);

const handleClick = async () => {
if (!pca) {
return;
}
setIsWorking(true);
try {
const accessToken = await getUserAccessToken(pca);
const accessToken = await getUserAccessToken(pca, returnPath);
await onAction(accessToken, showError);
} finally {
setIsWorking(false);
Expand Down
1 change: 1 addition & 0 deletions src/components/MembershipCheckButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function MembershipCheckButton({ class: className }: Props) {
icon={IdCard}
defaultText="Check Membership"
workingText="Checking..."
returnPath={`${window.location.href}?authButtonClick`}
onAction={async (accessToken, showError) => {
try {
const response = await membershipApiClient.apiV1MembershipGet({
Expand Down
1 change: 1 addition & 0 deletions src/components/MembershipPurchaseButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function MembershipPurchaseButton({ class: className }: Props) {
icon={ShoppingCart}
defaultText="Purchase Membership"
workingText="Loading..."
returnPath="/membership?authButtonClick"
onAction={async (accessToken, showError) => {
try {
const checkoutUrl =
Expand Down
Loading
Loading