Skip to content
Merged
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
90 changes: 90 additions & 0 deletions .github/scripts/test_scripts.py
Original file line number Diff line number Diff line change
@@ -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>'), "&quot;&gt;&lt;b&gt;")


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()
49 changes: 49 additions & 0 deletions .github/workflows/web-ci.yml
Original file line number Diff line number Diff line change
@@ -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'
5 changes: 3 additions & 2 deletions web/app/error.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -33,12 +34,12 @@ export default function Error({
>
Try again
</button>
<a
<Link
href="/"
className="rounded-full border border-zinc-700 px-5 py-2.5 text-sm font-medium text-zinc-200 transition hover:border-zinc-500"
>
Back to HackHQ
</a>
</Link>
</div>
</main>
);
Expand Down
3 changes: 2 additions & 1 deletion web/components/hq/deck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ function SaveHeart({ h, dark }: { h: Hackathon; dark?: boolean }) {
<button
onClick={(e) => {
e.stopPropagation();
tracked ? remove(h.id) : save(h.id);
if (tracked) remove(h.id);
else save(h.id);
}}
aria-label={tracked ? "Remove from tracker" : "Save to tracker"}
title={tracked ? "Remove from My HackHQ" : "Save to My HackHQ"}
Expand Down
3 changes: 3 additions & 0 deletions web/components/hq/preloader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export function Preloader() {

useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
// Reduced-motion users skip the curtain. Deliberate post-mount decision
// (matchMedia is unavailable during SSR, so this can't be a lazy init).
// eslint-disable-next-line react-hooks/set-state-in-effect
setGone(true);
return;
}
Expand Down
6 changes: 5 additions & 1 deletion web/components/hq/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export function HQProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
try {
const raw = localStorage.getItem(LS_KEY);
// Deliberate post-mount hydration: localStorage is unavailable during SSR,
// and doing this in a lazy initializer would cause a hydration mismatch.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (raw) setTracked(JSON.parse(raw));
} catch {
/* first visit / corrupted - start fresh */
Expand Down Expand Up @@ -71,7 +74,8 @@ export function HQProvider({ children }: { children: React.ReactNode }) {
const remove = useCallback(
(id: string) =>
setTracked((t) => {
const { [id]: _, ...rest } = t;
const rest = { ...t };
delete rest[id];
return rest;
}),
[],
Expand Down
2 changes: 2 additions & 0 deletions web/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// Vendored third-party Framer components (minified, not ours to lint).
"components/vendor/**",
]),
]);

Expand Down
84 changes: 84 additions & 0 deletions web/lib/listings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import {
parsePrizeValue,
deriveState,
splitTitle,
themesFor,
} from "./listings";

// Minimal raw listing (deriveState only reads state/active).
const raw = (over: { state?: string; active?: boolean }) => ({
id: "1",
company_name: "X",
title: "T",
url: "https://x.com",
...over,
});

describe("parsePrizeValue", () => {
it("parses K / M suffixes and comma amounts", () => {
expect(parsePrizeValue("$50K in prizes")).toBe(50_000);
expect(parsePrizeValue("$2M")).toBe(2_000_000);
expect(parsePrizeValue("$1,000,000")).toBe(1_000_000);
expect(parsePrizeValue("$500")).toBe(500);
});

it("returns 0 for missing or unparseable input", () => {
expect(parsePrizeValue(undefined)).toBe(0);
expect(parsePrizeValue("")).toBe(0);
expect(parsePrizeValue("Swag + prizes")).toBe(0);
expect(parsePrizeValue("TBA")).toBe(0);
});
});

describe("deriveState", () => {
it("honors explicit opens_soon / closed / inactive", () => {
expect(deriveState(raw({ state: "opens_soon" }), null)).toBe("opens_soon");
expect(deriveState(raw({ state: "closed" }), 5)).toBe("closed");
expect(deriveState(raw({ active: false }), 5)).toBe("closed");
});

it("derives status from daysLeft", () => {
expect(deriveState(raw({}), -1)).toBe("closed");
expect(deriveState(raw({}), 0)).toBe("closing_soon");
expect(deriveState(raw({}), 7)).toBe("closing_soon");
expect(deriveState(raw({}), 30)).toBe("open");
expect(deriveState(raw({}), null)).toBe("open");
});
});

describe("splitTitle", () => {
it("splits a trailing parenthetical when the base is long enough", () => {
expect(splitTitle("HackMIT 2026 (Weekend Hackathon)")).toEqual({
title: "HackMIT 2026",
tagline: "Weekend Hackathon",
});
});

it("leaves a short base intact", () => {
expect(splitTitle("AI (x)")).toEqual({ title: "AI (x)", tagline: null });
});

it("returns null tagline when there is no parenthetical", () => {
expect(splitTitle("TreeHacks")).toEqual({
title: "TreeHacks",
tagline: null,
});
});
});

describe("themesFor", () => {
it("tags matching themes", () => {
expect(themesFor("AI agent hackathon")).toContain("AI");
expect(themesFor("a fintech trading challenge")).toContain("FINTECH");
});

it("caps at three themes", () => {
const many = themesFor("AI blockchain health climate fintech gaming");
expect(many.length).toBeLessThanOrEqual(3);
});

it("returns [] when nothing matches", () => {
expect(themesFor("generic build weekend")).toEqual([]);
});
});
8 changes: 4 additions & 4 deletions web/lib/listings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const THEME_RULES: [RegExp, string][] = [
[/high.?school/i, "HIGH SCHOOL"],
];

function parsePrizeValue(prize: string | undefined): number {
export function parsePrizeValue(prize: string | undefined): number {
if (!prize) return 0;
const m = prize.replace(/,/g, "").match(/\$\s*([\d.]+)\s*([kKmM])?/);
if (!m) return 0;
Expand All @@ -78,7 +78,7 @@ function parsePrizeValue(prize: string | undefined): number {
return n * mult;
}

function deriveState(raw: RawListing, daysLeft: number | null): HackState {
export function deriveState(raw: RawListing, daysLeft: number | null): HackState {
if (raw.state === "opens_soon") return "opens_soon";
if (raw.state === "closed" || raw.active === false) return "closed";
if (daysLeft !== null) {
Expand All @@ -93,13 +93,13 @@ function noEmDash(s: string): string {
return s.replace(/\s*—\s*/g, " - ").trim();
}

function splitTitle(title: string): { title: string; tagline: string | null } {
export function splitTitle(title: string): { title: string; tagline: string | null } {
const m = title.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
if (m && m[1].length >= 6) return { title: m[1].trim(), tagline: m[2].trim() };
return { title: title.trim(), tagline: null };
}

function themesFor(text: string): string[] {
export function themesFor(text: string): string[] {
const out: string[] = [];
for (const [re, tag] of THEME_RULES) {
if (re.test(text) && !out.includes(tag)) out.push(tag);
Expand Down
42 changes: 42 additions & 0 deletions web/lib/parse-readme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { resolveAssetSrc, parseOpportunities } from "./parse-readme";

describe("resolveAssetSrc", () => {
it("passes absolute URLs through unchanged", () => {
expect(resolveAssetSrc("https://x.com/a.png")).toBe("https://x.com/a.png");
expect(resolveAssetSrc("http://x.com/a.png")).toBe("http://x.com/a.png");
});

it("returns '' for empty input", () => {
expect(resolveAssetSrc("")).toBe("");
});

it("falls back to raw.githubusercontent for unknown local assets", () => {
const out = resolveAssetSrc("assets/does-not-exist-xyz.png");
expect(out).toContain("raw.githubusercontent.com");
expect(out).toContain("assets/does-not-exist-xyz.png");
});
});

describe("parseOpportunities", () => {
const md = [
"<!-- HACKATHONS_TABLE_START -->",
"| Status | Host | Hackathon | Format | Location | Prize | Deadline | Application | Date Posted |",
"| ------ | ---- | --------- | ------ | -------- | ----- | -------- | ----------- | ----------- |",
'| ✅ **[OPEN]** | MIT | HackMIT 2026 | In-Person | Cambridge, MA | $20K | Jul 04, 2026 | <a href="https://hackmit.org/">Register</a> | Jun 27, 2026 |',
"<!-- HACKATHONS_TABLE_END -->",
].join("\n");

it("parses a well-formed hackathons table", () => {
const ops = parseOpportunities(md);
expect(ops).toHaveLength(1);
expect(ops[0].organization).toBe("MIT");
expect(ops[0].title).toBe("HackMIT 2026");
expect(ops[0].status).toBe("OPEN");
expect(ops[0].url).toBe("https://hackmit.org/");
});

it("returns [] when there is no table", () => {
expect(parseOpportunities("just some prose, no table")).toEqual([]);
});
});
4 changes: 2 additions & 2 deletions web/lib/parse-readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ function stableId(...parts: string[]): string {
return crypto.createHash("md5").update(parts.join("|")).digest("hex").slice(0, 10);
}

function resolveAssetSrc(src: string): string {
export function resolveAssetSrc(src: string): string {
if (!src) return "";
if (src.startsWith("http://") || src.startsWith("https://")) return src;

Expand Down Expand Up @@ -242,7 +242,7 @@ export function loadOpportunities(): Opportunity[] {
return md ? parseOpportunities(md) : [];
}

function parseOpportunities(markdown: string): Opportunity[] {
export function parseOpportunities(markdown: string): Opportunity[] {
const today = new Date();
today.setHours(0, 0, 0, 0);

Expand Down
Loading
Loading