diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..39bf3efc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Unit tests + run: npm test + + - name: Deploy Convex preview + run: | + npx convex deploy \ + --preview-create "pr-${{ github.event.pull_request.number }}-${{ github.run_attempt }}" \ + --cmd-url-env-var-name VITE_CONVEX_URL \ + --cmd 'echo "VITE_CONVEX_URL=$VITE_CONVEX_URL" >> "$GITHUB_ENV"' + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - name: Configure preview deployment environment + run: | + PREVIEW="pr-${{ github.event.pull_request.number }}-${{ github.run_attempt }}" + npx convex env set --preview-name "$PREVIEW" -- JWT_PRIVATE_KEY "$JWT_PRIVATE_KEY" + npx convex env set --preview-name "$PREVIEW" -- JWKS "$JWKS" + npx convex env set --preview-name "$PREVIEW" -- AUTH_RESEND_KEY "$AUTH_RESEND_KEY" + npx convex env set --preview-name "$PREVIEW" -- SITE_URL "http://localhost:5173" + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + JWT_PRIVATE_KEY: ${{ secrets.CONVEX_JWT_PRIVATE_KEY }} + JWKS: ${{ secrets.CONVEX_JWKS }} + AUTH_RESEND_KEY: ${{ secrets.CONVEX_AUTH_RESEND_KEY }} + + - name: Install Playwright browsers + run: npx playwright install firefox --with-deps + + - name: E2E tests + run: npm run test:e2e + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + CONVEX_PREVIEW_NAME: pr-${{ github.event.pull_request.number }}-${{ github.run_attempt }} + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/pr-closed.yml b/.github/workflows/pr-closed.yml index ef0d4789..14f139b8 100644 --- a/.github/workflows/pr-closed.yml +++ b/.github/workflows/pr-closed.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Update project status - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ secrets.PROJECT_AUTOMATION_TOKEN }} script: | diff --git a/.github/workflows/pr-opened.yml b/.github/workflows/pr-opened.yml index 7ffea9c0..e978e190 100644 --- a/.github/workflows/pr-opened.yml +++ b/.github/workflows/pr-opened.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Extract issue number and update PR body - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const branch = context.payload.pull_request.head.ref; @@ -37,7 +37,7 @@ jobs: console.log(`Replaced #??? with #${issueNumber}`); - name: Update project status to In Review - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ secrets.PROJECT_AUTOMATION_TOKEN }} script: | diff --git a/.gitignore b/.gitignore index 575b20b5..6cbd4973 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ pnpm-debug.log* lerna-debug.log* coverage +playwright-report +test-results +test/.auth node_modules dist dist-ssr diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index d8e6df45..b1600434 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -11,6 +11,10 @@ import type * as _fixtures_createMockTournament from "../_fixtures/createMockTournament.js"; import type * as _fixtures_createMockTournamentCompetitor from "../_fixtures/createMockTournamentCompetitor.js"; import type * as _fixtures_fowV4_createMockFowV4MatchResultData from "../_fixtures/fowV4/createMockFowV4MatchResultData.js"; +import type * as _model__test__helpers_testUsers from "../_model/_test/_helpers/testUsers.js"; +import type * as _model__test_actions_populateUsers from "../_model/_test/actions/populateUsers.js"; +import type * as _model__test_index from "../_model/_test/index.js"; +import type * as _model__test_mutations_cleanUp from "../_model/_test/mutations/cleanUp.js"; import type * as _model_common_VisibilityLevel from "../_model/common/VisibilityLevel.js"; import type * as _model_common__helpers_buildFilteredQuery from "../_model/common/_helpers/buildFilteredQuery.js"; import type * as _model_common__helpers_checkAuth from "../_model/common/_helpers/checkAuth.js"; @@ -292,10 +296,9 @@ import type * as _model_users_queries_internal_getUserByClaimToken from "../_mod import type * as _model_users_queries_internal_getUserByEmail from "../_model/users/queries/internal/getUserByEmail.js"; import type * as _model_users_queries_internal_getUserById from "../_model/users/queries/internal/getUserById.js"; import type * as _model_users_table from "../_model/users/table.js"; -import type * as _model_utils__helpers_testUsers from "../_model/utils/_helpers/testUsers.js"; -import type * as _model_utils_actions_populateUsers from "../_model/utils/actions/populateUsers.js"; import type * as _model_utils_createTestTournament from "../_model/utils/createTestTournament.js"; import type * as _model_utils_createTestTournamentMatchResults from "../_model/utils/createTestTournamentMatchResults.js"; +import type * as _model_utils_createTestTournamentPairings from "../_model/utils/createTestTournamentPairings.js"; import type * as _model_utils_createTestUsers from "../_model/utils/createTestUsers.js"; import type * as _model_utils_deleteTestTournament from "../_model/utils/deleteTestTournament.js"; import type * as _model_utils_getTournamentOrganizerList from "../_model/utils/getTournamentOrganizerList.js"; @@ -325,6 +328,7 @@ import type * as matchResults from "../matchResults.js"; import type * as migrations from "../migrations.js"; import type * as photos from "../photos.js"; import type * as scheduledTasks from "../scheduledTasks.js"; +import type * as test from "../test.js"; import type * as tournamentCompetitors from "../tournamentCompetitors.js"; import type * as tournamentPairings from "../tournamentPairings.js"; import type * as tournamentRegistrations from "../tournamentRegistrations.js"; @@ -353,6 +357,10 @@ declare const fullApi: ApiFromModules<{ "_fixtures/createMockTournament": typeof _fixtures_createMockTournament; "_fixtures/createMockTournamentCompetitor": typeof _fixtures_createMockTournamentCompetitor; "_fixtures/fowV4/createMockFowV4MatchResultData": typeof _fixtures_fowV4_createMockFowV4MatchResultData; + "_model/_test/_helpers/testUsers": typeof _model__test__helpers_testUsers; + "_model/_test/actions/populateUsers": typeof _model__test_actions_populateUsers; + "_model/_test/index": typeof _model__test_index; + "_model/_test/mutations/cleanUp": typeof _model__test_mutations_cleanUp; "_model/common/VisibilityLevel": typeof _model_common_VisibilityLevel; "_model/common/_helpers/buildFilteredQuery": typeof _model_common__helpers_buildFilteredQuery; "_model/common/_helpers/checkAuth": typeof _model_common__helpers_checkAuth; @@ -634,10 +642,9 @@ declare const fullApi: ApiFromModules<{ "_model/users/queries/internal/getUserByEmail": typeof _model_users_queries_internal_getUserByEmail; "_model/users/queries/internal/getUserById": typeof _model_users_queries_internal_getUserById; "_model/users/table": typeof _model_users_table; - "_model/utils/_helpers/testUsers": typeof _model_utils__helpers_testUsers; - "_model/utils/actions/populateUsers": typeof _model_utils_actions_populateUsers; "_model/utils/createTestTournament": typeof _model_utils_createTestTournament; "_model/utils/createTestTournamentMatchResults": typeof _model_utils_createTestTournamentMatchResults; + "_model/utils/createTestTournamentPairings": typeof _model_utils_createTestTournamentPairings; "_model/utils/createTestUsers": typeof _model_utils_createTestUsers; "_model/utils/deleteTestTournament": typeof _model_utils_deleteTestTournament; "_model/utils/getTournamentOrganizerList": typeof _model_utils_getTournamentOrganizerList; @@ -667,6 +674,7 @@ declare const fullApi: ApiFromModules<{ migrations: typeof migrations; photos: typeof photos; scheduledTasks: typeof scheduledTasks; + test: typeof test; tournamentCompetitors: typeof tournamentCompetitors; tournamentPairings: typeof tournamentPairings; tournamentRegistrations: typeof tournamentRegistrations; diff --git a/convex/_model/utils/_helpers/testUsers.ts b/convex/_model/_test/_helpers/testUsers.ts similarity index 100% rename from convex/_model/utils/_helpers/testUsers.ts rename to convex/_model/_test/_helpers/testUsers.ts diff --git a/convex/_model/utils/actions/populateUsers.ts b/convex/_model/_test/actions/populateUsers.ts similarity index 100% rename from convex/_model/utils/actions/populateUsers.ts rename to convex/_model/_test/actions/populateUsers.ts diff --git a/convex/_model/_test/index.ts b/convex/_model/_test/index.ts new file mode 100644 index 00000000..b79fe703 --- /dev/null +++ b/convex/_model/_test/index.ts @@ -0,0 +1,2 @@ +export * from './actions/populateUsers'; +export * from './mutations/cleanUp'; diff --git a/convex/_model/_test/mutations/cleanUp.ts b/convex/_model/_test/mutations/cleanUp.ts new file mode 100644 index 00000000..656a0f28 --- /dev/null +++ b/convex/_model/_test/mutations/cleanUp.ts @@ -0,0 +1,24 @@ +import type { TableNamesInDataModel } from 'convex/server'; +import { Infer, v } from 'convex/values'; + +import type { DataModel } from '../../../_generated/dataModel'; +import type { MutationCtx } from '../../../_generated/server'; +import schema from '../../../schema'; + +type TableName = TableNamesInDataModel; + +export const cleanUpArgs = v.object({ + preservedTables: v.array(v.string()), +}); + +export const cleanUp = async ( + ctx: MutationCtx, + args: Infer, +): Promise => { + const preserved = new Set(args.preservedTables); + const tables = (Object.keys(schema.tables) as TableName[]).filter((t) => !preserved.has(t)); + for (const table of tables) { + const docs = await ctx.db.query(table).collect(); + await Promise.all(docs.map((doc) => ctx.db.delete(doc._id))); + } +}; diff --git a/convex/_model/common/errors.ts b/convex/_model/common/errors.ts index e3d2e9b7..252f9c00 100644 --- a/convex/_model/common/errors.ts +++ b/convex/_model/common/errors.ts @@ -110,11 +110,9 @@ export const errors = { CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT: 'Cannot add pairings to an archived tournament.', CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT: 'Cannot add pairings to a draft tournament.', CANNOT_ADD_PAIRINGS_TO_IN_PROGRESS_ROUND: 'Cannot add pairings while round is already in-progress.', - CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT: 'Cannot add pairings to a tournament that hasn\'t started yet.', CANNOT_DELETE_PAIRINGS_FROM_ARCHIVED_TOURNAMENT: 'Cannot delete pairings from an archived tournament.', CANNOT_DELETE_PAIRINGS_FROM_DRAFT_TOURNAMENT: 'Cannot delete pairings from a draft tournament.', CANNOT_DELETE_PAIRINGS_FROM_IN_PROGRESS_ROUND: 'Cannot delete pairings while round is in-progress.', - CANNOT_DELETE_PAIRINGS_FROM_PUBLISHED_TOURNAMENT: 'Cannot delete pairings from a tournament that hasn\'t started yet.', CANNOT_ADD_TOO_MANY_PAIRINGS: 'Cannot add more pairings than the tournament is set-up for.', TOURNAMENT_ALREADY_HAS_PAIRINGS_FOR_ROUND: 'Tournament already has pairings for this round.', diff --git a/convex/_model/tournamentPairings/_helpers/assignTables.test.ts b/convex/_model/tournamentPairings/_helpers/assignTables.test.ts index 9afb9e33..e52b5208 100644 --- a/convex/_model/tournamentPairings/_helpers/assignTables.test.ts +++ b/convex/_model/tournamentPairings/_helpers/assignTables.test.ts @@ -13,6 +13,11 @@ import { assignTables } from './assignTables'; const createMockTournament = (competitorCount: number): TournamentDeep => ({ competitorCount, + pairingConfig: { + tableCount: Math.ceil(competitorCount / 2), + orderBy: 'ranking', + policies: { repeat: 'block', sameAlignment: 'allow' }, + }, }) as TournamentDeep; const createMockData = ( diff --git a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts index d7ac4292..014473b4 100644 --- a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts +++ b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts @@ -35,9 +35,6 @@ export const createTournamentPairings = async ( if (tournament.status === 'draft') { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT')); } - if (tournament.status === 'published') { - throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT')); - } if (tournament.status === 'archived') { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT')); } diff --git a/convex/_model/tournamentPairings/mutations/deleteTournamentPairings.ts b/convex/_model/tournamentPairings/mutations/deleteTournamentPairings.ts index 4bfee889..109937ee 100644 --- a/convex/_model/tournamentPairings/mutations/deleteTournamentPairings.ts +++ b/convex/_model/tournamentPairings/mutations/deleteTournamentPairings.ts @@ -30,11 +30,6 @@ export const deleteTournamentPairings = async ( throw new ConvexError(getErrorMessage('CANNOT_DELETE_PAIRINGS_FROM_DRAFT_TOURNAMENT')); } - // TODO: Technically not really needed... - if (tournament.status === 'published') { - throw new ConvexError(getErrorMessage('CANNOT_DELETE_PAIRINGS_FROM_PUBLISHED_TOURNAMENT')); - } - // TODO: Replace with generic 'archived' error: if (tournament.status === 'archived') { throw new ConvexError(getErrorMessage('CANNOT_DELETE_PAIRINGS_FROM_ARCHIVED_TOURNAMENT')); diff --git a/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser.ts b/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser.ts index 22d2a7ef..b32f16bf 100644 --- a/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser.ts +++ b/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser.ts @@ -19,5 +19,9 @@ export const getTournamentRegistrationByTournamentUser = async ( if (!doc) { return null; } + const tournament = await ctx.db.get(doc.tournamentId); + if (!tournament) { + return null; + } return await deepenTournamentRegistration(ctx, doc); }; diff --git a/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts b/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts index c8f07f97..902cbbd2 100644 --- a/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts +++ b/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts @@ -33,6 +33,7 @@ describe('getLastVisibleRound', () => { const { userId, tournamentId } = await t.run(async (ctx) => { const userId = await ctx.db.insert('users', { email: 'to@test.com', + username: 'to_test', locationVisibility: 0, nameVisibility: 0, }); @@ -59,6 +60,7 @@ describe('getLastVisibleRound', () => { const { userId, tournamentId } = await t.run(async (ctx) => { const userId = await ctx.db.insert('users', { email: 'user@test.com', + username: 'user_test', locationVisibility: 0, nameVisibility: 0, }); diff --git a/convex/_model/tournaments/queries/getTournamentOpenRound.ts b/convex/_model/tournaments/queries/getTournamentOpenRound.ts index c37f4dae..250ac45e 100644 --- a/convex/_model/tournaments/queries/getTournamentOpenRound.ts +++ b/convex/_model/tournaments/queries/getTournamentOpenRound.ts @@ -1,7 +1,6 @@ import { Infer, v } from 'convex/values'; import { QueryCtx } from '../../../_generated/server'; -import { getTournamentShallow } from '../_helpers/getTournamentShallow'; export const getTournamentOpenRoundArgs = v.object({ id: v.id('tournaments'), @@ -23,7 +22,10 @@ export const getTournamentOpenRound = async ( ctx: QueryCtx, args: Infer, ) => { - const tournament = await getTournamentShallow(ctx, args.id); + const tournament = await ctx.db.get(args.id); + if (!tournament) { + return null; + } if (tournament.status !== 'active' || tournament.currentRound === undefined) { return null; diff --git a/convex/_model/users/actions/internal/setUserDefaultAvatar.ts b/convex/_model/users/actions/internal/setUserDefaultAvatar.ts index c1fee2d9..de8301a7 100644 --- a/convex/_model/users/actions/internal/setUserDefaultAvatar.ts +++ b/convex/_model/users/actions/internal/setUserDefaultAvatar.ts @@ -23,7 +23,7 @@ export const setUserDefaultAvatar = async ( } // Fetch avatar - const avatarUrl = `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${Math.random()}&scale=75`; + const avatarUrl = `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${encodeURIComponent(user.username)}&scale=75`; const avatarResponse = await fetch(avatarUrl); if (!avatarResponse.ok) { throw new Error('Failed to fetch avatar'); diff --git a/convex/_model/users/actions/inviteUser.ts b/convex/_model/users/actions/inviteUser.ts index 90fc104d..088e8380 100644 --- a/convex/_model/users/actions/inviteUser.ts +++ b/convex/_model/users/actions/inviteUser.ts @@ -14,6 +14,7 @@ import { checkAuth } from '../../common/_helpers/checkAuth'; import { getErrorMessage } from '../../common/errors'; import { VisibilityLevel } from '../../common/VisibilityLevel'; import { createClaimToken } from '../_helpers/createClaimToken'; +import { generateUsername } from '../_helpers/generateUsername'; import { hashClaimToken } from '../_helpers/hashClaimToken'; export const inviteUserArgs = v.object({ @@ -55,12 +56,17 @@ export const inviteUser = async ( }); user = existingUser; } else { + let username = generateUsername(); + while (await ctx.runQuery(internal.users.getUserByUsername, { username })) { + username = generateUsername(); + } const { user: createdUser } = await createAccount(ctx, { provider: 'password', account: { id: email }, profile: { ...restArgs, email, + username, locationVisibility: VisibilityLevel.Hidden, nameVisibility: VisibilityLevel.Tournaments, claimTokenHash: await hashClaimToken(claimToken), diff --git a/convex/_model/users/index.ts b/convex/_model/users/index.ts index dda7ac3c..3c55e067 100644 --- a/convex/_model/users/index.ts +++ b/convex/_model/users/index.ts @@ -78,3 +78,7 @@ export { getUserById, getUserByIdArgs, } from './queries/internal/getUserById'; +export { + getUserByUsername, + getUserByUsernameArgs, +} from './queries/internal/getUserByUsername'; diff --git a/convex/_model/users/queries/internal/getUserByUsername.ts b/convex/_model/users/queries/internal/getUserByUsername.ts new file mode 100644 index 00000000..448e74f0 --- /dev/null +++ b/convex/_model/users/queries/internal/getUserByUsername.ts @@ -0,0 +1,15 @@ +import { Infer, v } from 'convex/values'; + +import { Doc } from '../../../../_generated/dataModel'; +import { QueryCtx } from '../../../../_generated/server'; + +export const getUserByUsernameArgs = v.object({ + username: v.string(), +}); + +export const getUserByUsername = async ( + ctx: QueryCtx, + args: Infer, +): Promise | null> => await ctx.db.query('users') + .withIndex('by_username', (q) => q.eq('username', args.username)) + .first(); diff --git a/convex/_model/users/table.ts b/convex/_model/users/table.ts index e7787922..b4a4bbf3 100644 --- a/convex/_model/users/table.ts +++ b/convex/_model/users/table.ts @@ -8,7 +8,7 @@ export const editableFields = { givenName: v.optional(v.string()), locationVisibility: v.number(), nameVisibility: v.number(), - username: v.optional(v.string()), + username: v.string(), }; export const computedFields = { diff --git a/convex/_model/utils/createTestTournament.ts b/convex/_model/utils/createTestTournament.ts index 7d78d4b4..5b0149ec 100644 --- a/convex/_model/utils/createTestTournament.ts +++ b/convex/_model/utils/createTestTournament.ts @@ -2,6 +2,7 @@ import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; import { Infer, v } from 'convex/values'; import { createMockTournament } from '../../_fixtures/createMockTournament'; +import { Id } from '../../_generated/dataModel'; import { MutationCtx } from '../../_generated/server'; import { getStaticEnumConvexValidator } from '../common/_helpers/getStaticEnumConvexValidator'; import { tournamentStatus } from '../common/tournamentStatus'; @@ -37,8 +38,8 @@ export const createTestTournament = async ( gameSystem, competitorCount, }: Infer, -): Promise => { - const maxCompetitors = competitorCount ?? useTeams ? 12 : 24; +): Promise> => { + const maxCompetitors = competitorCount ?? (useTeams ? 12 : 24); const competitorSize = useTeams ? 3 : 1; // 1. Gather users @@ -106,4 +107,6 @@ export const createTestTournament = async ( } } } + + return tournamentId; }; diff --git a/convex/_model/utils/createTestTournamentPairings.ts b/convex/_model/utils/createTestTournamentPairings.ts new file mode 100644 index 00000000..2e8555fd --- /dev/null +++ b/convex/_model/utils/createTestTournamentPairings.ts @@ -0,0 +1,38 @@ +import { Infer, v } from 'convex/values'; + +import { Id } from '../../_generated/dataModel'; +import { MutationCtx } from '../../_generated/server'; +import { defaultValues } from '../common/tournamentPairingConfig'; +import { getTournamentCompetitorsByTournament } from '../tournamentCompetitors'; +import { generateDraftPairings } from '../tournamentPairings/_helpers/generateDraftPairings'; + +export const createTestTournamentPairingsArgs = v.object({ + tournamentId: v.id('tournaments'), + round: v.number(), +}); + +export const createTestTournamentPairings = async ( + ctx: MutationCtx, + { tournamentId, round }: Infer, +): Promise[]> => { + const competitors = await getTournamentCompetitorsByTournament(ctx, { tournamentId }); + const activeCompetitors = competitors.filter((c) => c.active); + + const pairs = generateDraftPairings(activeCompetitors, defaultValues.policies); + + const pairingIds: Id<'tournamentPairings'>[] = []; + let tableNumber = 1; + + for (const [competitor0, competitor1] of pairs) { + const id = await ctx.db.insert('tournamentPairings', { + tournamentId, + round, + tournamentCompetitor0Id: competitor0._id, + tournamentCompetitor1Id: competitor1?._id ?? null, + table: competitor1 ? tableNumber++ : null, + }); + pairingIds.push(id); + } + + return pairingIds; +}; diff --git a/convex/_model/utils/createTestUsers.ts b/convex/_model/utils/createTestUsers.ts index 36c97e4d..c9fc75dc 100644 --- a/convex/_model/utils/createTestUsers.ts +++ b/convex/_model/utils/createTestUsers.ts @@ -1,5 +1,5 @@ -import { createUserData, userEmails } from './_helpers/testUsers'; import { MutationCtx } from '../../_generated/server'; +import { createUserData, userEmails } from '../_test/_helpers/testUsers'; export const createTestUsers = async ( ctx: MutationCtx, diff --git a/convex/_model/utils/index.ts b/convex/_model/utils/index.ts index c5d23351..6d6c9232 100644 --- a/convex/_model/utils/index.ts +++ b/convex/_model/utils/index.ts @@ -1,6 +1,3 @@ -export { - populateUsers, -} from './actions/populateUsers'; export { createTestTournament, createTestTournamentArgs, @@ -9,6 +6,10 @@ export { createTestTournamentMatchResults, createTestTournamentMatchResultsArgs, } from './createTestTournamentMatchResults'; +export { + createTestTournamentPairings, + createTestTournamentPairingsArgs, +} from './createTestTournamentPairings'; export { createTestUsers, } from './createTestUsers'; diff --git a/convex/test.ts b/convex/test.ts new file mode 100644 index 00000000..e7f79c97 --- /dev/null +++ b/convex/test.ts @@ -0,0 +1,11 @@ +import { internalAction, internalMutation } from './_generated/server'; +import * as model from './_model/_test'; + +export const cleanUp = internalMutation({ + args: model.cleanUpArgs, + handler: model.cleanUp, +}); + +export const populateUsers = internalAction({ + handler: model.populateUsers, +}); diff --git a/convex/users.ts b/convex/users.ts index d7d6fbd8..b58ea8a0 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -35,6 +35,11 @@ export const getUserById = internalQuery({ handler: model.getUserById, }); +export const getUserByUsername = internalQuery({ + args: model.getUserByUsernameArgs, + handler: model.getUserByUsername, +}); + export const updateUserEmail = internalMutation({ args: model.updateUserEmailArgs, handler: model.updateUserEmail, diff --git a/convex/utils.ts b/convex/utils.ts index a00fd490..ee7022b5 100644 --- a/convex/utils.ts +++ b/convex/utils.ts @@ -1,5 +1,4 @@ import { - internalAction, internalMutation, internalQuery, mutation, @@ -16,6 +15,11 @@ export const createTestTournamentMatchResults = mutation({ handler: model.createTestTournamentMatchResults, }); +export const createTestTournamentPairings = mutation({ + args: model.createTestTournamentPairingsArgs, + handler: model.createTestTournamentPairings, +}); + export const deleteTestTournament = mutation({ args: model.deleteTestTournamentArgs, handler: model.deleteTestTournament, @@ -35,10 +39,6 @@ export const revealTournamentPlayerNames = internalMutation({ handler: model.revealTournamentPlayerNames, }); -export const populateUsers = internalAction({ - handler: model.populateUsers, -}); - export const refreshSearchIndex = internalMutation({ args: model.refreshSearchIndexArgs, handler: model.refreshSearchIndex, diff --git a/package-lock.json b/package-lock.json index a9a334d5..d453c50d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "^10.0.0", "@fontsource/figtree": "^5.1.0", "@hookform/resolvers": "^3.9.0", - "@ianpaschal/combat-command-components": "^2.0.1", + "@ianpaschal/combat-command-components": "^2.1.1", "@ianpaschal/combat-command-game-systems": "^1.5.2", "@mapbox/search-js-core": "^1.0.0-beta.25", "@pdfme/pdf-lib": "^1.17.1", @@ -70,6 +70,7 @@ "@edge-runtime/vm": "^5.0.0", "@faker-js/faker": "^9.6.0", "@ianpaschal/eslint-config": "^1.0.1", + "@playwright/test": "^1.60.0", "@stylistic/stylelint-config": "^2.0.0", "@stylistic/stylelint-plugin": "^3.1.0", "@testing-library/jest-dom": "^6.9.1", @@ -1446,9 +1447,9 @@ } }, "node_modules/@ianpaschal/combat-command-components": { - "version": "2.0.1", - "resolved": "https://npm.pkg.github.com/download/@ianpaschal/combat-command-components/2.0.1/47025c3e721d3716c560e265bebc02ff46f8ee81", - "integrity": "sha512-VIU5Mo6mp230ecXbVW4LdyKjhSi3cgy1fjMAh6cxzncTjY0i46UHRtpJu8P3aOSuDkepNI/5QfjizbisxruRmA==", + "version": "2.1.1", + "resolved": "https://npm.pkg.github.com/download/@ianpaschal/combat-command-components/2.1.1/51557e9072a5ade5fd6b0b9c5422383b1b68cb50", + "integrity": "sha512-4kapO0LhmdwkWw77scwQWror8wrjgojeCcMCljl29OyO6Vi0biBxqOn4ROOpVZ0B5m6RiEkDsuUrg9FK37nVig==", "license": "MIT", "dependencies": { "@base-ui/react": "^1.4.1", @@ -2981,6 +2982,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/colors": { "version": "3.0.0", "license": "MIT" @@ -10155,6 +10172,53 @@ "node": ">=6" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "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, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "dev": true, diff --git a/package.json b/package.json index 0c968c43..8454e4f3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "lint:scss": "stylelint '**/*.scss'", "prepare": "husky", "test": "vitest --run --coverage", + "test:e2e": "playwright test", + "test:e2e:slow": "SLOW_MO=500 playwright test --headed", "gen:component": "node scripts/generateComponent.js", "release:prepare": "node scripts/prepareRelease.js", "release:finish": "node scripts/finishRelease.js" @@ -29,7 +31,7 @@ "@dnd-kit/sortable": "^10.0.0", "@fontsource/figtree": "^5.1.0", "@hookform/resolvers": "^3.9.0", - "@ianpaschal/combat-command-components": "^2.0.1", + "@ianpaschal/combat-command-components": "^2.1.1", "@ianpaschal/combat-command-game-systems": "^1.5.2", "@mapbox/search-js-core": "^1.0.0-beta.25", "@pdfme/pdf-lib": "^1.17.1", @@ -82,6 +84,7 @@ "@edge-runtime/vm": "^5.0.0", "@faker-js/faker": "^9.6.0", "@ianpaschal/eslint-config": "^1.0.1", + "@playwright/test": "^1.60.0", "@stylistic/stylelint-config": "^2.0.0", "@stylistic/stylelint-plugin": "^3.1.0", "@testing-library/jest-dom": "^6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..a439e8cb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,47 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './test', + globalSetup: './test/globalSetup.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? 'html' : 'list', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + webServer: [ + + // In CI the backend is a Convex preview deployment already running in the cloud — + // only start the local dev server when running tests locally. + ...(!process.env.CI ? [{ + command: 'CONVEX_DEPLOYMENT=local:local-ian_paschal-combat_command-1 npx convex dev --tail-logs disable > /dev/null 2>&1', + url: 'http://127.0.0.1:3210', + reuseExistingServer: true, + timeout: 60000, + }] : []), + { + command: 'npm run dev:frontend > /dev/null 2>&1', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, + ], + projects: [ + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + storageState: 'test/.auth/state.json', + launchOptions: { slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0 }, + }, + dependencies: ['setup'], + }, + ], +}); diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx index 4b587572..5a943d84 100644 --- a/src/components/ContextMenu/ContextMenu.tsx +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -10,6 +10,7 @@ import { Ellipsis } from 'lucide-react'; import { DeviceSize, useDeviceSize } from '~/hooks/useDeviceSize'; export interface ContextMenuProps { + 'aria-label'?: string; className?: string; actions: MenuItem[], size?: ElementSize; @@ -17,6 +18,7 @@ export interface ContextMenuProps { } export const ContextMenu = ({ + 'aria-label': ariaLabel, className, actions, size = 'normal', @@ -27,6 +29,7 @@ export const ContextMenu = ({ return (