import crypto from "node:crypto"; import { expect, test } from "@playwright/test"; import { PNG } from "pngjs"; const API_ORIGIN = requiredOrigin("UAT_API_ORIGIN"); const WEB_ORIGIN = requiredOrigin("UAT_WEB_ORIGIN"); const EXPECTED_REVISION = (process.env.EXPECTED_REVISION || "").trim(); function requiredOrigin(name) { const value = (process.env[name] || "").trim().replace(/\/$/, ""); if (!value.startsWith("https://")) throw new Error(`${name} must be an https origin`); return value; } async function callMultipart(path, formData, token, expected = [200]) { const response = await fetch(`${API_ORIGIN}${path}`, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: formData }); const text = await response.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch { data = text; } } if (!expected.includes(response.status)) { throw new Error(`POST ${path} returned ${response.status}: ${text.slice(0, 500)}`); } return { response, data }; } async function call(path, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeout ?? 20_000); try { const response = await fetch(`${API_ORIGIN}${path}`, { method: options.method || "GET", headers: { Accept: "application/json", ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) }, body: options.body === undefined ? undefined : JSON.stringify(options.body), redirect: "manual", signal: controller.signal }); const text = await response.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch { data = text; } } const expected = options.expected || [200]; if (!expected.includes(response.status)) { throw new Error(`${options.method || "GET"} ${path} returned ${response.status}: ${text.slice(0, 500)}`); } return { response, data }; } finally { clearTimeout(timeout); } } function providerIds(payload) { return (payload?.providers || []).map((provider) => provider.id); } function validateExternalUrl(provider, authUrl) { const url = new URL(authUrl); if (provider === "google") { expect(url.hostname).toBe("accounts.google.com"); expect(url.searchParams.get("client_id")).toBeTruthy(); expect(url.searchParams.get("redirect_uri")).toBe(`${API_ORIGIN}/api/Auth/external/google/callback`); } else { expect(url.hostname).toBe("steamcommunity.com"); const returnTo = new URL(url.searchParams.get("openid.return_to")); expect(returnTo.origin + returnTo.pathname).toBe(`${API_ORIGIN}/api/Auth/external/steam/callback`); expect(url.searchParams.get("openid.realm")).toBe(API_ORIGIN); } } test("public API and disposable learner journey", async () => { const suffix = `${Date.now().toString(36)}${crypto.randomBytes(3).toString("hex")}`; const username = `smoke_${suffix}`.slice(0, 28); const email = `${username}@smoke.invalid`; const password = `Smk!${crypto.randomBytes(18).toString("base64url")}`; let token = ""; let refreshToken = ""; let accountCreated = false; let activeCcsAttempt = ""; let mainError = null; let cleanupError = null; try { const health = await call("/healthz"); expect(String(health.data).trim()).toBe("ok"); const ready = await call("/readyz"); expect(String(ready.data).trim()).toBe("ready"); const { data: runtime } = await call("/api/runtime"); expect(String(runtime.environment).toLowerCase()).toBe("uat"); if (EXPECTED_REVISION) expect(runtime.buildRevision).toBe(EXPECTED_REVISION); const { data: legal } = await call("/api/Auth/legal"); expect(legal.termsUrl).toBeTruthy(); expect(legal.privacyUrl).toBeTruthy(); const { data: providers } = await call("/api/Auth/external/providers"); expect(providerIds(providers)).toEqual(expect.arrayContaining(["steam", "google"])); for (const provider of ["steam", "google"]) { const { data: start } = await call(`/api/Auth/external/${provider}/start`, { method: "POST", body: { purpose: "login" } }); expect(start.transactionId).toBeTruthy(); validateExternalUrl(provider, start.authUrl); } await call("/api/Auth/register", { method: "POST", body: { username, email, password, acceptLegal: true } }); accountCreated = true; const { data: session } = await call("/api/Auth/login", { method: "POST", body: { username, password } }); token = session.accessToken; refreshToken = session.refreshToken; expect(token).toBeTruthy(); expect(refreshToken).toBeTruthy(); const { data: me } = await call("/api/Auth/me", { token }); expect(me.username).toBe(username); expect(me.availableProviders).toEqual(expect.arrayContaining(["steam", "google"])); const { data: refreshed } = await call("/api/Auth/refresh", { method: "POST", body: { username, refreshToken } }); token = refreshed.accessToken; expect(token).toBeTruthy(); await call("/api/profile/me/preferences", { token }); await call("/api/dashboard", { token }); await call("/api/ratings/me", { token }); const { data: dynamicStatus } = await call("/api/dynamic-questions/status", { token }); expect(dynamicStatus.available).toBe(true); expect(dynamicStatus.publishedObjectives).toBe(5); const { data: dynamicQuestion } = await call("/api/dynamic-questions/next", { method: "POST", token, body: { runToken: "" } }); expect(dynamicQuestion.generationToken).toBeTruthy(); expect(dynamicQuestion.runToken).toBeTruthy(); expect(dynamicQuestion.question.stem.length).toBeGreaterThan(220); expect(dynamicQuestion.question.choices).toHaveLength(5); expect(Object.keys(dynamicQuestion.question.choiceExplanations)).toHaveLength(5); expect(dynamicQuestion.question.reviewFollowups.length).toBeGreaterThan(0); const dynamicChoice = dynamicQuestion.question.choices[0].id; const { data: dynamicAttempt } = await call("/api/dynamic-questions/attempt", { method: "POST", token, body: { generationToken: dynamicQuestion.generationToken, choiceId: dynamicChoice, isSkip: false, durationSeconds: 5 } }); expect(dynamicAttempt.objectiveId).toBeTruthy(); const { data: queue } = await call("/api/review-queue/next", { method: "POST", token, body: { excludeExternalIds: [], supportsChoicePools: true } }); expect(Number.isInteger(queue.pendingCount)).toBeTruthy(); const { data: questions } = await call("/api/questions/search", { method: "POST", token, body: { limit: 1, supportsChoicePools: true, requireMedia: false, excludeExternalIds: [] } }); expect(Array.isArray(questions)).toBeTruthy(); expect(questions).toHaveLength(1); expect(questions[0].externalId).toBeTruthy(); expect(questions[0].stem).toBeTruthy(); expect(questions[0].choices.length).toBeGreaterThanOrEqual(5); expect(questions[0].choices.map((choice) => choice.id)).toContain(questions[0].correctChoiceId); const { data: cases } = await call("/api/ccs/cases", { token }); expect(Array.isArray(cases)).toBeTruthy(); expect(cases.length).toBeGreaterThan(0); const managementCase = cases.find((item) => item.experienceMode === "management"); expect(managementCase, "UAT requires a published management CCS case for the smoke journey").toBeTruthy(); const { data: attempt } = await call("/api/ccs/attempts", { method: "POST", token, body: { caseExternalId: managementCase.externalId, mode: "timed" } }); activeCcsAttempt = attempt.id; expect(activeCcsAttempt).toBeTruthy(); expect(attempt.caseExternalId).toBeTruthy(); expect(attempt.state).toBe("active"); const { data: medicationOrders } = await call("/api/ccs/orders/search?q=medications&limit=20", { token }); expect(Array.isArray(medicationOrders)).toBeTruthy(); expect(medicationOrders.length).toBeGreaterThan(0); expect(medicationOrders.some((order) => order.category === "medication")).toBeTruthy(); const { data: testOrders } = await call("/api/ccs/orders/search?q=tests&limit=20", { token }); expect(Array.isArray(testOrders)).toBeTruthy(); expect(testOrders.some((order) => ["laboratory", "diagnostic"].includes(order.category))).toBeTruthy(); const { data: opened } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, { method: "POST", token, body: { commandId: `smoke-opening-${suffix}`, expectedRevision: attempt.revision, type: "acknowledge_opening", code: "", minutes: 0 } }); expect(opened.openingCompleted).toBe(true); expect(Array.isArray(opened.availableExams)).toBeTruthy(); expect(opened.availableExams.length, "management CCS smoke case must expose at least one exam").toBeGreaterThan(0); const exam = opened.availableExams[0]; const { data: examined } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands/batch`, { method: "POST", token, body: { commandId: `smoke-exam-${suffix}`, expectedRevision: opened.revision, commands: [{ type: "exam", code: exam.code, minutes: 0 }] } }); const { data: finalOrders } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, { method: "POST", token, body: { commandId: `smoke-final-${suffix}`, expectedRevision: examined.revision, type: "end_case", code: "", minutes: 0 } }); expect(finalOrders.state).toBe("final_orders"); const { data: completedCcs } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, { method: "POST", token, body: { commandId: `smoke-complete-${suffix}`, expectedRevision: finalOrders.revision, type: "exit_case", code: "", minutes: 0 } }); expect(completedCcs.state).toBe("completed"); activeCcsAttempt = ""; // Flashcards: create a deck and card, study and grade it, verify it drops out of the due // queue, then round-trip it through CSV export/import (exercising the exact-content dedupe). const { data: deck } = await call("/api/flashcards/decks", { method: "POST", token, body: { name: `Smoke Deck ${suffix}`, description: "" } }); expect(deck.id).toBeTruthy(); const { data: note } = await call("/api/flashcards/notes", { method: "POST", token, body: { deckId: deck.id, stableId: "", type: "basic", prompt: "Smoke test prompt", answer: "Smoke test answer", extra: "", tags: ["smoke"], images: [], sourceQuestionExternalId: "" } }); expect(note.id).toBeTruthy(); const studyDayStartUtc = new Date().toISOString(); const { data: firstCard } = await call("/api/flashcards/study/next", { method: "POST", token, body: { deckId: deck.id, excludeCardIds: [], studyDayStartUtc } }); expect(firstCard.note?.id).toBe(note.id); expect(firstCard.card?.state).toBe("new"); const { data: reviewResult } = await call("/api/flashcards/reviews", { method: "POST", token, body: { reviews: [{ clientReviewId: crypto.randomUUID(), cardId: firstCard.card.id, rating: "good", expectedRevision: firstCard.card.revision, reviewedUtc: new Date().toISOString(), durationSeconds: 5, studyDayStartUtc }] } }); expect(reviewResult.results).toHaveLength(1); expect(reviewResult.results[0].card.state).not.toBe("new"); expect(new Date(reviewResult.results[0].card.dueUtc).getTime()).toBeGreaterThan(Date.now()); const { data: secondCard } = await call("/api/flashcards/study/next", { method: "POST", token, body: { deckId: deck.id, excludeCardIds: [], studyDayStartUtc } }); expect(secondCard.note).toBeFalsy(); const { response: csvResponse, data: csvText } = await call("/api/flashcards/export/csv", { token }); expect(csvResponse.headers.get("content-type") || "").toContain("text/csv"); expect(String(csvText)).toContain("Smoke test prompt"); const csvForm = new FormData(); csvForm.append("file", new Blob([String(csvText)], { type: "text/csv" }), "flashcards.csv"); const { data: csvPreview } = await callMultipart("/api/flashcards/import/csv/preview", csvForm, token); expect(csvPreview.importToken).toBeTruthy(); expect(csvPreview.skipCount).toBeGreaterThan(0); const { data: csvCommit } = await call("/api/flashcards/import/commit", { method: "POST", token, body: { importToken: csvPreview.importToken } }); expect(csvCommit.skipped).toBeGreaterThan(0); const { data: jsonExport } = await call("/api/flashcards/export/json", { token }); expect(jsonExport.notes.some((item) => item.note.stableId === note.stableId)).toBeTruthy(); } catch (error) { mainError = error; } finally { try { if (accountCreated && !token) { const login = await call("/api/Auth/login", { method: "POST", body: { username, password } }); token = login.data.accessToken; } if (activeCcsAttempt && token) { await call(`/api/ccs/attempts/${activeCcsAttempt}/abandon`, { method: "POST", token, expected: [204, 404] }); } if (accountCreated && token) { await call("/api/Auth/me", { method: "DELETE", token, body: { password, confirmation: "DELETE" }, expected: [204] }); } } catch (error) { cleanupError = error; } } if (mainError && cleanupError) throw new AggregateError([mainError, cleanupError], "Smoke journey and cleanup failed"); if (mainError) throw mainError; if (cleanupError) throw cleanupError; }); test("web export boots a nonblank Godot canvas", async ({ page }, testInfo) => { const consoleErrors = []; const requestFailures = []; page.on("pageerror", (error) => consoleErrors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); page.on("requestfailed", (request) => { const url = new URL(request.url()); const failure = request.failure()?.errorText || "failed"; // Godot cancels redundant range/download requests once its streamed asset // loader has the complete payload. A removed loader plus a rendered canvas // proves those expected ERR_ABORTED requests were not load failures. if (url.origin === WEB_ORIGIN && failure !== "net::ERR_ABORTED" && /\.(js|wasm|pck|ttf|png)$/.test(url.pathname)) { requestFailures.push(`${url.pathname}: ${failure}`); } }); for (const path of ["/healthz", "/terms.html", "/privacy.html", "/legal/legal_documents.json", "/ranaze-loader-icon-v1.png"]) { const response = await page.request.get(`${WEB_ORIGIN}${path}`); expect(response.ok(), `${path} returned ${response.status()}`).toBeTruthy(); } const response = await page.goto(WEB_ORIGIN, { waitUntil: "domcontentloaded", timeout: 30_000 }); expect(response?.ok()).toBeTruthy(); await page.locator("#status").waitFor({ state: "detached", timeout: 90_000 }); const canvas = page.locator("#canvas"); await expect(canvas).toBeVisible(); const bounds = await canvas.boundingBox(); expect(bounds.width).toBeGreaterThan(1000); expect(bounds.height).toBeGreaterThan(600); await page.waitForTimeout(1_000); const screenshot = await canvas.screenshot({ path: testInfo.outputPath("uat-canvas.png") }); const png = PNG.sync.read(screenshot); const colors = new Set(); for (let y = 0; y < png.height; y += 12) { for (let x = 0; x < png.width; x += 12) { const offset = (png.width * y + x) * 4; colors.add(`${png.data[offset]},${png.data[offset + 1]},${png.data[offset + 2]}`); } } expect(colors.size, "Godot canvas appears blank or uniform").toBeGreaterThan(40); expect(requestFailures, "critical web assets failed to load").toEqual([]); expect(consoleErrors, "browser console errors were emitted").toEqual([]); });