Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ jobs:
run: |
yarn lint:quiet

- name: Run tests
run: yarn test
- name: Run Jest tests
run: yarn test:jest:ci

- name: Run E2E tests
run: yarn test:e2e:ci

- name: Compile application
env:
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ typings/
# Yarn Integrity file
.yarn-integrity

# Playwright report and results
playwright-report
test-results

# Playwright stored login status
e2e/.auth/

# dotenv environment variables file
.env

Expand Down
28 changes: 28 additions & 0 deletions e2e/auth.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const path = require('path');
const fs = require('fs');
const { test: setup, expect } = require('./fixtures');

const authFile = path.join(__dirname, '.auth/user.json');

setup('authenticate via Test Sign In', async ({ page }) => {
// Ensure .auth directory exists
fs.mkdirSync(path.dirname(authFile), { recursive: true });

// Navigate to sign-in with redirect back to home
// public/config.js already sets TEST_SIGN_IN=true and staging URL
await page.goto('/sign-in?redirect=%2F');

// "Test Sign In" button is visible because config.js sets TEST_SIGN_IN=true
await expect(page.getByText('Test Sign In')).toBeVisible();
await page.getByText('Test Sign In').click();

// testSignIn() calls GET /api/Account/TestSignIn on ct-dev.ncsa.illinois.edu,
// saves authToken+userInfo to localStorage, then does window.location = '/'
await page.waitForURL('/', { timeout: 15000 });

// Verify we're on the home page and authenticated
await expect(page.locator('#ct-nav-header')).toBeVisible();

// Save localStorage state (authToken + userInfo) for reuse across all tests
await page.context().storageState({ path: authFile });
});
43 changes: 43 additions & 0 deletions e2e/fixtures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Custom Playwright test fixtures.
*
* - `test` — same as base test, but the `page` fixture auto-installs
* mock routes before each test (no real backend needed).
* - `mockContext` — helper for beforeAll helpers that create their own
* browser contexts (discoverVideoUrl, discoverEPubUrls, etc.).
*
* Usage in test files:
* const { test, expect } = require('../fixtures');
* const { test, expect, mockContext } = require('../fixtures');
*
* Usage in auth.setup.js:
* const { test: setup, expect } = require('./fixtures');
*/

const { test: base, expect } = require('@playwright/test');
const { setupMockRoutes } = require('./mocks/api-routes');

const test = base.extend({
// Install mock routes on the BrowserContext, not just the Page.
// Context-level routes apply to all pages in the test, including popups
// opened via target="_blank" — which page-level routes would miss.
page: async ({ page, context }, use) => {
await setupMockRoutes(context);
await use(page);
},
});

/**
* Create a browser context with mock routes pre-installed.
* Use inside beforeAll helpers that navigate the app to discover dynamic URLs.
*
* @param {import('@playwright/test').Browser} browser
* @param {import('@playwright/test').BrowserContextOptions} [options]
*/
async function mockContext(browser, options = {}) {
const context = await browser.newContext(options);
await setupMockRoutes(context);
return context;
}

module.exports = { test, expect, mockContext };
126 changes: 126 additions & 0 deletions e2e/mocks/api-routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Playwright network mock layer for E2E tests.
*
* Only intercepts requests to https://ct-dev.ncsa.illinois.edu — the staging
* backend configured in public/config.js. Everything else (CDN assets,
* localhost dev-server, etc.) is left untouched and passes through normally.
*
* Mock data lives in ./data/*.json and can be edited to add new test scenarios.
*/

const BASE = 'https://ct-dev.ncsa.illinois.edu';

const universities = require('./data/universities.json');
const terms = require('./data/terms.json');
const departments = require('./data/departments.json');
const offerings = require('./data/offerings.json');
const playlistsByOff = require('./data/playlists-by-offering.json');
const playlist = require('./data/playlist.json');
const media = require('./data/media.json');
const epubList = require('./data/epub-list.json');
const epub = require('./data/epub.json');

