diff --git a/assets/favicon.ico b/assets/favicon.ico
new file mode 100644
index 0000000..c1ea4f4
Binary files /dev/null and b/assets/favicon.ico differ
diff --git a/assets/png/1024x1024-white-bg.jpg b/assets/png/1024x1024-white-bg.jpg
new file mode 100644
index 0000000..f12f0c1
Binary files /dev/null and b/assets/png/1024x1024-white-bg.jpg differ
diff --git a/assets/png/1024x1024-white-bg.png b/assets/png/1024x1024-white-bg.png
new file mode 100644
index 0000000..8842309
Binary files /dev/null and b/assets/png/1024x1024-white-bg.png differ
diff --git a/assets/png/1024x1024.png b/assets/png/1024x1024.png
new file mode 100644
index 0000000..17069a6
Binary files /dev/null and b/assets/png/1024x1024.png differ
diff --git a/assets/png/16x16.png b/assets/png/16x16.png
new file mode 100644
index 0000000..c0a8926
Binary files /dev/null and b/assets/png/16x16.png differ
diff --git a/assets/png/180x180.png b/assets/png/180x180.png
new file mode 100644
index 0000000..68f02f9
Binary files /dev/null and b/assets/png/180x180.png differ
diff --git a/assets/png/192x192.png b/assets/png/192x192.png
new file mode 100644
index 0000000..f8ab2ad
Binary files /dev/null and b/assets/png/192x192.png differ
diff --git a/assets/png/256x256.png b/assets/png/256x256.png
new file mode 100644
index 0000000..3e60b32
Binary files /dev/null and b/assets/png/256x256.png differ
diff --git a/assets/png/32x32.png b/assets/png/32x32.png
new file mode 100644
index 0000000..6f843a4
Binary files /dev/null and b/assets/png/32x32.png differ
diff --git a/assets/png/512x512.png b/assets/png/512x512.png
new file mode 100644
index 0000000..9c52165
Binary files /dev/null and b/assets/png/512x512.png differ
diff --git a/assets/png/64x64.png b/assets/png/64x64.png
new file mode 100644
index 0000000..3a07268
Binary files /dev/null and b/assets/png/64x64.png differ
diff --git a/assets/svg/compass.svg b/assets/svg/compass.svg
new file mode 100644
index 0000000..2b09ddc
--- /dev/null
+++ b/assets/svg/compass.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/deploy/compose/compose.dev.yaml b/deploy/compose/compose.dev.yaml
index 701a709..e42a2c4 100644
--- a/deploy/compose/compose.dev.yaml
+++ b/deploy/compose/compose.dev.yaml
@@ -33,6 +33,7 @@ services:
environment:
DEV_MODE: ${DEV_MODE:-false}
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
+ CAPTCHA_EXPOSE_ANSWER: ${CAPTCHA_EXPOSE_ANSWER:-false}
deploy:
resources:
limits:
@@ -66,6 +67,8 @@ services:
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=3600
+ # Disable HTTP/2 so WebSocket upgrades work from Chromium (RFC 8441 not enabled).
+ - --entrypoints.websecure.http2.maxConcurrentStreams=0
# Dev: Traefik default certificate (browser warning expected on localhost).
- --api.dashboard=true
- --api.insecure=true
diff --git a/frontend/e2e/.auth-state.json b/frontend/e2e/.auth-state.json
new file mode 100644
index 0000000..938a0f3
--- /dev/null
+++ b/frontend/e2e/.auth-state.json
@@ -0,0 +1 @@
+{"available":true,"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInJvbGUiOiJhZG1pbiIsInN1YiI6IjEiLCJleHAiOjE3OTI0OTc0MjcsImlhdCI6MTc5MjQ5NjUyN30.0RuGRaPhMeQ36aHrn_x2iAgJPpyH7pfXGr_cOOOeG1c","refreshToken":"e4059f725d0648ae77b0147835296228c5bba0d0bcda638ebb9a65adeed6c5f72e56d0da268de11d34393e9d4ea96acb","user":{"id":1,"email":"admin@example.com","username":"admin","name":"Admin","role":"admin","xp":0,"hearts":5,"heartsUpdatedAt":"2026-10-18T06:49:58.329935Z","streakCurrent":0,"streakLongest":0,"isLocked":false,"isActive":true,"avatarVariant":"beam","avatarPalette":"","phone":"","hasSecurityAnswer":false,"createdAt":"2026-10-18T06:49:58.329935Z","usernameChangesRemaining":3,"isFrozen":false,"isClosed":false}}
\ No newline at end of file
diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts
new file mode 100644
index 0000000..175bca7
--- /dev/null
+++ b/frontend/e2e/auth.spec.ts
@@ -0,0 +1,79 @@
+import { test, expect } from "@playwright/test";
+import { ADMIN_EMAIL, ADMIN_PASSWORD, BASE, requireAuth, injectAuth, apiLogin } from "./helpers";
+
+test.describe("Auth — public endpoints", () => {
+ test("login page renders", async ({ page }) => {
+ await page.goto("/login");
+ await expect(page).toHaveTitle(/.+/);
+ const hasForm = await page.locator("form, input[type=password]").count();
+ expect(hasForm).toBeGreaterThan(0);
+ });
+
+ test("protected routes reject unauthenticated requests", async ({ page }) => {
+ const routes = ["/api/tree", "/api/leaderboard/weekly", "/api/notifications"];
+ for (const route of routes) {
+ const res = await page.request.get(`${BASE}${route}`);
+ expect(res.status(), `${route} should be 401`).toBe(401);
+ }
+ });
+
+ test("invalid credentials: bad captcha → 400, bad password → 401", async ({ page }) => {
+ // Bad captcha answer returns 400
+ const r1 = await page.request.post(`${BASE}/api/auth/login`, {
+ data: { username: "x", password: "x", challengeId: "fake", captchaAnswer: "wrong" },
+ });
+ expect([400, 429]).toContain(r1.status());
+
+ // Correct captcha, wrong password → 401
+ const auth = requireAuth();
+ const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
+ const { challengeId, answer } = await captchaRes.json();
+ const r2 = await page.request.post(`${BASE}/api/auth/login`, {
+ data: { username: ADMIN_EMAIL, password: "definitely-wrong", challengeId, captchaAnswer: answer },
+ });
+ expect(r2.status()).toBe(401);
+ });
+});
+
+test.describe("Auth — requires working CAPTCHA", () => {
+ test("admin login via API succeeds and /me returns admin role", async ({ page }) => {
+ const auth = requireAuth();
+ const res = await page.request.get(`${BASE}/api/auth/me`, {
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
+ });
+ expect(res.status()).toBe(200);
+ const body = await res.json();
+ expect(body.user.role).toBe("admin");
+ });
+
+ test("logout invalidates refresh token", async ({ page }) => {
+ requireAuth();
+ // Issue a fresh session for this test only (don't use the shared token)
+ const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
+ const { challengeId, answer } = await captchaRes.json();
+ if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; }
+
+ const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
+ data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD, challengeId, captchaAnswer: answer },
+ });
+ const { accessToken, refreshToken } = await loginRes.json();
+
+ const logout = await page.request.post(`${BASE}/api/auth/logout`, {
+ data: { refreshToken },
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ expect(logout.status()).toBe(200);
+
+ const refresh = await page.request.post(`${BASE}/api/auth/refresh`, {
+ data: { refreshToken },
+ });
+ expect(refresh.status()).toBe(401);
+ });
+
+ test("cap page loads and stays off /login", async ({ page }) => {
+ await injectAuth(page);
+ await page.goto("/cap");
+ await page.waitForLoadState("networkidle", { timeout: 15_000 });
+ expect(page.url()).not.toContain("/login");
+ });
+});
diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts
new file mode 100644
index 0000000..fbe21aa
--- /dev/null
+++ b/frontend/e2e/chat.spec.ts
@@ -0,0 +1,65 @@
+import { test, expect } from "@playwright/test";
+import { BASE, requireAuth, injectAuth } from "./helpers";
+
+test.describe("Chat", () => {
+ test("conversations endpoint returns list", async ({ page }) => {
+ const auth = requireAuth();
+ const res = await page.request.get(`${BASE}/api/chats/conversations`, {
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
+ });
+ expect(res.status()).toBe(200);
+ const data = await res.json();
+ expect(data).toHaveProperty("conversations");
+ expect(Array.isArray(data.conversations)).toBe(true);
+ });
+
+ test("student→student chat is rejected (IDOR guard)", async ({ page }) => {
+ const auth = requireAuth();
+ const adminId = auth.user!.id;
+
+ // Create a student
+ const u1 = await page.request.post(`${BASE}/api/admin/users`, {
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
+ data: {
+ name: "ChatTest", email: "chat-idor@e2e.test", username: "chat_idor_e2e",
+ password: "TestPass123!", role: "student",
+ securityQuestion: "Q", securityAnswer: "A",
+ },
+ });
+ // 201 = created, 409 = already exists from prior run — both are fine
+ if (u1.status() !== 201 && u1.status() !== 409) {
+ test.skip(true, `Unexpected status creating test user: ${u1.status()}`);
+ return;
+ }
+
+ const studentId = u1.status() === 201
+ ? (await u1.json()).user.id
+ : null;
+
+ if (!studentId) { test.skip(true, "Could not get student ID"); return; }
+
+ // Log in as the student
+ const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
+ const { challengeId, answer } = await captchaRes.json();
+ if (!answer) { test.skip(true, "CAPTCHA not solvable"); return; }
+
+ const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
+ data: { username: "chat_idor_e2e", password: "TestPass123!", challengeId, captchaAnswer: answer },
+ });
+ const studentSession = await loginRes.json();
+ if (!studentSession.accessToken) { test.skip(true, "Student login failed"); return; }
+
+ // Student tries to read another student's conversations — must fail
+ const forbidden = await page.request.get(`${BASE}/api/chats/${studentId}/messages`, {
+ headers: { Authorization: `Bearer ${studentSession.accessToken}` },
+ });
+ expect([403, 404]).toContain(forbidden.status());
+ });
+
+ test("chat page renders without redirect to /login", async ({ page }) => {
+ await injectAuth(page);
+ await page.goto("/unwrap?tab=chats");
+ await page.waitForLoadState("networkidle", { timeout: 15_000 });
+ expect(page.url()).not.toContain("/login");
+ });
+});
diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts
new file mode 100644
index 0000000..8d29295
--- /dev/null
+++ b/frontend/e2e/global-setup.ts
@@ -0,0 +1,59 @@
+import { chromium } from "@playwright/test";
+import { ADMIN_EMAIL, ADMIN_PASSWORD, BASE } from "./helpers";
+import * as fs from "fs";
+import * as path from "path";
+
+const AUTH_FILE = path.join(__dirname, ".auth-state.json");
+
+export default async function globalSetup() {
+ // If answer is not exposed in captcha, skip auth — tests requiring auth will skip themselves.
+ const browser = await chromium.launch({
+ executablePath: `${process.env.HOME}/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell`,
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
+ });
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
+ const page = await context.newPage();
+
+ try {
+ const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
+ const captchaData = await captchaRes.json();
+
+ if (!captchaData.answer) {
+ console.warn("\n⚠ CAPTCHA_EXPOSE_ANSWER not set — restart the API container to enable auth tests.\n Run: ./scripts/compose-dev.sh restart api\n");
+ fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false }));
+ await browser.close();
+ return;
+ }
+
+ const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
+ data: {
+ username: ADMIN_EMAIL,
+ password: ADMIN_PASSWORD,
+ challengeId: captchaData.challengeId,
+ captchaAnswer: captchaData.answer,
+ rememberMe: true,
+ },
+ });
+ const body = await loginRes.json();
+
+ if (!body.accessToken) {
+ console.warn("\n⚠ Admin login failed in global setup:", JSON.stringify(body));
+ fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false }));
+ await browser.close();
+ return;
+ }
+
+ fs.writeFileSync(AUTH_FILE, JSON.stringify({
+ available: true,
+ accessToken: body.accessToken,
+ refreshToken: body.refreshToken,
+ user: body.user,
+ }));
+ console.log("\n✓ Auth state cached for test suite\n");
+ } catch (e) {
+ fs.writeFileSync(AUTH_FILE, JSON.stringify({ available: false }));
+ console.warn("\n⚠ Global setup failed:", e);
+ }
+
+ await browser.close();
+}
diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts
new file mode 100644
index 0000000..f04f8bb
--- /dev/null
+++ b/frontend/e2e/helpers.ts
@@ -0,0 +1,83 @@
+import { type Page, test } from "@playwright/test";
+import * as fs from "fs";
+import * as path from "path";
+
+export const ADMIN_EMAIL = "admin";
+export const ADMIN_PASSWORD = "wZW64CUbXbv9679dmR1FIbz3";
+export const BASE = "https://localhost";
+
+const AUTH_FILE = path.join(__dirname, ".auth-state.json");
+
+interface AuthState {
+ available: boolean;
+ accessToken?: string;
+ refreshToken?: string;
+ user?: { id: number; role: string; email: string };
+}
+
+export function loadAuth(): AuthState {
+ try {
+ return JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")) as AuthState;
+ } catch {
+ return { available: false };
+ }
+}
+
+/** Skip this test if auth is not available (CAPTCHA not exposed). */
+export function requireAuth(): AuthState {
+ const auth = loadAuth();
+ if (!auth.available) {
+ test.skip(true, "Auth not available — restart API with CAPTCHA_EXPOSE_ANSWER=true");
+ }
+ return auth;
+}
+
+/** Inject cached auth cookies into the page context. */
+export async function injectAuth(page: Page): Promise {
+ const auth = requireAuth();
+ // Seed Zustand persist store so the app hydrates with a valid user (skips redirect)
+ await page.addInitScript(({ user }) => {
+ try {
+ localStorage.setItem(
+ "pargar.auth",
+ JSON.stringify({ state: { user }, version: 0 }),
+ );
+ } catch {}
+ }, { user: auth.user });
+ await page.context().addCookies([
+ { name: "pargar_access", value: auth.accessToken!, domain: "localhost", path: "/", secure: true, sameSite: "Lax" },
+ { name: "pargar_refresh", value: auth.refreshToken!, domain: "localhost", path: "/", secure: true, httpOnly: true, sameSite: "Lax" },
+ ]);
+ return auth;
+}
+
+export async function loginAs(page: Page, identity: string, password: string) {
+ const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
+ const captchaData = await captchaRes.json();
+ const answer: string = captchaData.answer ?? "";
+ const challengeId: string = captchaData.challengeId ?? "";
+
+ const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
+ data: { username: identity, password, challengeId, captchaAnswer: answer, rememberMe: false },
+ });
+ const loginBody = await loginRes.json();
+
+ if (!loginBody.accessToken) {
+ throw new Error(`Login failed for ${identity}: ${JSON.stringify(loginBody)}`);
+ }
+
+ await page.context().addCookies([
+ { name: "access_token", value: loginBody.accessToken, domain: "localhost", path: "/", secure: true, sameSite: "Lax" },
+ { name: "refresh_token", value: loginBody.refreshToken, domain: "localhost", path: "/", secure: true, httpOnly: true, sameSite: "Lax" },
+ ]);
+ return loginBody;
+}
+
+export async function apiLogin(page: Page, identity: string, password: string) {
+ const captchaRes = await page.request.get(`${BASE}/api/auth/captcha`);
+ const { challengeId, answer } = await captchaRes.json();
+ const res = await page.request.post(`${BASE}/api/auth/login`, {
+ data: { username: identity, password, challengeId, captchaAnswer: answer, rememberMe: false },
+ });
+ return res.json();
+}
diff --git a/frontend/e2e/leaderboard.spec.ts b/frontend/e2e/leaderboard.spec.ts
new file mode 100644
index 0000000..ffd882f
--- /dev/null
+++ b/frontend/e2e/leaderboard.spec.ts
@@ -0,0 +1,37 @@
+import { test, expect } from "@playwright/test";
+import { BASE, requireAuth, injectAuth } from "./helpers";
+
+test.describe("Leaderboard", () => {
+ test("weekly leaderboard returns valid shape", async ({ page }) => {
+ const auth = requireAuth();
+ const res = await page.request.get(`${BASE}/api/leaderboard/weekly`, {
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
+ });
+ expect(res.status()).toBe(200);
+ const data = await res.json();
+ expect(data).toHaveProperty("week");
+ expect(data).toHaveProperty("entries");
+ expect(data).toHaveProperty("totalParticipants");
+ expect(data).toHaveProperty("me");
+ expect(Array.isArray(data.entries)).toBe(true);
+ });
+
+ test("leaderboard entries have tier field", async ({ page }) => {
+ const auth = requireAuth();
+ const res = await page.request.get(`${BASE}/api/leaderboard/weekly`, {
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
+ });
+ const data = await res.json();
+ for (const entry of data.entries) {
+ expect(["champion", "gold", "silver", "bronze"]).toContain(entry.tier);
+ expect(entry.rank).toBeGreaterThanOrEqual(1);
+ }
+ });
+
+ test("leaderboard page renders without redirect to /login", async ({ page }) => {
+ await injectAuth(page);
+ await page.goto("/leaderboard");
+ await page.waitForLoadState("networkidle", { timeout: 15_000 });
+ expect(page.url()).not.toContain("/login");
+ });
+});
diff --git a/frontend/e2e/responsive.spec.ts b/frontend/e2e/responsive.spec.ts
new file mode 100644
index 0000000..c9859bf
--- /dev/null
+++ b/frontend/e2e/responsive.spec.ts
@@ -0,0 +1,49 @@
+import { test, expect } from "@playwright/test";
+import { requireAuth, injectAuth } from "./helpers";
+
+const VIEWPORTS = [
+ { name: "mobile-sm (375px)", width: 375, height: 812 },
+ { name: "mobile-lg (430px)", width: 430, height: 932 },
+ { name: "tablet (768px)", width: 768, height: 1024 },
+ { name: "desktop (1280px)", width: 1280, height: 800 },
+ { name: "desktop-xl (1440px)", width: 1440, height: 900 },
+];
+
+const ROUTES = ["/cap", "/leaderboard", "/community", "/settings", "/notifications"];
+
+for (const vp of VIEWPORTS) {
+ test(`no horizontal overflow — ${vp.name}`, async ({ page }) => {
+ requireAuth();
+ await page.setViewportSize({ width: vp.width, height: vp.height });
+ await injectAuth(page);
+
+ for (const route of ROUTES) {
+ await page.goto(route);
+ await page.waitForLoadState("networkidle", { timeout: 15_000 });
+
+ const overflow = await page.evaluate(() => {
+ return document.documentElement.scrollWidth > document.documentElement.clientWidth;
+ });
+ expect(overflow, `Horizontal overflow at ${route} / ${vp.name}`).toBe(false);
+ }
+ });
+
+ test(`no interactive elements clipped off-screen — ${vp.name}`, async ({ page }) => {
+ requireAuth();
+ await page.setViewportSize({ width: vp.width, height: vp.height });
+ await injectAuth(page);
+
+ await page.goto("/cap");
+ await page.waitForLoadState("networkidle", { timeout: 15_000 });
+
+ const clipped = await page.evaluate((vpWidth) => {
+ return Array.from(document.querySelectorAll("button, a, input"))
+ .filter((el) => {
+ const r = el.getBoundingClientRect();
+ return r.right < 0 || r.left > vpWidth + 50;
+ }).length;
+ }, vp.width);
+
+ expect(clipped, `${clipped} elements clipped at ${vp.name}`).toBe(0);
+ });
+}
diff --git a/frontend/e2e/video.spec.ts b/frontend/e2e/video.spec.ts
new file mode 100644
index 0000000..e562a9a
--- /dev/null
+++ b/frontend/e2e/video.spec.ts
@@ -0,0 +1,75 @@
+import { test, expect } from "@playwright/test";
+import { BASE, requireAuth, injectAuth } from "./helpers";
+
+test.describe("Video player & media signing", () => {
+ test("lesson endpoint returns a signed videoUrl", async ({ page }) => {
+ const auth = requireAuth();
+ const treeRes = await page.request.get(`${BASE}/api/tree`, {
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
+ });
+ expect(treeRes.status()).toBe(200);
+ const tree = await treeRes.json();
+ const firstLesson = tree.chapters?.[0]?.lessons?.[0];
+ if (!firstLesson) { test.skip(true, "No lessons in tree"); return; }
+
+ const lessonRes = await page.request.get(`${BASE}/api/lessons/${firstLesson.id}`, {
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
+ });
+ expect(lessonRes.status()).toBe(200);
+ const lesson = await lessonRes.json();
+
+ expect(lesson.videoUrl).toBeTruthy();
+ expect(lesson.videoUrl).toMatch(/^https?:\/\//);
+
+ // Must be signed (has query params) or served via /media/ proxy
+ const url = new URL(lesson.videoUrl);
+ const isSigned = url.searchParams.size > 0 || lesson.videoUrl.includes("/media/");
+ expect(isSigned, `videoUrl not signed: ${lesson.videoUrl}`).toBe(true);
+ });
+
+ test("player page renders without JS errors for first lesson", async ({ page }) => {
+ const auth = requireAuth();
+ const treeRes = await page.request.get(`${BASE}/api/tree`, {
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
+ });
+ const tree = await treeRes.json();
+ const firstLesson = tree.chapters?.[0]?.lessons?.[0];
+ if (!firstLesson) { test.skip(true, "No lessons in tree"); return; }
+
+ const jsErrors: string[] = [];
+ page.on("pageerror", (err) => jsErrors.push(err.message));
+
+ await injectAuth(page);
+ await page.goto(`/player/${firstLesson.id}`);
+ await page.waitForLoadState("networkidle", { timeout: 20_000 });
+
+ // Filter known expected media errors (video load failures in headless are expected)
+ const realErrors = jsErrors.filter(
+ (e) => !e.includes("video") && !e.includes("media") && !e.includes("hls") && !e.includes("AbortError")
+ );
+ expect(realErrors, `JS errors on player page: ${realErrors.join(", ")}`).toHaveLength(0);
+ });
+
+ test("admin videos panel fires no media requests on grid load", async ({ page }) => {
+ requireAuth();
+ const videoRequests: string[] = [];
+ page.on("request", (req) => {
+ if (req.resourceType() === "media" || /\.(mp4|m3u8|ts)(\?|$)/.test(req.url())) {
+ videoRequests.push(req.url());
+ }
+ });
+
+ await injectAuth(page);
+ await page.goto("/admin");
+ await page.waitForLoadState("networkidle", { timeout: 15_000 });
+
+ // Click Videos tab if present
+ const videosTab = page.getByRole("button", { name: /ویدیو/i });
+ if (await videosTab.count() > 0) {
+ await videosTab.first().click();
+ await page.waitForLoadState("networkidle", { timeout: 10_000 });
+ }
+
+ expect(videoRequests, `Unexpected media requests on grid load: ${videoRequests.join(", ")}`).toHaveLength(0);
+ });
+});
diff --git a/frontend/e2e/websocket.spec.ts b/frontend/e2e/websocket.spec.ts
new file mode 100644
index 0000000..17dd00b
--- /dev/null
+++ b/frontend/e2e/websocket.spec.ts
@@ -0,0 +1,56 @@
+import * as tls from "tls";
+import { test, expect } from "@playwright/test";
+import { BASE, requireAuth } from "./helpers";
+
+test.describe("WebSocket", () => {
+ // Test WS via raw TLS to avoid browser HTTP/2 and Node.js http module quirks.
+ test("WS handshake with valid token succeeds", async () => {
+ const auth = requireAuth();
+
+ const statusCode = await new Promise((resolve) => {
+ const socket = tls.connect(
+ { host: "localhost", port: 443, rejectUnauthorized: false },
+ () => {
+ const handshake = [
+ "GET /api/ws HTTP/1.1",
+ "Host: localhost",
+ "Upgrade: websocket",
+ "Connection: Upgrade",
+ "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
+ "Sec-WebSocket-Version: 13",
+ `Authorization: Bearer ${auth.accessToken!}`,
+ "",
+ "",
+ ].join("\r\n");
+ socket.write(handshake);
+ },
+ );
+ let buf = "";
+ socket.on("data", (chunk) => {
+ buf += chunk.toString();
+ const firstLine = buf.split("\r\n")[0] ?? "";
+ const m = firstLine.match(/HTTP\/1\.\d\s+(\d+)/);
+ if (m) {
+ socket.destroy();
+ resolve(parseInt(m[1], 10));
+ }
+ });
+ socket.on("error", () => resolve(0));
+ setTimeout(() => { socket.destroy(); resolve(0); }, 8000);
+ });
+
+ expect(statusCode, `WS upgrade returned ${statusCode}`).toBe(101);
+ });
+
+ test("WS handshake without token is rejected", async ({ page }) => {
+ const result = await page.evaluate((wsUrl) => {
+ return new Promise<{ code: number }>((resolve) => {
+ const ws = new WebSocket(wsUrl);
+ ws.onclose = (e) => resolve({ code: e.code });
+ setTimeout(() => resolve({ code: -1 }), 6000);
+ });
+ }, BASE.replace("https", "wss") + "/api/ws");
+
+ expect(result.code).not.toBe(1000);
+ });
+});
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
new file mode 100644
index 0000000..6fa13d4
--- /dev/null
+++ b/frontend/playwright.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ timeout: 30_000,
+ retries: 0,
+ workers: 1,
+ globalSetup: "./e2e/global-setup.ts",
+ reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
+ use: {
+ baseURL: "https://localhost",
+ ignoreHTTPSErrors: true,
+ screenshot: "only-on-failure",
+ video: "off",
+ trace: "retain-on-failure",
+ locale: "fa-IR",
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ browserName: "chromium",
+ viewport: { width: 1280, height: 800 },
+ launchOptions: {
+ executablePath: `${process.env.HOME}/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell`,
+ args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-http2"],
+ },
+ },
+ },
+ ],
+});