diff --git a/.env.production b/.env.production index 4bc8765d..8b0a7a3d 100644 --- a/.env.production +++ b/.env.production @@ -3,4 +3,6 @@ NEXT_PUBLIC_API_URL=/api/ NEXT_PUBLIC_SLACK_CLIENT_ID=10831824934.7404945710466 NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-1BFJYBDC76 -NEXT_PUBLIC_RECAPTCHA_KEY=6Le63OUqAAAAABxxDrbaU9OywDLLHqutVwbw7a9d \ No newline at end of file +NEXT_PUBLIC_RECAPTCHA_KEY=6Le63OUqAAAAABxxDrbaU9OywDLLHqutVwbw7a9d + +ENV_FILE=.env.production \ No newline at end of file diff --git a/.env.test b/.env.test index 3eae1f85..f373e69d 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,7 @@ -NEXT_PUBLIC_API_URL=http://localhost:3000/api +NEXTAUTH_URL=http://localhost:3000/ +NEXTAUTH_SECRET=testsecret + +NEXT_PUBLIC_API_URL=/api/ DEVELOPER_EMAILS=["test@gmail.com"] @@ -6,4 +9,12 @@ TOA_URL=https://example.com TOA_APP_ID=123 TOA_KEY=456 -DEFAULT_IMAGE=https://example.com/default.jpg \ No newline at end of file +DEFAULT_IMAGE=https://example.com/default.jpg + +BASE_URL_FOR_PLAYWRIGHT=http://localhost:3000/ +ENABLE_TEST_SIGNIN_ROUTE=true +FALLBACK_MONGODB_URI=mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000 + +ENV_FILE=.env.test + +DB=playwright_tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 211da336..987b7490 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,9 @@ jobs: - name: Lint run: npm run lint + + e2e_test: + uses: ./.github/workflows/e2e_test.yml + permissions: + contents: read + pull-requests: write diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml new file mode 100644 index 00000000..e79336ca --- /dev/null +++ b/.github/workflows/e2e_test.yml @@ -0,0 +1,71 @@ +name: Playwright Tests +on: [workflow_dispatch, workflow_call] +jobs: + e2e_tests: + timeout-minutes: 60 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Make sure to require each shard in GitHub! + shardIndex: [1, 2, 3, 4] + shardTotal: [4] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.12.0 + with: + mongodb-version: "8.0" + + - name: Run Playwright tests + run: npx cross-env NODE_ENV=test playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + - name: Upload blob report to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: blob-report-${{ matrix.shardIndex }} + path: blob-report + retention-days: 1 + + merge_reports: + # Merge reports after playwright-tests, even if some shards have failed + if: ${{ !cancelled() }} + needs: [e2e_tests] + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@v4 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge into HTML Report + run: npx playwright merge-reports --reporter html ./all-blob-reports + + - name: Upload HTML report + uses: actions/upload-artifact@v4 + with: + name: html-report--attempt-${{ github.run_attempt }} + path: playwright-report + retention-days: 14 diff --git a/.gitignore b/.gitignore index 40dfa349..445a7479 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,11 @@ next-env.d.ts # PWA public/sw.js -public/swe-worker* \ No newline at end of file +public/swe-worker* + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 0f35dd6f..23f4fa17 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -8,6 +8,7 @@ "pmneo.tsimporter", "austenc.tailwind-docs", "bradlc.vscode-tailwindcss", - "Orta.vscode-jest" + "Orta.vscode-jest", + "ms-playwright.playwright" ] } diff --git a/components/Avatar.tsx b/components/Avatar.tsx index 7455a912..82fe40ad 100644 --- a/components/Avatar.tsx +++ b/components/Avatar.tsx @@ -3,7 +3,7 @@ import { levelToClassName } from "@/lib/Xp"; import { BsGearFill } from "react-icons/bs"; export default function Avatar(props: { - user?: { image: string | undefined; level: number; admin?: boolean }; + user?: { image: string | undefined; level?: number; admin?: boolean }; scale?: string | undefined; // Use "scale-75" for 75% scale, etc. imgHeightOverride?: string | undefined; showLevel?: boolean | undefined; @@ -13,6 +13,7 @@ export default function Avatar(props: { className?: string | undefined; online?: boolean; gearSize?: number; + altText?: string; }) { const { session, status } = useCurrentSession(); const user = props.user ?? session?.user; @@ -24,8 +25,8 @@ export default function Avatar(props: {
- {(props.showLevel ?? true) && ( -
+ {props.showLevel && ( +
LVL: {user?.level}
)} @@ -34,12 +35,12 @@ export default function Avatar(props: { > {"Avatar"}
{admin ? ( -
+
) : ( diff --git a/components/Card.tsx b/components/Card.tsx index b584664a..7ae27ac0 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -14,6 +14,7 @@ export default function Card(props: CardProps) { return (
{color ? ( diff --git a/components/EditAvatarModal.tsx b/components/EditAvatarModal.tsx new file mode 100644 index 00000000..8ba8e85e --- /dev/null +++ b/components/EditAvatarModal.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import Card from "./Card"; +import ClientApi from "@/lib/api/ClientApi"; +import Avatar from "./Avatar"; +import Image from "next/image"; +import { Session } from "inspector/promises"; +import toast from "react-hot-toast"; + +const api = new ClientApi(); +export default function EditAvatarModal(props: { + currentImg: string; + close: () => void; +}) { + const [newAvatar, setNewAvatar] = useState(props.currentImg); + + async function updateAvatar() { + toast.promise( + api.changePFP(newAvatar).then(() => location.reload()), + { + loading: "Updating profile picture...", + success: "Successfully updated profile picture!", + error: "Failed to update profile picture!", + }, + ); + } + + return ( + + +
+ +
+ + setNewAvatar(e.target.value)} + placeholder="Enter new avatar url" + /> +
+ + +
+
+
+ ); +} diff --git a/components/SignInMenu.tsx b/components/SignInMenu.tsx index 1c9ae53b..66d17c34 100644 --- a/components/SignInMenu.tsx +++ b/components/SignInMenu.tsx @@ -59,7 +59,13 @@ function SignInCard() {

Sign In

{error &&

{error}

} -

Choose a login provider

+

Choose a login provider

+ + You currently have to sign-in + using either your{" "} + original sign-in method or + your email. +
+
+
+ {myMatches + .filter((matchData) => showSubmittedReports || !matchData.completed) + .map((matchData, index) => ( + + ))} +
+ + + ); +} diff --git a/components/competition/CompHeaderCard.tsx b/components/competition/CompHeaderCard.tsx index 2222446d..012fd8ca 100644 --- a/components/competition/CompHeaderCard.tsx +++ b/components/competition/CompHeaderCard.tsx @@ -1,13 +1,31 @@ import { NotLinkedToTba } from "@/lib/client/ClientUtils"; -import { Competition } from "@/lib/Types"; +import { Competition, Match, Report } from "@/lib/Types"; +import { useState } from "react"; import { BiExport } from "react-icons/bi"; +import { FaCalendarDay } from "react-icons/fa"; import { MdAutoGraph, MdQueryStats, MdCoPresent } from "react-icons/md"; +import ViewMatchesModal from "../ViewMatchesModal"; +import { User } from "../../lib/Types"; export default function CompHeaderCard({ comp, + matches, + reports, + user, + matchPathway, }: { comp: Competition | undefined; + matches: Match[]; + reports: Report[]; + user: User | null; + matchPathway: string; }) { + const [viewMatches, setViewMatches] = useState(false); + + async function toggleViewMatches() { + setViewMatches(!viewMatches); + } + return (
@@ -36,8 +54,24 @@ export default function CompHeaderCard({ > Pit Stats +
+
+ {viewMatches && user && ( + + )}
); } diff --git a/components/competition/InsightsAndSettingsCard.tsx b/components/competition/InsightsAndSettingsCard.tsx index 5062d365..0cfd21b9 100644 --- a/components/competition/InsightsAndSettingsCard.tsx +++ b/components/competition/InsightsAndSettingsCard.tsx @@ -64,7 +64,7 @@ export default function InsightsAndSettingsCard(props: { const exportAsCsv = async () => { setExportPending(true); - const res = await api.exportCompAsCsv(comp?._id!).catch((e) => { + const res = await api.exportCompDataAsCsv(comp?._id!).catch((e) => { console.error(e); return { csv: undefined }; }); @@ -82,6 +82,31 @@ export default function InsightsAndSettingsCard(props: { setExportPending(false); }; + async function exportScheduleAsCsv() { + setExportPending(true); + + const res = await api.exportCompScheduleAsCsv(comp?._id!).catch((e) => { + console.error(e); + return { csv: undefined }; + }); + + if (!res) { + console.error("failed to export"); + } + + if (res.csv) { + download( + `${comp?.name ?? "Competition"}Schedule.csv`, + res.csv, + "text/csv", + ); + } else { + console.error("No CSV data returned from server"); + } + + setExportPending(false); + } + const createMatch = async () => { try { await api.createMatch( @@ -255,6 +280,18 @@ export default function InsightsAndSettingsCard(props: { "Export Scouting Data as CSV" )} +
/lib/testutils/setup.ts"], + setupFiles: ["/lib/testutils/JestSetup.ts"], // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [], @@ -164,10 +164,7 @@ const config: Config = { // testLocationInResults: false, // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], + testMatch: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(test).[tj]s?(x)"], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // testPathIgnorePatterns: [ diff --git a/lib/CompetitionHandling.ts b/lib/CompetitionHandling.ts index 8ea481b5..75ee044c 100644 --- a/lib/CompetitionHandling.ts +++ b/lib/CompetitionHandling.ts @@ -10,7 +10,7 @@ import { League, } from "./Types"; import { ObjectId } from "bson"; -import { rotateArray } from "./client/ClientUtils"; +import { rotateArray, shuffleArray } from "./client/ClientUtils"; import { games } from "./games"; import { GameId } from "./client/GameId"; import CollectionId from "./client/CollectionId"; @@ -29,6 +29,7 @@ export function generateSchedule( matchCount: number, robotsPerMatch: number, ) { + scouters = shuffleArray(scouters); const schedule = []; for (let i = 0; i < matchCount; i++) { const subjectiveScouter = diff --git a/lib/MongoDB.ts b/lib/MongoDB.ts index d196dca9..f1ae7e1a 100644 --- a/lib/MongoDB.ts +++ b/lib/MongoDB.ts @@ -11,22 +11,29 @@ import { default as BaseMongoDbInterface } from "mongo-anywhere/MongoDbInterface import CachedDbInterface from "./client/dbinterfaces/CachedDbInterface"; import { cacheOptions } from "./client/dbinterfaces/CachedDbInterface"; import { findObjectBySlugLookUp } from "./slugToId"; +import { loadEnvConfig } from "@next/env"; -if (!process.env.MONGODB_URI) { +let uri = process.env.MONGODB_URI ?? process.env.FALLBACK_MONGODB_URI; + +if (!uri) { // Necessary to allow connections from files running outside of Next - require("dotenv").config(); + const projectDir = process.cwd(); + loadEnvConfig(projectDir); + + uri = process.env.MONGODB_URI ?? process.env.FALLBACK_MONGODB_URI; - if (!process.env.MONGODB_URI) - console.error('Invalid/Missing environment variable: "MONGODB_URI"'); + if (!uri) + console.warn( + 'Invalid/Missing environment variables: "MONGODB_URI", "FALLBACK_MONGODB_URI". Using default connection string.', + ); } -const uri = process.env.MONGODB_URI ?? "mongodb://localhost:27017"; const options: MongoClientOptions = { maxPoolSize: 3 }; let client; let clientPromise: Promise; -if (!global.clientPromise) { +if (uri && !global.clientPromise) { client = new MongoClient(uri, options); global.clientPromise = client.connect(); } @@ -37,21 +44,19 @@ export { clientPromise }; export async function getDatabase( useCache: boolean = true, ): Promise { - if (!global.interface) { - await clientPromise; + if (global.interface) return global.interface; // Return the existing instance if already created - const mongo = new MongoDBInterface(clientPromise); + await clientPromise; - const dbInterface = useCache - ? new CachedDbInterface(mongo, cacheOptions) - : mongo; - await dbInterface.init(); - global.interface = dbInterface; + const mongo = new MongoDBInterface(clientPromise); - return dbInterface; - } + const dbInterface = useCache + ? new CachedDbInterface(mongo, cacheOptions) + : mongo; + await dbInterface.init(); + global.interface = dbInterface; - return global.interface; + return dbInterface; } export class MongoDBInterface diff --git a/lib/ResendUtils.ts b/lib/ResendUtils.ts index a2a0f468..8efd88fd 100644 --- a/lib/ResendUtils.ts +++ b/lib/ResendUtils.ts @@ -11,10 +11,11 @@ export interface ResendInterface { } export class ResendUtils implements ResendInterface { - private static resend: Resend; + private static resend: Resend | undefined; constructor() { - ResendUtils.resend ??= new Resend(process.env.SMTP_PASSWORD); + if (process.env.SMTP_PASSWORD) + ResendUtils.resend ??= new Resend(process.env.SMTP_PASSWORD); } async createContact(rawUser: NextAuthUser) { @@ -31,7 +32,7 @@ export class ResendUtils implements ResendInterface { const nameParts = user.name?.split(" "); - const res = await ResendUtils.resend.contacts.create({ + const res = await ResendUtils.resend?.contacts.create({ email: user.email, firstName: nameParts[0], lastName: nameParts.length > 1 ? nameParts[1] : "", @@ -39,7 +40,7 @@ export class ResendUtils implements ResendInterface { audienceId: process.env.RESEND_AUDIENCE_ID, }); - if (!res.data?.id) { + if (!res?.data?.id) { console.error("Failed to create contact for", user.email); console.error(res); return; @@ -64,7 +65,7 @@ export class ResendUtils implements ResendInterface { return; } - ResendUtils.resend.emails.send({ + ResendUtils.resend?.emails.send({ from: "Gearbox Server ", to: JSON.parse(process.env.DEVELOPER_EMAILS), // Environment variables are always strings, so we need to parse it subject, diff --git a/lib/TheBlueAlliance.ts b/lib/TheBlueAlliance.ts index c5c3baf9..3fe3456a 100644 --- a/lib/TheBlueAlliance.ts +++ b/lib/TheBlueAlliance.ts @@ -155,6 +155,8 @@ export namespace TheBlueAlliance { } async request(suburl: string): Promise { + if (!this.apiKey) return; + var res = await fetch(this.baseUrl + suburl, { method: "GET", headers: { @@ -334,7 +336,7 @@ export namespace TheBlueAlliance { async allCompetitionsToPairings(year: number) { var allCompetitions = await this.req.getEvents(year); var pairings: CompetitonNameIdPair[] = []; - allCompetitions.forEach((comp) => { + allCompetitions?.forEach((comp) => { pairings.push({ name: comp.name, tbaId: comp.key }); }); diff --git a/lib/Types.ts b/lib/Types.ts index 32cf938f..8127ae02 100644 --- a/lib/Types.ts +++ b/lib/Types.ts @@ -32,6 +32,9 @@ export interface Session extends NextAuthSession { _id: string; sessionToken: string; userId: ObjectId; + /** + * Should actually be a Date + */ expires: string; } diff --git a/lib/api/ClientApi.ts b/lib/api/ClientApi.ts index fe0d800b..e3d5c45b 100644 --- a/lib/api/ClientApi.ts +++ b/lib/api/ClientApi.ts @@ -21,15 +21,16 @@ import { LeaderboardTeam, LinkedList, } from "@/lib/Types"; -import { NotLinkedToTba, removeDuplicates } from "../client/ClientUtils"; +import { + NotLinkedToTba, + removeDuplicates, + toDict, +} from "../client/ClientUtils"; import { addXp, deleteComp, deleteSeason, generatePitReports, - getSeasonFromComp, - getTeamFromMatch, - getTeamFromReport, onTeam, ownsTeam, } from "./ApiUtils"; @@ -51,14 +52,14 @@ import { RequestHelper } from "unified-api"; import { createNextRoute, NextApiTemplate } from "unified-api-nextjs"; import { Report } from "../Types"; import Logger from "../client/Logger"; -import getRollbar, { RollbarInterface } from "../client/RollbarUtils"; const requestHelper = new RequestHelper( process.env.NEXT_PUBLIC_API_URL ?? "", // Replace undefined when env is not present (ex: for testing builds) - (url) => - toast.error( - `Failed API request: ${url}. If this is an error, please contact the developers.`, - ), + (url, error) => { + const msg = `Failed API request: ${url}. Details: ${error}`; + console.error(msg); + toast.error(msg); + }, ); const logger = new Logger(["API"]); @@ -1123,7 +1124,7 @@ export default class ClientApi extends NextApiTemplate { }, }); - exportCompAsCsv = createNextRoute< + exportCompDataAsCsv = createNextRoute< [string], { csv: string }, ApiDependencies, @@ -1183,6 +1184,93 @@ export default class ClientApi extends NextApiTemplate { }, }); + exportCompScheduleAsCsv = createNextRoute< + [string], + { csv: string }, + ApiDependencies, + { team: Team; comp: Competition } + >({ + isAuthorized: (req, res, deps, [compId]) => + AccessLevels.IfCompOwner(req, res, deps, compId), + handler: async ( + req, + res, + { db: dbPromise, userPromise }, + { team, comp }, + [compId], + ) => { + const db = await dbPromise; + + const matches = await db.findObjects(CollectionId.Matches, { + _id: { $in: comp.matches.map((matchId) => new ObjectId(matchId)) }, + }); + const reports = await db.findObjects(CollectionId.Reports, { + match: { $in: matches.map((match) => match?._id?.toString()) }, + }); + + if (reports.length == 0) { + return res + .status(200) + .send({ error: "No reports found for competition" }); + } + + const users = await db.findObjects(CollectionId.Users, { + _id: { + $in: reports + .map((r) => r.user) + .concat(matches.map((m) => m.subjectiveScouter)) + .flat() + .map((id) => new ObjectId(id)), + }, + }); + + const reportsById = toDict(reports); + const usersById = toDict(users); + + interface Row { + matchNumber: string; + quantScouters: string[]; + subjectiveScouter: string; + } + + const rows: Row[] = [ + // Headers + { + matchNumber: "Match #", + quantScouters: matches[0].reports.map( + (_, index) => `Scouter ${index + 1}`, + ), + subjectiveScouter: "Subjective Scouter", + }, + ]; + + for (const match of matches) { + rows.push({ + matchNumber: match.number.toString(), + quantScouters: match.reports.map((id) => + reportsById[id].user ? usersById[reportsById[id].user].name! : "", + ), + subjectiveScouter: match.subjectiveScouter + ? usersById[match.subjectiveScouter].name! + : "", + }); + } + + const headers = Object.values(rows[0]).flat(); + + let csv = ""; + for (const row of rows) { + csv += + Object.values(row) + .flat() + .map((str) => str.replace(",", "")) + .join(",") + "\n"; + } + + res.status(200).send({ csv }); + }, + }); + teamCompRanking = createNextRoute< [string, number], { place: number | string; max: number | string }, @@ -2621,4 +2709,55 @@ export default class ClientApi extends NextApiTemplate { return res.status(200).send({ result: "success" }); }, }); + + /** + * Creates a user and session, and then returns the session token. Used in E2E tests. + */ + testSignIn = createNextRoute< + [], + { + sessionToken: string; + user: User; + }, + ApiDependencies, + void + >({ + isAuthorized: () => + Promise.resolve({ + authorized: process.env.ENABLE_TEST_SIGNIN_ROUTE === "true", + authData: undefined, + }), + handler: async (req, res, { db: dbPromise }, authData, args) => { + const db = await dbPromise; + + const user = await db.addObject( + CollectionId.Users, + new User( + "Test User", + "test@gmail.com", + process.env.DEFAULT_IMAGE, + false, + await GenerateSlug(db, CollectionId.Users, "Test User"), + [], + [], + undefined, + 0, + 0, + ), + ); + + const session = await db.addObject(CollectionId.Sessions, { + sessionToken: crypto.randomUUID().toString(), + userId: user._id as unknown as ObjectId, + expires: new Date( + Date.now() + 7 * 24 * 60 * 60 * 1000, + ) as unknown as string, // 1 week expiration + }); + + return res.status(200).send({ + sessionToken: session.sessionToken, + user, + }); + }, + }); } diff --git a/lib/client/RollbarUtils.ts b/lib/client/RollbarUtils.ts index 984f3d1b..e2bd0fc1 100644 --- a/lib/client/RollbarUtils.ts +++ b/lib/client/RollbarUtils.ts @@ -13,6 +13,14 @@ export interface RollbarInterface { export default function getRollbar(): RollbarInterface { if (global.rollbar) return global.rollbar; + if (!process.env.ROLLBAR_TOKEN) { + return { + error: (...args: any[]) => console.error(...args), + warn: (...args: any[]) => console.warn(...args), + info: (...args: any[]) => console.info(...args), + debug: (...args: any[]) => console.debug(...args), + }; + } const rollbar = new Rollbar({ accessToken: process.env.ROLLBAR_TOKEN, @@ -23,3 +31,34 @@ export default function getRollbar(): RollbarInterface { global.rollbar = rollbar; return rollbar; } + +export function reportDeploymentToRollbar() { + const deployId = process.env.DEPLOY_ID; + + if (!deployId) { + getRollbar().error("Missing deployId in environment variables"); + return; + } + + if (!process.env.ROLLBAR_TOKEN) { + console.warn("ROLLBAR_TOKEN is not set. Cannot report deployment."); + return; + } + + const url = "https://api.rollbar.com/api/1/deploy/" + deployId; + const options = { + method: "PATCH", + headers: { + accept: "application/json", + "content-type": "application/json", + "X-Rollbar-Access-Token": process.env.ROLLBAR_TOKEN, + }, + body: JSON.stringify({ + status: "succeeded", + }), + }; + + fetch(url, options) + .then(() => console.log("Deployment reported to Rollbar")) + .catch((err) => getRollbar().error(err)); +} diff --git a/lib/client/StatsMath.ts b/lib/client/StatsMath.ts index 855d0b68..6fd009f7 100644 --- a/lib/client/StatsMath.ts +++ b/lib/client/StatsMath.ts @@ -146,3 +146,33 @@ export function ComparativePercentMulti( return results; } + +//Takes a list of Quantitative reports and a stat and returns the minimum value recorded for said stat +export function GetMinimum( + quantitativeReports: Report[], + stat: string, +) { + if (!quantitativeReports) return 0; + let minimum = quantitativeReports[0].data[stat]; + for (let repo of quantitativeReports) { + if (repo.data[stat] < minimum) { + minimum = repo.data[stat]; + } + } + return minimum; +} + +//Takes a list of Quantitative reports and a stat and returns the maximum value recorded for said stat +export function GetMaximum( + quantitativeReports: Report[], + stat: string, +) { + if (!quantitativeReports) return 0; + let maximum = 0; + for (let repo of quantitativeReports) { + if (repo.data[stat] > maximum) { + maximum = repo.data[stat]; + } + } + return maximum; +} diff --git a/lib/games.ts b/lib/games.ts index fff434e3..756ddc0d 100644 --- a/lib/games.ts +++ b/lib/games.ts @@ -22,6 +22,7 @@ import { AmpAutoPoints, AmpTeleopPoints, BooleanAverage, + GetMinimum, MostCommonValue, NumericalTotal, Round, @@ -29,6 +30,8 @@ import { SpeakerTeleopPoints, TrapPoints, } from "./client/StatsMath"; +import { report } from "process"; +import { GetMaximum } from "./client/StatsMath"; function getBaseBadges( pitReport: Pitreport | undefined, @@ -1323,23 +1326,76 @@ namespace Reefscape { const statsLayout: StatsLayout = { sections: { Auto: [ - { key: "AutoMovedPastStaringLine", label: "Avg Auto Moves Past Start" }, { key: "AutoCoralScoredLevelOne", label: "Avg Amt Of Coral Scored Level One Auto", }, + { + label: "> Min Auto L1 Coral", + get(pitData, quantitativeReports) { + return GetMinimum(quantitativeReports!, "AutoCoralScoredLevelOne"); + }, + }, + { + label: "> Max Auto L1 Coral", + get(pitData, quantitativeReports) { + return GetMaximum(quantitativeReports!, "AutoCoralScoredLevelOne"); + }, + }, { key: "AutoCoralScoredLevelTwo", label: "Avg Amt Of Coral Scored Level Two Auto", }, + { + label: "> Min Auto L2 Coral", + get(pitData, quantitativeReports) { + return GetMinimum(quantitativeReports!, "AutoCoralScoredLevelTwo"); + }, + }, + { + label: "> Max Auto L2 Coral", + get(pitData, quantitativeReports) { + return GetMaximum(quantitativeReports!, "AutoCoralScoredLevelTwo"); + }, + }, { key: "AutoCoralScoredLevelThree", label: "Avg Amt Of Coral Scored Level Three Auto", }, + { + label: "> Min Auto L3 Coral", + get(pitData, quantitativeReports) { + return GetMinimum( + quantitativeReports!, + "AutoCoralScoredLevelThree", + ); + }, + }, + { + label: "> Max Auto L3 Coral", + get(pitData, quantitativeReports) { + return GetMaximum( + quantitativeReports!, + "AutoCoralScoredLevelThree", + ); + }, + }, { key: "AutoCoralScoredLevelFour", label: "Avg Amt Of Coral Scored Level Four Auto", }, + { + label: "> Min Auto L4 Coral", + get(pitData, quantitativeReports) { + return GetMinimum(quantitativeReports!, "AutoCoralScoredLevelFour"); + }, + }, + { + label: "> Max Auto L4 Coral", + get(pitData, quantitativeReports) { + return GetMaximum(quantitativeReports!, "AutoCoralScoredLevelFour"); + }, + }, { label: "Avg Auto Coral", get(pitData, quantitativeReports) { @@ -1362,33 +1418,140 @@ namespace Reefscape { key: "AutoAlgaeRemovedFromReef", label: "Avg Amt of Algae Removed From Reef", }, + { + label: "> Min Algae Removed From Reef", + get(pitData, quantitativeReports) { + return GetMinimum(quantitativeReports!, "AutoAlgaeRemovedFromReef"); + }, + }, + { + label: "> Max Algae Removed From Reef", + get(pitData, quantitativeReports) { + return GetMaximum(quantitativeReports!, "AutoAlgaeRemovedFromReef"); + }, + }, { key: "AutoAlgaeScoredProcessor", label: "Avg Amt of Algae Scored Processor Auto", }, + { + label: "> Min Algae Scored In Processor", + get(pitData, quantitativeReports) { + return GetMinimum(quantitativeReports!, "AutoAlgaeScoredProcessor"); + }, + }, + { + label: "> Max Algae Scored In Processor", + get(pitData, quantitativeReports) { + return GetMaximum(quantitativeReports!, "AutoAlgaeScoredProcessor"); + }, + }, { key: "AutoAlgaeScoredNet", label: "Avg Amt of Algae Scored Net Auto", }, + { + label: "> Min Algae Scored In Net", + get(pitData, quantitativeReports) { + return GetMinimum(quantitativeReports!, "AutoAlgaeScoredNet"); + }, + }, + { + label: "> Max Algae Scored In Net", + get(pitData, quantitativeReports) { + return GetMaximum(quantitativeReports!, "AutoAlgaeScoredNet"); + }, + }, ], Teleop: [ - { key: "GroundIntake", label: "Has Ground Intake?" }, { key: "TeleopCoralScoredLevelOne", label: "Avg Amt Of Coral Scored Level One Teleop", }, + { + label: "> Min L1 Coral Scored", + get(pitData, quantitativeReports) { + return GetMinimum( + quantitativeReports!, + "TeleopCoralScoredLevelOne", + ); + }, + }, + { + label: "> Max L1 Coral Scored", + get(pitData, quantitativeReports) { + return GetMaximum( + quantitativeReports!, + "TeleopCoralScoredLevelOne", + ); + }, + }, { key: "TeleopCoralScoredLevelTwo", label: "Avg Amt Of Coral Scored Level Two Teleop", }, + { + label: "> Min L2 Coral Scored", + get(pitData, quantitativeReports) { + return GetMinimum( + quantitativeReports!, + "TeleopCoralScoredLevelTwo", + ); + }, + }, + { + label: "> Max L2 Coral Scored", + get(pitData, quantitativeReports) { + return GetMaximum( + quantitativeReports!, + "TeleopCoralScoredLevelTwo", + ); + }, + }, { key: "TeleopCoralScoredLevelThree", label: "Avg Amt Of Coral Scored Level Three Teleop", }, + { + label: "> Min L3 Coral Scored", + get(pitData, quantitativeReports) { + return GetMinimum( + quantitativeReports!, + "TeleopCoralScoredLevelThree", + ); + }, + }, + { + label: "> Max L3 Coral Scored", + get(pitData, quantitativeReports) { + return GetMaximum( + quantitativeReports!, + "TeleopCoralScoredLevelThree", + ); + }, + }, { key: "TeleopCoralScoredLevelFour", label: "Avg Amt Of Coral Scored Level Four Teleop", }, + { + label: "> Min L4 Coral Scored", + get(pitData, quantitativeReports) { + return GetMinimum( + quantitativeReports!, + "TeleopCoralScoredLevelFour", + ); + }, + }, + { + label: "> Max L4 Coral Scored", + get(pitData, quantitativeReports) { + return GetMaximum( + quantitativeReports!, + "TeleopCoralScoredLevelFour", + ); + }, + }, { label: "Avg Teleop Coral", get(pitData, quantitativeReports) { @@ -1411,14 +1574,62 @@ namespace Reefscape { key: "TeleopAlgaeRemovedFromReef", label: "Avg Amt of Algae Removed From Reef", }, + { + label: "> Min Algae Removed From Reef", + get(pitData, quantitativeReports) { + return GetMinimum( + quantitativeReports!, + "TeleopAlgaeRemovedFromReef", + ); + }, + }, + { + label: "> Max Algae Removed From Reef", + get(pitData, quantitativeReports) { + return GetMaximum( + quantitativeReports!, + "TeleopAlgaeRemovedFromReef", + ); + }, + }, { key: "TeleopAlgaeScoredProcessor", label: "Avg Amt of Algae Scored Processor Teleop", }, + { + label: "> Min Algae Scored In Processor", + get(pitData, quantitativeReports) { + return GetMinimum( + quantitativeReports!, + "TeleopAlgaeScoredProcessor", + ); + }, + }, + { + label: "> Max Algae Scored In Processor", + get(pitData, quantitativeReports) { + return GetMaximum( + quantitativeReports!, + "TeleopAlgaeScoredProcessor", + ); + }, + }, { key: "TeleopAlgaeScoredNet", label: "Avg Amt of Algae Scored Net Teleop", }, + { + label: "> Min Algae Scored In Net", + get(pitData, quantitativeReports) { + return GetMinimum(quantitativeReports!, "TeleopAlgaeScoredNet"); + }, + }, + { + label: "> Max Algae Scored In Net", + get(pitData, quantitativeReports) { + return GetMaximum(quantitativeReports!, "TeleopAlgaeScoredNet"); + }, + }, ], }, getGraphDots: function ( @@ -1638,7 +1849,9 @@ namespace Reefscape { break; } - totalPoints += report.TeleopCoralScoredLevelOne * 2; + totalPoints += + (report.TeleopCoralScoredLevelOne + report.TeleopAlgaeScoredProcessor) * + 2; totalPoints += (report.AutoCoralScoredLevelOne + report.TeleopCoralScoredLevelTwo) * 3; totalPoints += @@ -1649,9 +1862,7 @@ namespace Reefscape { 4; totalPoints += report.TeleopCoralScoredLevelFour * 5; totalPoints += - (report.AutoAlgaeScoredProcessor + - report.TeleopAlgaeScoredProcessor + - report.AutoCoralScoredLevelThree) * + (report.AutoAlgaeScoredProcessor + report.AutoCoralScoredLevelThree) * 6; totalPoints += report.AutoCoralScoredLevelFour * 7; } diff --git a/lib/reportDeploymentToRollbar.ts b/lib/reportDeploymentToRollbar.ts deleted file mode 100644 index cfe4e577..00000000 --- a/lib/reportDeploymentToRollbar.ts +++ /dev/null @@ -1,27 +0,0 @@ -import getRollbar from "./client/RollbarUtils"; - -export default function reportDeploymentToRollbar() { - const deployId = process.env.DEPLOY_ID; - - if (!deployId) { - getRollbar().error("Missing gitSha or deployId in environment variables"); - return; - } - - const url = "https://api.rollbar.com/api/1/deploy/" + deployId; - const options = { - method: "PATCH", - headers: { - accept: "application/json", - "content-type": "application/json", - "X-Rollbar-Access-Token": process.env.ROLLBAR_TOKEN, - }, - body: JSON.stringify({ - status: "succeeded", - }), - }; - - fetch(url, options) - .then(() => console.log("Deployment reported to Rollbar")) - .catch((err) => getRollbar().error(err)); -} diff --git a/lib/testutils/JestSetup.ts b/lib/testutils/JestSetup.ts new file mode 100644 index 00000000..61989856 --- /dev/null +++ b/lib/testutils/JestSetup.ts @@ -0,0 +1,4 @@ +import { loadEnvConfig } from "@next/env"; + +const projectDir = process.cwd(); +loadEnvConfig(projectDir); diff --git a/lib/testutils/PlaywrightSetup.ts b/lib/testutils/PlaywrightSetup.ts new file mode 100644 index 00000000..d45ecea6 --- /dev/null +++ b/lib/testutils/PlaywrightSetup.ts @@ -0,0 +1,6 @@ +import { loadEnvConfig } from "@next/env"; + +export default function setup() { + const projectDir = process.cwd(); + loadEnvConfig(projectDir); +} diff --git a/lib/testutils/TestUtils.ts b/lib/testutils/TestUtils.ts index d7f80896..74dce5e5 100644 --- a/lib/testutils/TestUtils.ts +++ b/lib/testutils/TestUtils.ts @@ -17,6 +17,8 @@ import { ResendInterface } from "../ResendUtils"; import { SlackInterface } from "../SlackClient"; import { NextResponse } from "unified-api-nextjs"; import { RollbarInterface } from "../client/RollbarUtils"; +import { BrowserContext, Page } from "@playwright/test"; +import ClientApi from "../api/ClientApi"; export class TestRes extends NextResponse { status = jest.fn((code) => this); @@ -160,3 +162,71 @@ export async function createTestDocuments(db: DbInterface) { return { report, subjectiveReport, match, pitReport, comp, season, team }; } + +export namespace PlaywrightUtils { + export function getTestClientApi() { + const api = new ClientApi(); + + // Relative requests don't work in Playwright apparently + if ( + process.env.BASE_URL_FOR_PLAYWRIGHT && + !api.requestHelper.baseUrl.startsWith(process.env.BASE_URL_FOR_PLAYWRIGHT) + ) { + api.requestHelper.baseUrl = + process.env.BASE_URL_FOR_PLAYWRIGHT + api.requestHelper.baseUrl; + } + + return api; + } + + /** + * Will reload the page + */ + export async function signUp(page: Page) { + const { sessionToken, user } = await getTestClientApi().testSignIn(); + + if (!sessionToken || !user) { + throw new Error("Failed to sign in"); + } + + await signIn(page, sessionToken); + + return { + sessionToken, + user, + }; + } + + /** + * Will reload the page + */ + export async function signIn(page: Page, sessionToken: string) { + await page.context().addCookies([ + { + name: "next-auth.session-token", + value: sessionToken, + path: "/", + domain: "localhost", + sameSite: "Lax", + httpOnly: true, + secure: true, + expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // 1 day expiration + }, + ]); + + // It sometimes requires a reload and a fetch to get sign ins to register + await getUser(page); + await page.reload(); + } + + export async function getUser(page: Page) { + const res = await page.context().request.get("/api/auth/session"); + + if (res.ok()) { + const { user } = await res.json(); + return user as User; + } else { + throw new Error("Failed to get user"); + } + } +} diff --git a/lib/testutils/setup.ts b/lib/testutils/setup.ts deleted file mode 100644 index 967c95da..00000000 --- a/lib/testutils/setup.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as dotenv from "dotenv"; - -dotenv.config({ path: ".env.test" }); diff --git a/package-lock.json b/package-lock.json index 3536a2fc..71f9e500 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "sj3", - "version": "1.2.24", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sj3", - "version": "1.2.24", + "version": "1.3.2", "license": "CC BY-NC-SA 4.0", "dependencies": { "dependencies": "^0.0.1", "@next-auth/mongodb-adapter": "^1.1.3", - "@serwist/next": "^9.0.11", + "@serwist/next": "^9.0.13", "@types/http-proxy": "^1.17.15", "@types/react-dom": "18.3.1", "@types/socket.io-client": "^3.0.0", @@ -21,14 +21,14 @@ "bson": "^5.0.0", "dotenv": "^16.4.7", "eslint": "9.18.0", - "eslint-config-next": "15.2.2", + "eslint-config-next": "15.2.4", "formidable": "^3.5.2", - "jose": "^6.0.8", + "jose": "^6.0.10", "levenary": "^1.1.1", "minimongo": "^7.0.0", - "mongo-anywhere": "^1.1.11", + "mongo-anywhere": "^1.1.15", "mongodb": "^5.0.0", - "next": "^15.2.3", + "next": "^15.2.4", "next-auth": "^4.24.11", "next-seo": "^6.6.0", "omit-call-signature": "^1.0.15", @@ -42,21 +42,22 @@ "react-ga4": "^2.1.0", "react-google-recaptcha-v3": "^1.10.1", "react-hot-toast": "^2.5.1", - "react-icons": "^5.4.0", + "react-icons": "^5.5.0", "react-p5": "^1.4.1", "react-qr-code": "^2.0.15", - "resend": "^4.1.2", + "resend": "^4.2.0", "rollbar": "^2.26.4", "string-similarity-js": "^2.1.4", "ts-node": "^10.9.2", "tsx": "^4.19.3", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.9" + "unified-api-nextjs": "^1.1.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.17.0", + "@eslint/js": "^9.24.0", "@jest/globals": "^29.7.0", + "@playwright/test": "^1.51.1", "@types/formidable": "^3.4.5", "@types/jest": "^29.5.14", "@types/node": "^22.13.13", @@ -1263,9 +1264,11 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", + "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", + "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -1930,15 +1933,15 @@ } }, "node_modules/@next/env": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.3.tgz", - "integrity": "sha512-a26KnbW9DFEUsSxAxKBORR/uD9THoYoKbkpFywMN/AFvboTt94b8+g/07T8J6ACsdLag8/PDU60ov4rPxRAixw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", + "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.2.2.tgz", - "integrity": "sha512-1+BzokFuFQIfLaRxUKf2u5In4xhPV7tUgKcK53ywvFl6+LXHWHpFkcV7VNeKlyQKUotwiq4fy/aDNF9EiUp4RQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.2.4.tgz", + "integrity": "sha512-O8ScvKtnxkp8kL9TpJTTKnMqlkZnS+QxwoQnJwPGBxjBbzd6OVVPEJ5/pMNrktSyXQD/chEfzfFzYLM6JANOOQ==", "license": "MIT", "dependencies": { "fast-glob": "3.3.1" @@ -1973,9 +1976,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.3.tgz", - "integrity": "sha512-uaBhA8aLbXLqwjnsHSkxs353WrRgQgiFjduDpc7YXEU0B54IKx3vU+cxQlYwPCyC8uYEEX7THhtQQsfHnvv8dw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", + "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", "cpu": [ "arm64" ], @@ -1989,9 +1992,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.3.tgz", - "integrity": "sha512-pVwKvJ4Zk7h+4hwhqOUuMx7Ib02u3gDX3HXPKIShBi9JlYllI0nU6TWLbPT94dt7FSi6mSBhfc2JrHViwqbOdw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", + "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", "cpu": [ "x64" ], @@ -2005,9 +2008,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.3.tgz", - "integrity": "sha512-50ibWdn2RuFFkOEUmo9NCcQbbV9ViQOrUfG48zHBCONciHjaUKtHcYFiCwBVuzD08fzvzkWuuZkd4AqbvKO7UQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", + "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", "cpu": [ "arm64" ], @@ -2021,9 +2024,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.3.tgz", - "integrity": "sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", + "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", "cpu": [ "arm64" ], @@ -2037,9 +2040,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.3.tgz", - "integrity": "sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", + "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", "cpu": [ "x64" ], @@ -2053,9 +2056,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.3.tgz", - "integrity": "sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", + "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", "cpu": [ "x64" ], @@ -2069,9 +2072,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.3.tgz", - "integrity": "sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", + "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", "cpu": [ "arm64" ], @@ -2085,9 +2088,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.3.tgz", - "integrity": "sha512-gHYS9tc+G2W0ZC8rBL+H6RdtXIyk40uLiaos0yj5US85FNhbFEndMA2nW3z47nzOWiSvXTZ5kBClc3rD0zJg0w==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", + "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", "cpu": [ "x64" ], @@ -2132,11 +2135,6 @@ "node": ">= 8" } }, - "node_modules/@one-ini/wasm": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" - }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -2154,6 +2152,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "devOptional": true, + "dependencies": { + "playwright": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2194,12 +2207,13 @@ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" }, "node_modules/@react-email/render": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.1.tgz", - "integrity": "sha512-W3gTrcmLOVYnG80QuUp22ReIT/xfLsVJ+n7ghSlG2BITB8evNABn1AO2rGQoXuK84zKtDAlxCdm3hRyIpZdGSA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.5.tgz", + "integrity": "sha512-CA69HYXPk21HhtAXATIr+9JJwpDNmAFCvdMUjWmeoD1+KhJ9NAxusMRxKNeibdZdslmq3edaeOKGbdQ9qjK8LQ==", + "license": "MIT", "dependencies": { "html-to-text": "9.0.5", - "js-beautify": "^1.14.11", + "prettier": "3.4.2", "react-promise-suspense": "0.3.4" }, "engines": { @@ -2210,6 +2224,21 @@ "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-email/render/node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@restart/hooks": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", @@ -2278,6 +2307,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" @@ -2287,15 +2317,16 @@ } }, "node_modules/@serwist/build": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/@serwist/build/-/build-9.0.11.tgz", - "integrity": "sha512-mYBBpm6hWN40J/Sj2aNncVgl/o7Ch/CfyHHl3XvEecbowRuJNv8BfIch+Tvwlfd1cqBqNc65kCYvCL2XJms8DA==", + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@serwist/build/-/build-9.0.13.tgz", + "integrity": "sha512-Hoc6llxFmnsE8z5Cs95UmRRhRyoNh44OdrMWWPPX8BpW19z0CK/qnBquptjyBIe46jjoOxsPHK0Tt7oZOV4Mbw==", + "license": "MIT", "dependencies": { "common-tags": "1.8.2", "glob": "10.4.5", "pretty-bytes": "6.1.1", "source-map": "0.8.0-beta.0", - "zod": "3.23.8" + "zod": "3.24.2" }, "engines": { "node": ">=18.0.0" @@ -2313,6 +2344,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -2321,6 +2353,7 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -2340,6 +2373,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -2354,6 +2388,7 @@ "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "license": "BSD-3-Clause", "dependencies": { "whatwg-url": "^7.0.0" }, @@ -2365,6 +2400,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "license": "MIT", "dependencies": { "punycode": "^2.1.0" } @@ -2372,12 +2408,14 @@ "node_modules/@serwist/build/node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "license": "BSD-2-Clause" }, "node_modules/@serwist/build/node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "license": "MIT", "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", @@ -2385,17 +2423,18 @@ } }, "node_modules/@serwist/next": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/@serwist/next/-/next-9.0.11.tgz", - "integrity": "sha512-Aa26qgJxnaxbGK8JYAWqAPeDcyfu0xjYUPpvSfc3d9GrIl9G9cdflkcsvcbmJGnOb3Oc4tHN4wncYO6cIKb2wQ==", - "dependencies": { - "@serwist/build": "9.0.11", - "@serwist/webpack-plugin": "9.0.11", - "@serwist/window": "9.0.11", - "chalk": "5.3.0", + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@serwist/next/-/next-9.0.13.tgz", + "integrity": "sha512-nI2N4oSEHJGH0YUsE4m1obPfudO3DZ/pXiHPaisw+28YjgkMqD6ePfhzeHGO07ahPmIUiyHca9VNO8OfarbQ1Q==", + "license": "MIT", + "dependencies": { + "@serwist/build": "9.0.13", + "@serwist/webpack-plugin": "9.0.13", + "@serwist/window": "9.0.13", + "chalk": "5.4.1", "glob": "10.4.5", - "serwist": "9.0.11", - "zod": "3.23.8" + "serwist": "9.0.13", + "zod": "3.24.2" }, "engines": { "node": ">=18.0.0" @@ -2419,9 +2458,10 @@ } }, "node_modules/@serwist/next/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -2463,13 +2503,14 @@ } }, "node_modules/@serwist/webpack-plugin": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/@serwist/webpack-plugin/-/webpack-plugin-9.0.11.tgz", - "integrity": "sha512-ZTxVJqv7nKlPWmhC3oMt7tZc0ssnd7SYr6L8zZLLF7ltJUub9TMyh1K1zL5TJtsr8DyfALNFPwPA/s2yjYSk+w==", + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@serwist/webpack-plugin/-/webpack-plugin-9.0.13.tgz", + "integrity": "sha512-Z+Eve8dckM2FulRCa7Cj7VCF3EOP7QkRA76tI742olF7J2sYZSm3t9Ex13jDxTMmYiU1AxLq6V6gEMIdRAetVw==", + "license": "MIT", "dependencies": { - "@serwist/build": "9.0.11", + "@serwist/build": "9.0.13", "pretty-bytes": "6.1.1", - "zod": "3.23.8" + "zod": "3.24.2" }, "engines": { "node": ">=18.0.0" @@ -2488,12 +2529,13 @@ } }, "node_modules/@serwist/window": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/@serwist/window/-/window-9.0.11.tgz", - "integrity": "sha512-hUJVNxZbqfnJbUTfuJew7SR3f1KBIv/4nwmP1YRxjR6EMthUfN4bVSLzWLW8/u3h5bNaVh7sOhopUVC8m1HTCQ==", + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@serwist/window/-/window-9.0.13.tgz", + "integrity": "sha512-Cf3RizPuFInDcLt0P1Y5QzG1sA5mW131/PZfMYE3yBuNUSGNgOQGlYuLdwDOWPHgECYoVb/da8pspdQNKs0O5g==", + "license": "MIT", "dependencies": { "@types/trusted-types": "2.0.7", - "serwist": "9.0.11" + "serwist": "9.0.13" }, "peerDependencies": { "typescript": ">=5.0.0" @@ -3012,7 +3054,8 @@ "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" }, "node_modules/@types/warning": { "version": "3.0.3", @@ -3063,14 +3106,6 @@ "react-dom": "^17 || ^18 || ^19" } }, - "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -4069,6 +4104,7 @@ "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "license": "MIT", "engines": { "node": ">=4.0.0" } @@ -4078,15 +4114,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, "node_modules/console-polyfill": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/console-polyfill/-/console-polyfill-0.3.0.tgz", @@ -4492,6 +4519,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -4510,12 +4538,14 @@ "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -4530,6 +4560,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -4564,53 +4595,6 @@ "safer-buffer": "^2.1.0" } }, - "node_modules/editorconfig": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", - "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", - "dependencies": { - "@one-ini/wasm": "0.1.1", - "commander": "^10.0.0", - "minimatch": "9.0.1", - "semver": "^7.5.3" - }, - "bin": { - "editorconfig": "bin/editorconfig" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/editorconfig/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "engines": { - "node": ">=14" - } - }, - "node_modules/editorconfig/node_modules/minimatch": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", - "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4686,6 +4670,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -4982,12 +4967,12 @@ } }, "node_modules/eslint-config-next": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.2.2.tgz", - "integrity": "sha512-g34RI7RFS4HybYFwGa/okj+8WZM+/fy+pEM+aqRQoVvM4gQhKrd4wIEddKmlZfWD75j8LTwB5zwkmNv3DceH1A==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.2.4.tgz", + "integrity": "sha512-v4gYjd4eYIme8qzaJItpR5MMBXJ0/YV07u7eb50kEnlEmX7yhOjdUdzz70v4fiINYRjLf8X8TbogF0k7wlz6sA==", "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.2.2", + "@next/eslint-plugin-next": "15.2.4", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", @@ -5499,6 +5484,15 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", + "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", @@ -6243,6 +6237,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", @@ -6265,6 +6260,7 @@ "url": "https://github.com/sponsors/fb55" } ], + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", @@ -6296,9 +6292,10 @@ } }, "node_modules/idb": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", - "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==" + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.2.tgz", + "integrity": "sha512-CX70rYhx7GDDQzwwQMDwF6kDRQi5vVs6khHUumDrMecBylKkwvZ8HWvKV08AGb7VbpoGCWUQ4aHzNDgoUiOIUg==", + "license": "ISC" }, "node_modules/idb-wrapper": { "version": "1.7.2", @@ -6373,11 +6370,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, "node_modules/internal-slot": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", @@ -7828,9 +7820,9 @@ } }, "node_modules/jose": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.8.tgz", - "integrity": "sha512-EyUPtOKyTYq+iMOszO42eobQllaIjJnwkZ2U93aJzNyPibCy7CEvT9UQnaCVB51IAd49gbNdCew1c0LcLTCB2g==", + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.10.tgz", + "integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -7841,75 +7833,6 @@ "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" }, - "node_modules/js-beautify": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz", - "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==", - "dependencies": { - "config-chain": "^1.1.13", - "editorconfig": "^1.0.4", - "glob": "^10.3.3", - "js-cookie": "^3.0.5", - "nopt": "^7.2.0" - }, - "bin": { - "css-beautify": "js/bin/css-beautify.js", - "html-beautify": "js/bin/html-beautify.js", - "js-beautify": "js/bin/js-beautify.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/js-beautify/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/js-beautify/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/js-beautify/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "engines": { - "node": ">=14" - } - }, "node_modules/js-sha1": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/js-sha1/-/js-sha1-0.6.0.tgz", @@ -8062,6 +7985,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", "funding": { "url": "https://ko-fi.com/killymxi" } @@ -8148,7 +8072,8 @@ "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", @@ -8306,9 +8231,9 @@ } }, "node_modules/mongo-anywhere": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/mongo-anywhere/-/mongo-anywhere-1.1.11.tgz", - "integrity": "sha512-wM5FMS7sj6vZAEw9XaRaWFYqNeA8slKcQxxWdhyN4a8xGBrOxN2U4wF33kuAkYTgqsdy5fxS3NSF4KkQAUq5Zg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/mongo-anywhere/-/mongo-anywhere-1.1.15.tgz", + "integrity": "sha512-wN6E/jN0lae5EqAeaAaE5fdUdb+ZchZKib3FWGOOOQUYZvTv2ino9Aii3GK+uIMxNdnm6N//B0bEGEeCNbBy1g==", "dependencies": { "bson": "^5.0.0", "minimongo": "^6.19.0", @@ -8431,12 +8356,12 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, "node_modules/next": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.3.tgz", - "integrity": "sha512-x6eDkZxk2rPpu46E1ZVUWIBhYCLszmUY6fvHBFcbzJ9dD+qRX6vcHusaqqDlnY+VngKzKbAiG2iRCkPbmi8f7w==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", + "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", "license": "MIT", "dependencies": { - "@next/env": "15.2.3", + "@next/env": "15.2.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", @@ -8451,14 +8376,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.2.3", - "@next/swc-darwin-x64": "15.2.3", - "@next/swc-linux-arm64-gnu": "15.2.3", - "@next/swc-linux-arm64-musl": "15.2.3", - "@next/swc-linux-x64-gnu": "15.2.3", - "@next/swc-linux-x64-musl": "15.2.3", - "@next/swc-win32-arm64-msvc": "15.2.3", - "@next/swc-win32-x64-msvc": "15.2.3", + "@next/swc-darwin-arm64": "15.2.4", + "@next/swc-darwin-x64": "15.2.4", + "@next/swc-linux-arm64-gnu": "15.2.4", + "@next/swc-linux-arm64-musl": "15.2.4", + "@next/swc-linux-x64-gnu": "15.2.4", + "@next/swc-linux-x64-musl": "15.2.4", + "@next/swc-win32-arm64-msvc": "15.2.4", + "@next/swc-win32-x64-msvc": "15.2.4", "sharp": "^0.33.5" }, "peerDependencies": { @@ -8604,20 +8529,6 @@ "node": ">=6.0.0" } }, - "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8940,6 +8851,7 @@ "version": "0.12.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" @@ -9002,6 +8914,7 @@ "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", "funding": { "url": "https://ko-fi.com/killymxi" } @@ -9109,6 +9022,50 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "devOptional": true, + "dependencies": { + "playwright-core": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "devOptional": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/polygon-clipping": { "version": "0.15.7", "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz", @@ -9318,6 +9275,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "license": "MIT", "engines": { "node": "^14.13.1 || >=16.0.0" }, @@ -9365,11 +9323,6 @@ "react": ">=0.14.0" } }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==" - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -9603,9 +9556,10 @@ } }, "node_modules/react-icons": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", - "integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", "peerDependencies": { "react": "*" } @@ -9644,6 +9598,7 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^2.0.1" } @@ -9651,7 +9606,8 @@ "node_modules/react-promise-suspense/node_modules/fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==" + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" }, "node_modules/react-qr-code": { "version": "2.0.15", @@ -9850,12 +9806,12 @@ } }, "node_modules/resend": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/resend/-/resend-4.1.2.tgz", - "integrity": "sha512-km0btrAj/BqIaRlS+SoLNMaCAUUWEgcEvZpycfVvoXEwAHCxU+vp/ikxPgKRkyKyiR2iDcdUq5uIBTDK9oSSSQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-4.2.0.tgz", + "integrity": "sha512-s6ogU+BBYH1H6Zl926cpddtLRAJWeFv5cIKcuHLWk1QYhFTbpFJlhqx31pnN2f0CB075KFSrc1Xf6HG690wzuw==", "license": "MIT", "dependencies": { - "@react-email/render": "1.0.1" + "@react-email/render": "1.0.5" }, "engines": { "node": ">=18" @@ -10065,6 +10021,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", "dependencies": { "parseley": "^0.12.0" }, @@ -10085,11 +10042,12 @@ } }, "node_modules/serwist": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/serwist/-/serwist-9.0.11.tgz", - "integrity": "sha512-oUuyOuIFZoj+ZYdwWnPYVsndOw5CoGsTy106mHJ2wxjnz01TrDhg2l26yrsoJek1qT/Doi0pzknYLLXTIFs5og==", + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/serwist/-/serwist-9.0.13.tgz", + "integrity": "sha512-BF3bmzYdOVT2lF3iHV0044NqTO6q6GAiqrYpc7L9EPYQXZHOy22WajKaHLvCdvpm2Jpji4SsxUL8/uC1WSCZ5g==", + "license": "MIT", "dependencies": { - "idb": "8.0.0" + "idb": "8.0.2" }, "peerDependencies": { "typescript": ">=5.0.0" @@ -11208,20 +11166,20 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, "node_modules/unified-api": { - "version": "1.0.19", - "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.0.19.tgz", - "integrity": "sha512-8fQ/fnOTHtEzNTQPPQ+vlFP3jq4vje+7D8adn1yh2TETIkEvgaVvxFbfG4BKyYyp1ZRJxvLH42kYz14WlU8/6A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/unified-api/-/unified-api-1.1.4.tgz", + "integrity": "sha512-g9ATByEgoQq2HVrF2X4DYCPW2xqSw+xBh3n6SoqCXwuN6fj6P31lFViaK1yHu9iy2mTHHdQW6IApj+KKNrbShg==", "dependencies": { "omit-call-signature": "^1.0.16" } }, "node_modules/unified-api-nextjs": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.0.10.tgz", - "integrity": "sha512-EkW0g3shBuLkbCkX/I3uQHq+ug31rzsHNGEXnOZc2UHypaD5OfQjKV5irwwGtc0iucwYzkDvKKd8aX6dgXXTkQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/unified-api-nextjs/-/unified-api-nextjs-1.1.3.tgz", + "integrity": "sha512-6y9eMJgJArjJ+K9XpTx2IXic1HQFWYyixPnwZUe07zH1DEMTbq57oOrPHon8kqYT2PUbbyy2P47JjmXkaGxNjA==", "dependencies": { "next": "^15.1.2", - "unified-api": "^1.0.19" + "unified-api": "^1.1.4" } }, "node_modules/update-browserslist-db": { @@ -11692,9 +11650,10 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index fc646276..9c4bdceb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sj3", - "version": "1.2.24", + "version": "1.3.2", "private": true, "repository": "https://github.com/Decatur-Robotics/Gearbox", "license": "CC BY-NC-SA 4.0", @@ -10,6 +10,8 @@ "start": "cross-env NODE_ENV=production npx tsx index.ts", "restart": "next build && cross-env NODE_ENV=production npx tsx index.ts", "test": "jest", + "e2e": "cross-env NODE_ENV=test playwright test", + "e2e-start-server": "cross-env NODE_ENV=test next build && cross-env NODE_ENV=test npx tsx index.ts", "docker-build": "docker build -t gearbox .", "docker-start": "docker run -i -t -p 80:80 gearbox", "docker-prune": "docker container prune", @@ -19,7 +21,7 @@ }, "dependencies": { "@next-auth/mongodb-adapter": "^1.1.3", - "@serwist/next": "^9.0.11", + "@serwist/next": "^9.0.13", "@types/http-proxy": "^1.17.15", "@types/react-dom": "18.3.1", "@types/socket.io-client": "^3.0.0", @@ -30,14 +32,14 @@ "dependencies": "^0.0.1", "dotenv": "^16.4.7", "eslint": "9.18.0", - "eslint-config-next": "15.2.2", + "eslint-config-next": "15.2.4", "formidable": "^3.5.2", - "jose": "^6.0.8", + "jose": "^6.0.10", "levenary": "^1.1.1", "minimongo": "^7.0.0", - "mongo-anywhere": "^1.1.11", + "mongo-anywhere": "^1.1.15", "mongodb": "^5.0.0", - "next": "^15.2.3", + "next": "^15.2.4", "next-auth": "^4.24.11", "next-seo": "^6.6.0", "omit-call-signature": "^1.0.15", @@ -51,21 +53,22 @@ "react-ga4": "^2.1.0", "react-google-recaptcha-v3": "^1.10.1", "react-hot-toast": "^2.5.1", - "react-icons": "^5.4.0", + "react-icons": "^5.5.0", "react-p5": "^1.4.1", "react-qr-code": "^2.0.15", - "resend": "^4.1.2", + "resend": "^4.2.0", "rollbar": "^2.26.4", "string-similarity-js": "^2.1.4", "ts-node": "^10.9.2", "tsx": "^4.19.3", "typescript": "5.7.3", - "unified-api-nextjs": "^1.0.9" + "unified-api-nextjs": "^1.1.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.17.0", + "@eslint/js": "^9.24.0", "@jest/globals": "^29.7.0", + "@playwright/test": "^1.51.1", "@types/formidable": "^3.4.5", "@types/jest": "^29.5.14", "@types/node": "^22.13.13", diff --git a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx index 3a01e4ee..ecb101e1 100644 --- a/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx +++ b/pages/[teamSlug]/[seasonSlug]/[competitonSlug]/index.tsx @@ -405,7 +405,13 @@ export default function CompetitionIndex({ >
- + - Delete Season +

+ Delete +
Season +

)}
diff --git a/pages/profile.tsx b/pages/profile.tsx index b3db52db..c2f74d32 100644 --- a/pages/profile.tsx +++ b/pages/profile.tsx @@ -22,6 +22,8 @@ import { signOut } from "next-auth/react"; import XpProgressBar from "@/components/XpProgressBar"; import { HiPencilAlt } from "react-icons/hi"; import toast from "react-hot-toast"; +import EditAvatarModal from "@/components/EditAvatarModal"; +import { close } from "fs"; const api = new ClientApi(); @@ -43,6 +45,8 @@ export default function Profile(props: { teamList: Team[] }) { const [editingName, setEditingName] = useState(false); const [newName, setNewName] = useState(); + const [editingAvatar, setEditingAvatar] = useState(false); + useEffect(() => { const loadTeams = async () => { setLoadingTeams(true); @@ -69,6 +73,10 @@ export default function Profile(props: { teamList: Team[] }) { Analytics.requestedToJoinTeam(teamNumber, user?.name ?? "Unknown User"); }; + async function toggleEditingAvatarModal() { + setEditingAvatar(!editingAvatar); + } + async function toggleEditingName() { setEditingName(!editingName); @@ -91,7 +99,7 @@ export default function Profile(props: { teamList: Team[] }) { hideMenu={false} title="Profile" > - + {/* */} setNewName(e.target.value)} defaultValue={newName} + placeholder="New Name" className="input" /> ) : (

{user?.name}

)}
+ {editingAvatar && ( + + )} ); } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..a8c50cd6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,84 @@ +import { defineConfig, devices } from "@playwright/test"; +import { loadEnvConfig } from "@next/env"; + +const projectDir = process.cwd(); +loadEnvConfig(projectDir); + +const baseURL = process.env.BASE_URL_FOR_PLAYWRIGHT; // Default base URL for tests + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests/e2e", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 5 : 0, + repeatEach: process.env.CI ? 5 : 1, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 4 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? "blob" : "html", + + globalSetup: require.resolve("./lib/testutils/PlaywrightSetup"), + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + video: "retain-on-failure", // Record video only for failed tests + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"], baseURL }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"], baseURL }, + }, + + { + name: "webkit", + use: { ...devices["Desktop Safari"], baseURL }, + }, + + /* Test against mobile viewports. */ + { + name: "Mobile Chrome", + use: { ...devices["Pixel 5"], baseURL }, + }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 12"], baseURL }, + }, + + /* Test against branded browsers. */ + { + name: "Microsoft Edge", + use: { ...devices["Desktop Edge"], channel: "msedge", baseURL }, + }, + { + name: "Google Chrome", + use: { ...devices["Desktop Chrome"], channel: "chrome", baseURL }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "npm run e2e-start-server", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 5 * 60 * 1000, // 5 minutes, + stdout: "pipe", + }, +}); diff --git a/tests/e2e/index.spec.ts b/tests/e2e/index.spec.ts new file mode 100644 index 00000000..c6c0cecb --- /dev/null +++ b/tests/e2e/index.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from "@playwright/test"; + +test("Has title", async ({ page }) => { + await page.goto("/"); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Gearbox/i); +}); + +test("Has Gearbox header", async ({ page }) => { + await page.goto("/"); + + // Expect an h1 "to contain" a substring. + await expect( + page.getByRole("heading", { name: "Gearbox" }).first(), + ).toBeVisible(); +}); + +test("Has get started link", async ({ page }) => { + await page.goto("/"); + + // Click the get started link. + await expect(page.getByRole("link", { name: "Get started" })).toHaveText( + "Get Started", + ); +}); + +test("Has build time", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText(/Build Time:/i).first()).toBeVisible(); + + expect( + await page + .getByText(/Build Time:/i) + .first() + .textContent() + .then((text) => { + const timeString = text!.split(": ")[1]; + const time = new Date(timeString); + + return time.getTime(); + }), + ).toBeGreaterThan(new Date().getTime() - 60 * 15 * 1000); +}); + +test("Has link to Decatur Robotics website", async ({ page }) => { + await page.goto("/"); + + await expect( + page.locator("a[href='https://www.decaturrobotics.org/our-team']"), + ).toBeVisible(); +}); diff --git a/tests/e2e/misc.spec.ts b/tests/e2e/misc.spec.ts new file mode 100644 index 00000000..2dbe3fae --- /dev/null +++ b/tests/e2e/misc.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from "@playwright/test"; +import { PlaywrightUtils } from "@/lib/testutils/TestUtils"; + +test("Sign up function signs up", async ({ page, context }) => { + const { user } = await PlaywrightUtils.signUp(page); + + const sessionToken = await context + .cookies() + .then( + (cookies) => + cookies.find((cookie) => cookie.name === "next-auth.session-token") + ?.value, + ); + + expect(sessionToken).toBeDefined(); + expect(sessionToken).not.toBe(""); + + const foundUser = await PlaywrightUtils.getUser(page); + if (foundUser) foundUser.id = user.id; // ID mismatches are normal + + expect(foundUser).toEqual(user as any); +}); diff --git a/tests/e2e/profile.spec.ts b/tests/e2e/profile.spec.ts new file mode 100644 index 00000000..5150bd14 --- /dev/null +++ b/tests/e2e/profile.spec.ts @@ -0,0 +1,134 @@ +import Card from "@/components/Card"; +import { PlaywrightUtils } from "@/lib/testutils/TestUtils"; +import { test, expect } from "@playwright/test"; + +const poSans = + '"https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSSZk-p-TpJAKV0GsfBa3EJPWWjQPxcVTB5Rg&s"'; +test("Displays sign in page when not signed in", async ({ page }) => { + await page.goto("/profile"); + + await expect( + page.getByRole("heading", { name: /sign in/i }).first(), + ).toBeVisible(); +}); + +test("Displays user information when signed in", async ({ page }) => { + const { user } = await PlaywrightUtils.signUp(page); + + await page.goto("/profile"); + + await expect( + page.getByRole("heading", { name: /sign in/i }).first(), + ).not.toBeVisible(); + + await expect(page.getByText(user.email!)).toBeVisible(); + await expect(page.getByText(user.slug!)).toBeVisible(); + await expect(page.getByText(new RegExp(user.name!))).toBeVisible(); +}); + +test.describe("Edit user name", () => { + test("Allows user to edit their name", async ({ page }) => { + await PlaywrightUtils.signUp(page); + + await page.goto("/profile"); + + const editButton = page.getByTestId("edit-name-button"); + await editButton.click(); + + const nameInput = page.getByPlaceholder(/new name/i); + await expect(nameInput).toBeVisible(); + + await nameInput.fill("New Name"); + await editButton.click(); + + await expect(page.getByText("New Name")).toBeVisible(); + }); +}); + +test.describe("Edit Avatar", () => { + test("Edit Avatar button displays popup", async ({ page }) => { + await PlaywrightUtils.signUp(page); + + await page.goto("/profile"); + + const editAvatarButton = page.getByRole("button", { name: "Edit Avatar" }); + await editAvatarButton.click(); + + const editAvatarPopup = page.getByTitle("Edit Avatar"); + await expect(editAvatarPopup).toBeVisible(); + }); + + test("Cancel button closes popup", async ({ page }) => { + await PlaywrightUtils.signUp(page); + + await page.goto("/profile"); + + //Edit Avatar button + await page.getByRole("button", { name: "Edit Avatar" }).click(); + + //Cancel button + await page.getByRole("button", { name: "Cancel" }).click(); + + //Edit Avatar popup + await expect(page.getByTitle("Edit Avatar")).not.toBeVisible(); + }); + + test("Preview Image recognizes image in field", async ({ page }) => { + await PlaywrightUtils.signUp(page); + + await page.goto("/profile"); + + //Edit Avatar button + await page.getByRole("button", { name: "Edit Avatar" }).click(); + + //Enter avatar url input + await page.getByPlaceholder("Enter new avatar url").fill(poSans); + + //Avatar preview + await expect(page.getByAltText("New Avatar")).toHaveAttribute( + "src", + poSans, + ); + }); + + test("Save button saves new avatar", async ({ page }) => { + await PlaywrightUtils.signUp(page); + + await page.goto("/profile"); + + //Edit Avatar button + await page.getByRole("button", { name: "Edit Avatar" }).click(); + + //Enter avatar url input + await page.getByPlaceholder("Enter new avatar url").fill(poSans); + + //Save button + await page.getByRole("button", { name: "Save" }).click(); + + //Url of user's avatar + await expect(page.getByAltText("Avatar").first()).toHaveAttribute( + "src", + poSans, + ); + }); + + test("Cancel button does not save new avatar", async ({ page }) => { + const currentAvatar = (await PlaywrightUtils.signUp(page)).user.image; + + await page.goto("/profile"); + + //Edit Avatar button + await page.getByRole("button", { name: "Edit Avatar" }).click(); + + //Enter avatar url input + await page.getByPlaceholder("Enter new avatar url").fill(poSans); + + //Cancel button + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByAltText("Avatar").first()).toHaveAttribute( + "src", + currentAvatar, + ); + }); +}); diff --git a/tests/lib/CompetitionHandling.test.ts b/tests/unit/lib/CompetitionHandling.test.ts similarity index 99% rename from tests/lib/CompetitionHandling.test.ts rename to tests/unit/lib/CompetitionHandling.test.ts index 8e38fc33..6b1e7162 100644 --- a/tests/lib/CompetitionHandling.test.ts +++ b/tests/unit/lib/CompetitionHandling.test.ts @@ -13,8 +13,8 @@ import { Match, Report, Team, + MatchType, } from "@/lib/Types"; -import { MatchType } from "../../lib/Types"; import { ObjectId } from "bson"; import { GameId } from "@/lib/client/GameId"; diff --git a/tests/lib/DbInterfaceAuthAdapter.test.ts b/tests/unit/lib/DbInterfaceAuthAdapter.test.ts similarity index 100% rename from tests/lib/DbInterfaceAuthAdapter.test.ts rename to tests/unit/lib/DbInterfaceAuthAdapter.test.ts diff --git a/tests/lib/Layout.test.ts b/tests/unit/lib/Layout.test.ts similarity index 100% rename from tests/lib/Layout.test.ts rename to tests/unit/lib/Layout.test.ts diff --git a/tests/lib/TheOrangeAlliance.test.ts b/tests/unit/lib/TheOrangeAlliance.test.ts similarity index 100% rename from tests/lib/TheOrangeAlliance.test.ts rename to tests/unit/lib/TheOrangeAlliance.test.ts diff --git a/tests/lib/Types.test.ts b/tests/unit/lib/Types.test.ts similarity index 100% rename from tests/lib/Types.test.ts rename to tests/unit/lib/Types.test.ts diff --git a/tests/lib/Utils.test.ts b/tests/unit/lib/Utils.test.ts similarity index 100% rename from tests/lib/Utils.test.ts rename to tests/unit/lib/Utils.test.ts diff --git a/tests/lib/Xp.test.ts b/tests/unit/lib/Xp.test.ts similarity index 100% rename from tests/lib/Xp.test.ts rename to tests/unit/lib/Xp.test.ts diff --git a/tests/lib/api/AccessLevels.test.ts b/tests/unit/lib/api/AccessLevels.test.ts similarity index 100% rename from tests/lib/api/AccessLevels.test.ts rename to tests/unit/lib/api/AccessLevels.test.ts diff --git a/tests/lib/api/ApiUtils.test.ts b/tests/unit/lib/api/ApiUtils.test.ts similarity index 100% rename from tests/lib/api/ApiUtils.test.ts rename to tests/unit/lib/api/ApiUtils.test.ts diff --git a/tests/lib/api/ClientApi.test.ts b/tests/unit/lib/api/ClientApi.test.ts similarity index 95% rename from tests/lib/api/ClientApi.test.ts rename to tests/unit/lib/api/ClientApi.test.ts index 24b6c6ad..a10478de 100644 --- a/tests/lib/api/ClientApi.test.ts +++ b/tests/unit/lib/api/ClientApi.test.ts @@ -1107,3 +1107,58 @@ describe(`${ClientApi.name}.${api.changeTeamNumberForReport.name}`, () => { ); }); }); + +describe(`${ClientApi.name}.${api.testSignIn.name}`, () => { + test("Returns user", async () => { + const { db, res } = await getTestApiUtils(); + + await api.testSignIn.handler(...(await getTestApiParams(res, { db }, []))); + + const { user } = res.send.mock.calls[0][0] as { + user: User; + }; + + expect(user).toBeDefined(); + expect(user._id).toBeDefined(); + expect( + await db.findObjectById(CollectionId.Users, new ObjectId(user._id!)), + ).toEqual(user); + }); + + test("Returns valid sessionToken", async () => { + const { db, res } = await getTestApiUtils(); + + await api.testSignIn.handler(...(await getTestApiParams(res, { db }, []))); + + const { sessionToken } = res.send.mock.calls[0][0] as { + sessionToken: string; + }; + + expect(sessionToken).toBeDefined(); + expect(sessionToken).not.toBe(""); + + const session = await db.findObject(CollectionId.Sessions, { + sessionToken, + }); + expect(session).toBeDefined(); + }); + + test("Session has correct userId", async () => { + const { db, res } = await getTestApiUtils(); + + await api.testSignIn.handler(...(await getTestApiParams(res, { db }, []))); + + const { user, sessionToken } = res.send.mock.calls[0][0] as { + user: User; + sessionToken: string; + }; + + expect(sessionToken).toBeDefined(); + + const session = await db.findObject(CollectionId.Sessions, { + sessionToken, + }); + expect(session).toBeDefined(); + expect(session?.userId).toEqual(user._id); + }); +}); diff --git a/tests/lib/client/ClientUtils.test.ts b/tests/unit/lib/client/ClientUtils.test.ts similarity index 100% rename from tests/lib/client/ClientUtils.test.ts rename to tests/unit/lib/client/ClientUtils.test.ts diff --git a/tests/lib/client/InputVerification.test.ts b/tests/unit/lib/client/InputVerification.test.ts similarity index 100% rename from tests/lib/client/InputVerification.test.ts rename to tests/unit/lib/client/InputVerification.test.ts diff --git a/tests/lib/client/Picklist.test.ts b/tests/unit/lib/client/Picklist.test.ts similarity index 100% rename from tests/lib/client/Picklist.test.ts rename to tests/unit/lib/client/Picklist.test.ts diff --git a/tests/lib/client/StatsMath.test.ts b/tests/unit/lib/client/StatsMath.test.ts similarity index 100% rename from tests/lib/client/StatsMath.test.ts rename to tests/unit/lib/client/StatsMath.test.ts diff --git a/tests/lib/dev/FakeData.test.ts b/tests/unit/lib/dev/FakeData.test.ts similarity index 100% rename from tests/lib/dev/FakeData.test.ts rename to tests/unit/lib/dev/FakeData.test.ts diff --git a/tests/lib/slugToId.test.ts b/tests/unit/lib/slugToId.test.ts similarity index 100% rename from tests/lib/slugToId.test.ts rename to tests/unit/lib/slugToId.test.ts