/**
* Generate a mock JWT whose payload jwtDecode() (used client-side) can read.
* Only the base64url-encoded payload matters; the signature is intentionally fake.
*/
function makeMockJwt() {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({
sub: 'testuser@classtranscribe.com',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname': 'Test',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname': 'User',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'testuser@classtranscribe.com',
'classtranscribe/UserId': 'mock-user-001',
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role': ['Instructor', 'Admin'],
exp: 9999999999,
})).toString('base64url');
return `${header}.${payload}.mock-sig`;
}

const MOCK_JWT = makeMockJwt();

function json(data) {
return { status: 200, contentType: 'application/json', body: JSON.stringify(data) };
}

// Minimal 1×1 white pixel PNG for mocking image buffer requests
const MOCK_IMAGE_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';

/**
* Route an intercepted ct-dev request to the correct mock response.
* Unrecognised paths return {} so the app receives valid JSON instead of an error.
*/
function handleRequest(route, path) {
// Mock cover image — returns a minimal 1×1 PNG buffer
if (path === '/api/static/mock-cover.png')
return route.fulfill({ status: 200, contentType: 'image/png', body: Buffer.from(MOCK_IMAGE_B64, 'base64') });

// Auth
if (path === '/api/Account/TestSignIn')
return route.fulfill(json({ authToken: MOCK_JWT, userId: 'mock-user-001', emailId: 'testuser@classtranscribe.com', universityId: 'mock-uni-001' }));

// Universities — must return an array; non-array triggers InvalidDataError in homeSlice
if (path === '/api/Universities' || path.startsWith('/api/Universities/'))
return route.fulfill(json(universities));

// Terms — must return an array; non-array triggers InvalidDataError + rethrow in homeSlice
if (path.startsWith('/api/Terms/'))
return route.fulfill(json(terms));

// Departments
if (path.startsWith('/api/Departments'))
return route.fulfill(json(departments));

// Watch history — returns array; has graceful catch block
if (path.startsWith('/api/WatchHistories/'))
return route.fulfill(json([]));

// User metadata — has graceful catch block; starredOfferings field is optional
if (path.startsWith('/api/Account/GetUserMetadata'))
return route.fulfill(json({}));

// Captions — transcript lines for the watch page
if (path.startsWith('/api/Captions/'))
return route.fulfill(json([]));

// Offerings — ByStudent returns the full list; any /:id returns the first offering object
if (path === '/api/Offerings/ByStudent')
return route.fulfill(json(offerings));
if (path.startsWith('/api/Offerings/'))
return route.fulfill(json(offerings[0]));

// Playlists — check more-specific ByOffering before generic /:id
if (path.startsWith('/api/Playlists/ByOffering/'))
return route.fulfill(json(playlistsByOff));
if (path.startsWith('/api/Playlists/'))
return route.fulfill(json(playlist));

// Media — exclude multipart upload at /api/Media/Media/
if (path.startsWith('/api/Media/') && !path.startsWith('/api/Media/Media/'))
return route.fulfill(json(media));

// EPubs — check more-specific BySource before generic /:id
if (path.startsWith('/api/EPubs/BySource/'))
return route.fulfill(json(epubList));
if (path.startsWith('/api/EPubs/'))
return route.fulfill(json(epub));

// Anything else on ct-dev (SignalR hubs, swagger, etc.) — return empty JSON
console.warn(`[mock] unhandled ct-dev path: ${path}`);
return route.fulfill(json({}));
}

/**
* Attach mock routes to a Playwright page or browser context.
* Only ct-dev traffic is intercepted; all other requests pass through.
*
* @param {import('@playwright/test').Page | import('@playwright/test').BrowserContext} pageOrContext
*/
async function setupMockRoutes(pageOrContext) {
await pageOrContext.route(`${BASE}/**`, (route) =>
handleRequest(route, new URL(route.request().url()).pathname),
);
}

