Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
04193ac
In progress, working version
robsimmons Jan 23, 2026
def83d2
Fix PORT
robsimmons Jan 23, 2026
e548425
Merge branch 'main' into main
robsimmons Jan 23, 2026
cd986e6
Merge pull request #1 from neu-se/main
robsimmons Jan 23, 2026
d9618ff
Merge branch 'neu-se:main' into main
robsimmons Jan 23, 2026
3fe0090
more readme
robsimmons Jan 23, 2026
65e9b4d
project title
robsimmons Jan 23, 2026
607478c
Merge branch 'neu-se:main' into main
robsimmons Jan 24, 2026
d882150
working demo frontend
robsimmons Jan 25, 2026
11de5e2
Merge branch 'neu-se:main' into main
robsimmons Jan 25, 2026
391de1c
Merge remote-tracking branch 'upstream/main'
robsimmons Jan 25, 2026
75e79ba
Merge branch 'neu-se:main' into main
robsimmons Jan 25, 2026
e8b3bbc
Merge branch 'neu-se:main' into main
robsimmons Jan 25, 2026
978e6a6
more and better comments
robsimmons Jan 25, 2026
968154c
Merge branch 'neu-se:main' into main
robsimmons Jan 25, 2026
4b47b3c
Run new prettier config
robsimmons Jan 25, 2026
92df4e0
Merge
robsimmons Jan 25, 2026
46d9c44
Merge branch 'neu-se:main' into main
robsimmons Jan 25, 2026
6954200
actually add vite as a dev dependency... turns out not to be required…
robsimmons Jan 25, 2026
dfe9c52
Align tsconfig.json
robsimmons Jan 25, 2026
48a9d8d
add type for favicon
robsimmons Jan 25, 2026
67a1ecc
Change service layer to throw exceptions; manage with less async
robsimmons Jan 26, 2026
3177f18
Merge remote-tracking branch 'upstream/main'
robsimmons Feb 5, 2026
c36a2c5
prettier
robsimmons Feb 5, 2026
1af3822
Merge branch 'neu-se:main' into main
robsimmons Feb 5, 2026
d5c3e1e
Simplify vitest setup to default options
robsimmons Feb 5, 2026
3f49b53
Update eslint-plugin-react-refresh
robsimmons Feb 5, 2026
706eb3b
Merge remote-tracking branch 'upstream/main'
robsimmons Feb 5, 2026
7ac260f
Small edits to README
robsimmons Feb 5, 2026
0d78349
Add playwright tests
robsimmons Feb 5, 2026
92eb74c
Prettier & playwright
robsimmons Feb 5, 2026
50cb56a
Speed up dependency install?
robsimmons Feb 5, 2026
e6346df
Speed up dependency install?
robsimmons Feb 5, 2026
117050c
Ci???
robsimmons Feb 5, 2026
dd4fdb3
Needs playwright actually
robsimmons Feb 5, 2026
53bf71e
Juggle directories
robsimmons Feb 5, 2026
9033d8e
Tweak CI
robsimmons Feb 5, 2026
1c183b6
Tweak test names
robsimmons Feb 5, 2026
7ac996c
Tweak tests for upstream
robsimmons Feb 5, 2026
737aa98
generalize exclusions
robsimmons Feb 5, 2026
1ff54a3
Merge remote-tracking branch 'upstream/main'
robsimmons Feb 5, 2026
2fd063d
Merge remote-tracking branch 'upstream/main'
robsimmons Feb 7, 2026
3713447
use concurrently package to run dev servers at the same time
robsimmons Mar 14, 2026
5a38e03
Merge remote-tracking branch 'upstream/main'
robsimmons Mar 14, 2026
c97d1ed
Vite 8
robsimmons Mar 14, 2026
d2d7b5c
Merge remote-tracking branch 'upstream/main'
robsimmons Mar 14, 2026
ab709e7
Merge remote-tracking branch 'upstream/main'
robsimmons Mar 30, 2026
78c235d
Remove now-default options from tsconfig
robsimmons Mar 30, 2026
f6036b5
Merge remote-tracking branch 'upstream/main'
robsimmons Mar 30, 2026
529fe65
Merge remote-tracking branch 'upstream/main'
robsimmons Mar 30, 2026
cffc14a
formatting and package-lock
robsimmons Mar 30, 2026
f397ce4
match package-lock
robsimmons Mar 30, 2026
0acef98
Merge remote-tracking branch 'upstream'
robsimmons Apr 23, 2026
e72bb0a
update dependencies
robsimmons Apr 23, 2026
71dfa07
rebuild package-lock
robsimmons Apr 23, 2026
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
10 changes: 8 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@ jobs:
- name: Linting
run: npm run lint

