chore(infra): update dev compose, add playwright e2e suite & assets
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 842 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<title>Compass master</title>
|
||||||
|
<image width="1024" height="1024" href="png/1024x1024.png" xlink:href="png/1024x1024.png"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
@@ -33,6 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DEV_MODE: ${DEV_MODE:-false}
|
DEV_MODE: ${DEV_MODE:-false}
|
||||||
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
|
||||||
|
CAPTCHA_EXPOSE_ANSWER: ${CAPTCHA_EXPOSE_ANSWER:-false}
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -66,6 +67,8 @@ services:
|
|||||||
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
|
# Allow slow, large uploads (admin video ≤ 200MB); default is 60s.
|
||||||
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
|
- --entrypoints.web.transport.respondingTimeouts.readTimeout=3600
|
||||||
- --entrypoints.websecure.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).
|
# Dev: Traefik default certificate (browser warning expected on localhost).
|
||||||
- --api.dashboard=true
|
- --api.dashboard=true
|
||||||
- --api.insecure=true
|
- --api.insecure=true
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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<AuthState> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||