module.exports = { setupMockRoutes };
8 changes: 8 additions & 0 deletions e2e/mocks/data/departments.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[
{
"id": "mock-dept-001",
"name": "Computer Science",
"acronym": "CS",
"universityId": "mock-uni-001"
}
]
13 changes: 13 additions & 0 deletions e2e/mocks/data/epub-list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[
{
"id": "mock-epub-001",
"sourceType": 2,
"sourceId": "mock-media-001",
"title": "CS 101 Lecture 1 I-Note",
"filename": "CS-101-Lecture-1",
"language": "en-US",
"author": "Test User",
"publisher": "ClassTranscribe",
"publishStatus": 0
}
]
35 changes: 35 additions & 0 deletions e2e/mocks/data/epub.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"id": "mock-epub-001",
"sourceType": 2,
"sourceId": "mock-media-001",
"title": "CS 101 Lecture 1 I-Note",
"filename": "CS-101-Lecture-1",
"language": "en-US",
"author": "Test User",
"publisher": "ClassTranscribe",
"cover": { "src": "/api/static/mock-cover.png", "alt": "Cover image", "descriptions": [], "timestamp": null, "link": null },
"chapters": [
{
"id": "mock-chapter-001",
"title": "Chapter 1: Introduction",
"condition": ["default"],
"contents": [
"Welcome to CS 101. This course covers fundamental computer science concepts.",
"We will explore algorithms, data structures, and programming paradigms."
],
"subChapters": []
},
{
"id": "mock-chapter-002",
"title": "Chapter 2: Basic Programming",
"condition": ["default"],
"contents": [
"Programming is the art of instructing computers to perform tasks."
],
"subChapters": []
}
],
"h3": false,
"jsonMetadata": {},
"publishStatus": 0
}
19 changes: 19 additions & 0 deletions e2e/mocks/data/media.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"id": "mock-media-001",
"playlistId": "mock-playlist-001",
"name": "Lecture 1 - Introduction",
"sourceType": 0,
"ready": true,
"duration": 3600,
"video": { "video1Path": "/mock-video.mp4" },
"transcriptions": [
{
"id": "mock-trans-001",
"language": "en-US",
"label": "English",
"sourceLabel": "Auto-generated",
"transcriptionType": 0
}
],
"jsonMetadata": { "images": [] }
}
35 changes: 35 additions & 0 deletions e2e/mocks/data/offerings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[
{
"offering": {
"id": "mock-offering-001",
"courseName": "Introduction to Computer Science",
"description": "An introductory CS course.",
"accessType": 0,
"sectionName": "Section A",
"termId": "mock-term-001",
"jsonMetadata": {}
},
"courses": [
{
"id": "mock-course-001",
"departmentId": "mock-dept-001",
"departmentAcronym": "CS",
"acronym": "CS",
"courseNumber": "101"
}
],
"term": {
"id": "mock-term-001",
"name": "Fall 2024",
"universityId": "mock-uni-001"
},
"instructorIds": [
{
"id": "mock-user-001",
"email": "testuser@classtranscribe.com",
"firstName": "Test",
"lastName": "User"
}
]
}
]
28 changes: 28 additions & 0 deletions e2e/mocks/data/playlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"id": "mock-playlist-001",
"offeringId": "mock-offering-001",
"name": "Week 1 Lectures",
"description": "",
"playlistIndex": 0,
"medias": [
{
"id": "mock-media-001",
"playlistId": "mock-playlist-001",
"name": "Lecture 1 - Introduction",
"sourceType": 0,
"ready": true,
"duration": 3600,
"video": { "video1Path": "/mock-video.mp4" },
"transcriptions": [
{
"id": "mock-trans-001",
"language": "en-US",
"label": "English",
"sourceLabel": "Auto-generated",
"transcriptionType": 0
}
],
"jsonMetadata": {}
}
]
}
19 changes: 19 additions & 0 deletions e2e/mocks/data/playlists-by-offering.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[
{
"id": "mock-playlist-001",
"offeringId": "mock-offering-001",
"name": "Week 1 Lectures",
"description": "",
"playlistIndex": 0,
"medias": [
{
"id": "mock-media-001",
"playlistId": "mock-playlist-001",
"name": "Lecture 1 - Introduction",
"sourceType": 0,
"ready": true,
"duration": 3600
}
]
}
]
Loading
Loading