- name: Tests
run: npm run test
- name: Run server tests
run: npm run test:server

- name: Install Playwright dependencies
run: npx playwright install --with-deps chromium

- name: Run Playwright end-to-end tests
run: npm run test:frontend
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,59 @@

This is a template project for CS4530, Software Engineering at Northeastern.

## Express Configuration

The functional content of this project is a minimal Express transcript API for
a very simple transcript server.
## Vite+Express Full-stack Application

This project has two parts:

1. A minimal Express transcript API for a very simple transcript server
2. A Vite frontend with code that calls that server (this lives in the
`./frontend` directory)

The way this project runs in "production mode" versus "development mode" is
very different.

### Production Mode

Production mode is simpler: there's one server running, the Express server, on
port 3000, accessible via the url <http://localhost:3000>. When a GET request
doesn't match any existing API endpoints, the Express server looks in
`./frontend/dist` to see if there's a file it can serve from that directory.
Files are put in that directory when `npm run build` calls the `vite build`
command.

The `vite build` step is necessary because we're writing our frontend code in
TypeScript, but browsers can't do type stripping like Node can — we have to do
some transformation on the code we're writing to make it browser-friendly.
(Vite is doing a bunch of other transformations for other reasons as well.)

### Development Mode

Development mode is a little trickier to explain. When developing, we want our
browser to be connecting to Vite's "development web server", not to Express,
because Vite does a lot of nifty stuff to make sure that when we change our
TypeScript code, it **reloads the web page**. That is _very_ handy for
frontend web development.

However, this means your "frontend code" — the HTML and JS that the browser is
supposed to run being served by the Vite development web server — is coming
from a different server than the Express server running in React. The default
convention is that Vite development web server is accessed via
<http://localhost:5173>, and the Express API server is accessible via
<http://localhost:3000>. If you try to have a website that is being served
from a different website than the API service it is using, you're going to
have to gain a nightmarish amount of literacy with
[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS). (A
different port on `localhost` counts as a different website.) This wasn't a
problem in production mode: your entire website is coming from the Express
server. You really want your website look like it's _all_ coming from a single
server during development too.

The easy way to do this is to have the development server _only_ respond to
API requests, and have the Vite development server forwards all API requests
to the Express server. This is called "proxying", and it means that you can
access a complete Vite server from <http://localhost:5173>. (The Vite
development server needs to know what an API request is: it's configured to
treat every route starting with `/api` as an API endpoint.)

### Express API

Expand Down
55 changes: 55 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transcript service</title>
</head>
<body>
<h1>Transcript service</h1>
<div>
<label for="password">Enter credentials:</label>
<br />
<input id="password" />
</div>
<section>
<h2>Add new student</h2>
<form id="addStudent">
<label for="studentName">Enter new student's name:</label>
<input id="studentName" />
<br />
<button>Create new student transcript</button>
</form>
<div class="feedback" id="showNewStudent"></div>
</section>
<section>
<h2>Add grade for existing student</h2>
<form id="addGrade">
<label for="studentIdForAddGrade">Student ID:</label>
<input id="studentIdForAddGrade" />
<br />
<label for="addGradeCourse">Course name:</label>
<input id="addGradeCourse" />
<br />
<label for="addGradeGrade">Course grade:</label>
<input id="addGradeGrade" />
<br />
<button>Add grade</button>
</form>
<div class="feedback" id="showAddGrade"></div>
</section>
<section>
<h2>View transcript</h2>
<form id="viewTranscript">
<label for="idToView">Enter student id to view:</label>
<input id="idToView" />
<br />
<button>View</button>
</form>
<div class="feedback" id="showTranscript"></div>
</section>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Binary file added frontend/public/favicon.ico
Binary file not shown.
3 changes: 3 additions & 0 deletions frontend/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /
56 changes: 56 additions & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { addGrade, addStudent, getTranscript } from "./service.ts";
import "./style.css";

