From 8ae4af8aeb9db9515d0b4f41f5310d1760fb9df5 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Tue, 7 Jul 2026 09:06:44 -0400 Subject: [PATCH 1/3] ci: web CI workflow, lint fixes, unit tests (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #48. CI: - Add .github/workflows/web-ci.yml: on PR + push(main) for web/**, .github/scripts/**. Job `web` runs npm ci, lint, vitest, next build, and tsc --noEmit (after build so generated next-env.d.ts / .next/types exist). Job `scripts` runs the Python unittest suite. Actions pinned to SHAs. Lint (npm run lint now exits 0): - Fix the two set-state-in-effect errors (preloader reduced-motion, store localStorage hydration) with justified eslint-disable — both are intentional post-mount syncs where a lazy initializer would cause a hydration mismatch. - deck.tsx: ternary statement -> if/else (no-unused-expressions). - store.tsx: drop the throwaway destructure binding (no-unused-vars). - error.tsx: use instead of (no-html-link-for-pages). - Ignore components/vendor/** (minified third-party Framer code). Tests: - vitest (dev dep) + `test` script. lib/listings.test.ts and lib/parse-readme.test.ts cover parsePrizeValue, deriveState, splitTitle, themesFor, resolveAssetSrc, and table parsing (happy path + malformed). - .github/scripts/test_scripts.py (stdlib unittest): parse_deadline_date, sanitize_field, is_valid_email, escape_attr, parse_issue_body, parse_state, parse_deadline, and the SSRF guard. - Exported the tested lib functions for direct import. Verified: lint exit 0; 15 JS tests + 13 Python tests pass; next build + tsc --noEmit succeed; workflow YAML valid; production audit (--omit=dev) clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/test_scripts.py | 90 +++ .github/workflows/web-ci.yml | 49 ++ web/app/error.tsx | 5 +- web/components/hq/deck.tsx | 3 +- web/components/hq/preloader.tsx | 3 + web/components/hq/store.tsx | 6 +- web/eslint.config.mjs | 2 + web/lib/listings.test.ts | 84 +++ web/lib/listings.ts | 8 +- web/lib/parse-readme.test.ts | 42 ++ web/lib/parse-readme.ts | 4 +- web/package-lock.json | 975 ++++++++++++++++++++++++++++---- web/package.json | 6 +- 13 files changed, 1157 insertions(+), 120 deletions(-) create mode 100644 .github/scripts/test_scripts.py create mode 100644 .github/workflows/web-ci.yml create mode 100644 web/lib/listings.test.ts create mode 100644 web/lib/parse-readme.test.ts diff --git a/.github/scripts/test_scripts.py b/.github/scripts/test_scripts.py new file mode 100644 index 0000000..976b767 --- /dev/null +++ b/.github/scripts/test_scripts.py @@ -0,0 +1,90 @@ +"""Unit tests for the hackathon automation scripts. + +Run with: python -m unittest discover -s .github/scripts -p 'test_*.py' +Covers the core parsing/validation helpers with happy-path + malformed input. +""" + +import unittest +from datetime import date + +import util +import contribution_approved as ca +import auto_extract as ax + + +class ParseDeadlineDate(unittest.TestCase): + def test_iso_and_us_formats(self): + self.assertEqual(util.parse_deadline_date("2026-07-04"), date(2026, 7, 4)) + self.assertEqual(util.parse_deadline_date("07/04/2026"), date(2026, 7, 4)) + + def test_invalid_raises(self): + with self.assertRaises(ValueError): + util.parse_deadline_date("July 4th") + with self.assertRaises(ValueError): + util.parse_deadline_date("2026/07/04") + + +class SanitizeField(unittest.TestCase): + def test_strips_control_chars_and_collapses_ws(self): + self.assertEqual(util.sanitize_field('X";sh\nY'), 'X";sh Y') + self.assertEqual(util.sanitize_field(" a b "), "a b") + + def test_handles_none_and_truncates(self): + self.assertEqual(util.sanitize_field(None), "") + self.assertEqual(len(util.sanitize_field("a" * 500, max_len=10)), 10) + + +class Email(unittest.TestCase): + def test_valid(self): + self.assertTrue(util.is_valid_email("me@example.com")) + + def test_invalid(self): + self.assertFalse(util.is_valid_email("me@example.com\nx")) + self.assertFalse(util.is_valid_email("not-an-email")) + self.assertFalse(util.is_valid_email("")) + + +class EscapeAttr(unittest.TestCase): + def test_escapes_quotes_and_angles(self): + self.assertEqual(util.escape_attr('">'), ""><b>") + + +class ParseIssueBody(unittest.TestCase): + def test_parses_field_sections(self): + body = ( + "### Hackathon Name\nHackMIT 2026\n\n" + "### Link to Hackathon Page\nhttps://hackmit.org\n" + ) + data = ca.parse_issue_body(body, []) + self.assertEqual(data["hackathon_name"], "HackMIT 2026") + self.assertEqual(data["link_to_hackathon_page"], "https://hackmit.org") + + def test_skips_no_response_placeholder(self): + data = ca.parse_issue_body("### Prize Pool (optional)\n_No response_\n", []) + self.assertFalse(data.get("prize_pool_(optional)")) + + +class ParseStateAndDeadline(unittest.TestCase): + def test_state(self): + self.assertEqual(ca.parse_state({"status": "Opens soon"}), "opens_soon") + self.assertEqual(ca.parse_state({"status": "Open now"}), "open") + self.assertEqual(ca.parse_state({}), "open") + + def test_deadline(self): + self.assertEqual(ca.parse_deadline({"deadline": "2026-07-04"}), "2026-07-04") + self.assertIsNone(ca.parse_deadline({})) + + +class SsrfGuard(unittest.TestCase): + def test_blocks_internal_hosts(self): + for host in ("127.0.0.1", "localhost", "169.254.169.254", "10.0.0.1"): + ok, _ = ax._resolved_ips_are_public(host) + self.assertFalse(ok, f"{host} should be blocked") + + def test_rejects_non_http_scheme(self): + ok, _ = ax._validate_fetch_url("file:///etc/passwd") + self.assertFalse(ok) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml new file mode 100644 index 0000000..bc33e3c --- /dev/null +++ b/.github/workflows/web-ci.yml @@ -0,0 +1,49 @@ +name: Web CI + +on: + push: + branches: [main] + paths: + - 'web/**' + - '.github/scripts/**' + - '.github/workflows/web-ci.yml' + pull_request: + paths: + - 'web/**' + - '.github/scripts/**' + - '.github/workflows/web-ci.yml' + +permissions: + contents: read + +jobs: + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run test + - run: npm run build + # tsc runs after build: next build generates next-env.d.ts and + # .next/types (both gitignored, so absent on a fresh checkout). + - run: npx tsc --noEmit + + scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + - run: pip install -r .github/scripts/requirements.txt + - name: Run Python unit tests + run: python -m unittest discover -s .github/scripts -p 'test_*.py' diff --git a/web/app/error.tsx b/web/app/error.tsx index d75d5e0..face67a 100644 --- a/web/app/error.tsx +++ b/web/app/error.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useEffect } from "react"; // Route-level error boundary: catches thrown errors from the page/segment and @@ -33,12 +34,12 @@ export default function Error({ > Try again - Back to HackHQ - + ); diff --git a/web/components/hq/deck.tsx b/web/components/hq/deck.tsx index cd1fc11..cc6957d 100644 --- a/web/components/hq/deck.tsx +++ b/web/components/hq/deck.tsx @@ -175,7 +175,8 @@ function SaveHeart({ h, dark }: { h: Hackathon; dark?: boolean }) {