diff --git a/e2e/kattis-parser.spec.ts b/e2e/kattis-parser.spec.ts
new file mode 100644
index 0000000..178b637
--- /dev/null
+++ b/e2e/kattis-parser.spec.ts
@@ -0,0 +1,109 @@
+import { test, expect, type Page } from '@playwright/test';
+import { parseKattisProblemPage } from '../src/lib/providers/kattis/ingestion.ts';
+
+// Regression coverage for the real Kattis problem-page HTML parser.
+//
+// parseKattisProblemPage scrapes the live open.kattis.com markup for a
+// problem's display name (the
) and difficulty (.difficulty_number, with a
+// .difficulty fallback). Every other Kattis test mocks parsePage out, so the
+// selector logic that is most likely to break when Kattis changes its markup is
+// otherwise unexercised. These snapshots pin its behavior against captured
+// fixtures so a markup drift or an accidental selector change fails loudly.
+//
+// The parser uses the browser-only DOMParser, so it cannot run in the Node
+// unit-test process. We run the *production* function (serialized via
+// .toString(), not a copy) inside a real browser DOM via page.evaluate. Adding
+// a DOM shim (jsdom/happy-dom) purely for this would introduce a dependency the
+// repo guidelines discourage; the Playwright browser already provides one.
+
+const parserSource = parseKattisProblemPage.toString();
+
+async function parseInBrowser(
+ page: Page,
+ html: string,
+ problemId: string
+): Promise<{ name: string; rating: number }> {
+ return page.evaluate(
+ ([source, htmlArg, idArg]) => {
+ const fn = new Function(`return (${source});`)() as (
+ html: string,
+ problemId: string
+ ) => { name: string; rating: number };
+ return fn(htmlArg, idArg);
+ },
+ [parserSource, html, problemId] as const
+ );
+}
+
+// A realistic slice of the open.kattis.com problem page: the problem title in an
+// and the difficulty rendered in a .difficulty_number badge.
+const FULL_PAGE = `
+
+ Two Stones – Kattis
+
+
+
+`;
+
+// Older/alternate markup that exposes the difficulty via the .difficulty class
+// rather than .difficulty_number.
+const LEGACY_DIFFICULTY_CLASS = `
+
+
+ Legacy Problem
+ 7
+
+`;
+
+// A page with a title but no difficulty element at all.
+const NO_DIFFICULTY = `
+
+
+ Nameless Difficulty
+
+`;
+
+// A page with neither a title nor a difficulty (e.g. an error/interstitial page).
+const NO_TITLE = `
+
+
+
Problem not available
+
+`;
+
+test.describe('parseKattisProblemPage', () => {
+ test('reads the h1 name and .difficulty_number rating', async ({ page }) => {
+ await page.goto('about:blank');
+ const result = await parseInBrowser(page, FULL_PAGE, 'twostones');
+ expect(result).toEqual({ name: 'Two Stones', rating: 2.1 });
+ });
+
+ test('falls back to the .difficulty class when .difficulty_number is absent', async ({
+ page
+ }) => {
+ await page.goto('about:blank');
+ const result = await parseInBrowser(page, LEGACY_DIFFICULTY_CLASS, 'legacy');
+ expect(result).toEqual({ name: 'Legacy Problem', rating: 7 });
+ });
+
+ test('defaults the rating to 5 when no difficulty element is present', async ({ page }) => {
+ await page.goto('about:blank');
+ const result = await parseInBrowser(page, NO_DIFFICULTY, 'nodiff');
+ expect(result).toEqual({ name: 'Nameless Difficulty', rating: 5 });
+ });
+
+ test('falls back to the problem id when the h1 is missing', async ({ page }) => {
+ await page.goto('about:blank');
+ const result = await parseInBrowser(page, NO_TITLE, 'missingtitle');
+ expect(result).toEqual({ name: 'missingtitle', rating: 5 });
+ });
+});