const showNewStudentDiv = document.querySelector<HTMLDivElement>("#showNewStudent")!;
document.querySelector<HTMLFormElement>("#addStudent")!.onsubmit = (ev) => {
ev.preventDefault();
const password = document.querySelector<HTMLInputElement>("#password")!.value.trim();
const studentName = document.querySelector<HTMLInputElement>("#studentName")!.value.trim();
addStudent(password, studentName)
.then(({ studentID }) => {
showNewStudentDiv.innerText = `Record created for student '${studentName}' with ID ${studentID}`;
})
.catch((err) => {
showNewStudentDiv.innerText = `${err}`;
});
};

const showAddGradeDiv = document.querySelector<HTMLDivElement>("#showAddGrade")!;
document.querySelector<HTMLFormElement>("#addGrade")!.onsubmit = (ev) => {
ev.preventDefault();
const password = document.querySelector<HTMLInputElement>("#password")!.value.trim();
const studentID = document.querySelector<HTMLInputElement>("#studentIdForAddGrade")!.value.trim();
const courseName = document.querySelector<HTMLInputElement>("#addGradeCourse")!.value.trim();
const courseGrade = document.querySelector<HTMLInputElement>("#addGradeGrade")!.value.trim();
addGrade(password, studentID, courseName, courseGrade)
.then(() => {
showAddGradeDiv.innerText = `Added grade of ${courseGrade} in ${courseName} successfully!`;
})
.catch((err) => {
showAddGradeDiv.innerText = `${err}`;
});
};

const showGetTranscriptDiv = document.querySelector<HTMLDivElement>("#showTranscript")!;
document.querySelector<HTMLFormElement>("#viewTranscript")!.onsubmit = (ev) => {
ev.preventDefault();
const password = document.querySelector<HTMLInputElement>("#password")!.value.trim();
const studentID = document.querySelector<HTMLInputElement>("#idToView")!.value.trim();
getTranscript(password, studentID)
.then((result) => {
if (!result.success) {
showGetTranscriptDiv.innerText = `No student exists with id ${studentID}`;
} else {
const { student, grades } = result.transcript;
showGetTranscriptDiv.innerText = `Transcript for student ${student.studentName} (id ${student.studentID})`;
const list = document.createElement("ul");
showGetTranscriptDiv.append(list);
for (const record of grades) {
const item = document.createElement("li");
item.innerText = `${record.grade} in ${record.course}`;
list.append(item);
}
}
})
.catch((err) => (showGetTranscriptDiv.innerText = `${err}`));
};
129 changes: 129 additions & 0 deletions frontend/src/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { z } from "zod";

export class ServiceError extends Error {
constructor(message: string) {
super(message);
}
}

const zError = z.object({ error: z.string() });

const zAddStudentResponse = z.object({ studentID: z.int() });
/**
* Validate inputs and call the `addStudent` api
*
* @param password - credentials
* @param studentName - a student name (error if empty)
* @returns successful API response
* @throws if validation fails or there is an API response error
*/
export async function addStudent(
password: string,
studentName: string,
): Promise<z.infer<typeof zAddStudentResponse>> {
if (studentName === "") throw new ServiceError("Student name must be non-empty");

const response = await fetch("/api/addStudent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
password,
studentName,
}),
});
const data = z.union([zError, zAddStudentResponse]).parse(await response.json());
if ("error" in data) throw new ServiceError(data.error);
return data;
}

