chore(infra): update dev compose, add playwright e2e suite & assets
This commit is contained in:
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user