diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c4f7b28..7095250 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/README.md b/README.md index 5135bb0..c27b09e 100644 --- a/README.md +++ b/README.md @@ -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 . 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 +, and the Express API server is accessible via +. 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 . (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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d79f384 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,55 @@ + + + + + + + Transcript service + + +

Transcript service

+
+ +
+ +
+
+

Add new student

+
+ + +
+ +
+ +
+
+

Add grade for existing student

+
+ + +
+ + +
+ + +
+ +
+ +
+
+

View transcript

+
+ + +
+ +
+ +
+
+ + + diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..52f2eff Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..b21f088 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: / diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..9074ed6 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,56 @@ +import { addGrade, addStudent, getTranscript } from "./service.ts"; +import "./style.css"; + +const showNewStudentDiv = document.querySelector("#showNewStudent")!; +document.querySelector("#addStudent")!.onsubmit = (ev) => { + ev.preventDefault(); + const password = document.querySelector("#password")!.value.trim(); + const studentName = document.querySelector("#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("#showAddGrade")!; +document.querySelector("#addGrade")!.onsubmit = (ev) => { + ev.preventDefault(); + const password = document.querySelector("#password")!.value.trim(); + const studentID = document.querySelector("#studentIdForAddGrade")!.value.trim(); + const courseName = document.querySelector("#addGradeCourse")!.value.trim(); + const courseGrade = document.querySelector("#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("#showTranscript")!; +document.querySelector("#viewTranscript")!.onsubmit = (ev) => { + ev.preventDefault(); + const password = document.querySelector("#password")!.value.trim(); + const studentID = document.querySelector("#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}`)); +}; diff --git a/frontend/src/service.ts b/frontend/src/service.ts new file mode 100644 index 0000000..822a736 --- /dev/null +++ b/frontend/src/service.ts @@ -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> { + 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> { + 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> { + 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; +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..49cdd05 --- /dev/null +++ b/frontend/src/style.css @@ -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; +} diff --git a/frontend/tests/e2e/transcript.e2e.test.ts b/frontend/tests/e2e/transcript.e2e.test.ts new file mode 100644 index 0000000..5c0a856 --- /dev/null +++ b/frontend/tests/e2e/transcript.e2e.test.ts @@ -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); + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..aecd98a --- /dev/null +++ b/frontend/tsconfig.json @@ -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": ["."] +} diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs new file mode 100644 index 0000000..aa3aa7f --- /dev/null +++ b/frontend/vite.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + server: { + proxy: { "/api": `http://localhost:3000` }, + }, +}); diff --git a/package-lock.json b/package-lock.json index aab4131..246761a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,22 @@ { - "name": "cs4530-express", + "name": "cs4530-vite", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cs4530-express", + "name": "cs4530-vite", "version": "1.0.0", "dependencies": { "express": "^5.2.1", "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.58.1", "@types/express": "^5.0.6", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.0.18", + "concurrently": "^9.2.1", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -22,10 +24,13 @@ "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.0", + "npm-run-all2": "^8.0.4", + "playwright": "^1.58.1", "prettier": "^3.7.4", "supertest": "^7.2.2", "typescript": "^6.0.2", "typescript-eslint": "^8.57.3", + "vite": "^8.0.0", "vitest": "^4.0.17" } }, @@ -640,6 +645,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.16", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", @@ -1912,6 +1933,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2324,6 +2355,34 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2374,6 +2433,31 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2637,6 +2721,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -3506,9 +3597,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3580,6 +3671,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4106,6 +4207,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -4372,6 +4483,19 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -4426,6 +4550,16 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4840,6 +4974,15 @@ "node": ">= 0.8" } }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -5006,6 +5149,82 @@ "dev": true, "license": "MIT" }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-all2": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", + "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "cross-spawn": "^7.0.6", + "memorystream": "^0.3.1", + "picomatch": "^4.0.2", + "pidtree": "^0.6.0", + "read-package-json-fast": "^4.0.0", + "shell-quote": "^1.7.3", + "which": "^5.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "npm-run-all2": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": "^20.5.0 || >=22.0.0", + "npm": ">= 10" + } + }, + "node_modules/npm-run-all2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm-run-all2/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-all2/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5297,7 +5516,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5305,6 +5523,51 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5446,6 +5709,20 @@ "node": ">= 0.10" } }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", + "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5490,6 +5767,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -5584,6 +5871,16 @@ "node": ">= 18" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -5778,6 +6075,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -5914,6 +6224,21 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -5973,6 +6298,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6033,16 +6371,19 @@ } }, "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -6127,6 +6468,16 @@ "node": ">=0.6" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6171,8 +6522,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -6445,7 +6795,6 @@ "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6518,6 +6867,21 @@ } } }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vitest": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", @@ -6741,12 +7105,40 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6754,6 +7146,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7a0c824..960a903 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,33 @@ { - "name": "cs4530-express", + "name": "cs4530-vite", "version": "1.0.0", "description": "CS4530 Template", "type": "module", "scripts": { - "check": "tsc", + "check": "npm-run-all check:*", + "check:frontend": "tsc -p frontend/tsconfig.json", + "check:server": "tsc", "lint": "eslint .", "lint:fix": "eslint . --fix", "prettier": "prettier --check .", "prettier:fix": "prettier --write .", - "test": "vitest run --coverage", - "dev": "node --watch ./src/server.ts", - "build": "echo 'nothing to do to build, run `npm start`'", - "start": "MODE=production node ./src/server.ts" + "test": "npm-run-all test:*", + "test:frontend": "playwright test", + "test:server": "vitest run --coverage", + "dev": "concurrently -n frontend,server -c blue,green \"npm run dev:frontend\" \"npm run dev:server\"", + "dev:frontend": "vite frontend", + "dev:server": "node --watch ./src/server.ts", + "build": "tsc -p frontend/tsconfig.json --noEmit && vite build frontend", + "start": "MODE=production node ./src/server.ts", + "playwright": "playwright test --ui" }, "author": "Rob Simmons", "devDependencies": { + "@playwright/test": "^1.58.1", "@types/express": "^5.0.6", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.0.18", + "concurrently": "^9.2.1", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -26,10 +35,13 @@ "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.0", + "npm-run-all2": "^8.0.4", + "playwright": "^1.58.1", "prettier": "^3.7.4", "supertest": "^7.2.2", "typescript": "^6.0.2", "typescript-eslint": "^8.57.3", + "vite": "^8.0.0", "vitest": "^4.0.17" }, "dependencies": { diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..fc1d6bf --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,45 @@ +import { defineConfig, devices } from "@playwright/test"; + +/* global process */ // TODO: is there a better way to avoid making ESLint angry? +export default defineConfig({ + // Where the tests live, relative to this file + testDir: "./frontend/tests/e2e", + + // Fail the build on CI if you accidentally left test.only in the source code. + forbidOnly: !!process.env.CI, + + // The HTML reporter gives nice, pretty reports + reporter: process.env.CI ? "dot" : [["html", { outputFolder: "playwright-report" }]], + + // No parallelism (slower, but can avoid errors with overlapping tests) + workers: 1, + + // Settings that we'd rather set once, rather than in every test file + use: { + baseURL: "http://localhost:5173", + }, + + // Just test with chrome + projects: [{ name: "chromium", use: devices["Desktop Chrome"] }], + + // This sets up the two-server development environment that we recommend, + // the Vite frontend server that the tests will connect to, and the Express + // server that serves API requests. The `reuseExistingServer` option means + // that, if you already have your development environment running, tests + // will just operate on that running server instead of starting a new + // server. + webServer: [ + { + name: "Frontend", + command: "npm run dev:frontend", + reuseExistingServer: !process.env.CI, + url: "http://localhost:5173", + }, + { + name: "Server", + command: "npm run dev:server", + reuseExistingServer: !process.env.CI, + url: "http://localhost:3000", + }, + ], +}); diff --git a/src/server.ts b/src/server.ts index 51d45a5..6de0f7b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,36 @@ /* eslint no-console: "off" */ import { app } from "./app.ts"; +import * as path from "node:path"; +import express from "express"; +// This if-then-else check for MODE=production helps avoid a common source of +// pain: +// +// 1. You build the website (`npm run build`) and test it in production mode +// 2. You want to update the frontend, so you start the Vite development +// server and edit code +// 3. You don't realize you have the *Express* server open in your browser, +// serving stale files created during the build command in step #1. +// You can't get any frontend changes to show up in the browser, no +// matter what you do. +if (process.env.MODE === "production") { + // In production mode, we want to serve the frontend code from Express + app.use(express.static(path.join(import.meta.dirname, "../frontend/dist"))); + app.get(/(.*)/, (req, res) => + res.sendFile(path.join(import.meta.dirname, "../frontend/dist/index.html")), + ); +} else { + app.get("/", (req, res) => { + res.send( + "You are connecting directly to the API server in development mode! " + + "You probably want to look elsewhere for the Vite frontend.", + ); + res.end(); + }); +} + +// Actually start the server const PORT = parseInt(process.env.PORT || "3000"); app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..0aaff1a --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/node_modules/**", "**/.git/**", "./{client,frontend}/**"], + }, +});