const zAddGradeResponse = z.object({ success: z.literal(true) });
/**
* Validate inputs and call the `addGrade` api
*
* @param password - credentials
* @param studentIDStr - student ID (error if not a positive integer)
* @param courseName - student name
* @param courseGradeStr - course grade (error if not a number between 0 and 100, inclusive)
* @returns successful API response
* @throws if validation fails or there is an API response error
*/
export async function addGrade(
password: string,
studentIDStr: string,
courseName: string,
courseGradeStr: string,
): Promise<z.infer<typeof zAddGradeResponse>> {
const studentID = parseInt(studentIDStr);
if (isNaN(studentID) || `${studentID}` !== studentIDStr || studentID < 0) {
throw new ServiceError("Student ID is invalid");
}

const courseGrade = parseFloat(courseGradeStr);
if (
isNaN(courseGrade) ||
`${courseGrade}` !== courseGradeStr ||
courseGrade < 0 ||
courseGrade > 100
) {
throw new ServiceError("Course grade is not valid");
}

if (courseName === "") {
throw new ServiceError("Course name is required");
}

const response = await fetch("/api/addGrade", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
password,
studentID,
courseName,
courseGrade,
}),
});
const data = z.union([zError, zAddGradeResponse]).parse(await response.json());
if ("error" in data) throw new ServiceError(data.error);
return data;
}

const zGetTranscriptResponse = z.union([
z.object({ success: z.literal(false) }),
z.object({
success: z.literal(true),
transcript: z.object({
student: z.object({ studentID: z.int(), studentName: z.string() }),
grades: z.array(z.object({ course: z.string(), grade: z.number() })),
}),
}),
]);

/**
* Validate inputs and call the `getTranscript` API
*
* @param password - credentials
* @param studentIDStr - student ID (error if not a positive integer)
* @returns successful API response
* @throws if validation fails or there is an API response error
*/
export async function getTranscript(
password: string,
studentIDStr: string,
): Promise<z.infer<typeof zGetTranscriptResponse>> {
const studentID = parseInt(studentIDStr);
if (isNaN(studentID) || `${studentID}` !== studentIDStr || studentID < 0) {
throw new ServiceError("Student ID is invalid");
}

const response = await fetch("/api/getTranscript", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
password,
studentID,
}),
});
const data = z.union([zError, zGetTranscriptResponse]).parse(await response.json());
if ("error" in data) throw new ServiceError(data.error);
return data;
}
11 changes: 11 additions & 0 deletions frontend/src/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
section {
border-top: 1px black solid;
margin-top: 1em;
}

.feedback {
color: darkcyan;
margin-left: 2em;
font-family: monospace;
margin-top: 1em;
}
34 changes: 34 additions & 0 deletions frontend/tests/e2e/transcript.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from "@playwright/test";

test.describe("The add student functionality", () => {
test("should appear", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("Enter new student's name:")).toBeVisible();
});

test("should require some auth", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Enter new student's name:").focus();
await page.keyboard.type("Hank");
await page.keyboard.press("Enter");
await expect(page.getByText("Error: Invalid credentials")).toHaveCount(1);
});

test("should require valid auth", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Enter credentials").fill("not the password");
await page.getByLabel("Enter new student's name:").focus();
await page.keyboard.type("Hank");
await page.keyboard.press("Enter");
await expect(page.getByText("Error: Invalid credentials")).toHaveCount(1);
});

test("should work with valid auth", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Enter credentials").fill("password");
await page.getByLabel("Enter new student's name:").focus();
await page.keyboard.type("Hank");
await page.keyboard.press("Enter");
await expect(page.getByText("Record created for student 'Hank' with ID ")).toHaveCount(1);
});
});
22 changes: 22 additions & 0 deletions frontend/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
/* Basic configuration */
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ES2022",
"moduleResolution": "bundler",
"types": ["vite/client"],
"skipLibCheck": true,
"noEmit": true,

/* Consistency with Node type stripping style */
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,

/* Linting */
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true
},
"include": ["."]
}
Loading