diff --git a/components/tournament/TournamentCheckInPanel.vue b/components/tournament/TournamentCheckInPanel.vue
index a32c35187..c78596b15 100644
--- a/components/tournament/TournamentCheckInPanel.vue
+++ b/components/tournament/TournamentCheckInPanel.vue
@@ -209,7 +209,33 @@ function formatClock(iso: string | null) {
});
}
+// The window's shape ("between 4:00 PM and 4:15 PM") is a clock, but the
+// headline announcing when it opens is a date: check-in on a tournament two
+// months out opens on a specific day, and "opens at 4:00 PM" next to a
+// multi-week countdown reads as today.
+function formatWhen(iso: string | null) {
+ if (!iso) {
+ return "--:--";
+ }
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) {
+ return "--:--";
+ }
+ const today = new Date();
+ if (date.toDateString() === today.toDateString()) {
+ return formatClock(iso);
+ }
+ return date.toLocaleString(dateLocale(), {
+ month: "short",
+ day: "numeric",
+ year: date.getFullYear() === today.getFullYear() ? undefined : "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
const opensAtLabel = computed(() => formatClock(opensAtIso.value));
+const opensAtWhen = computed(() => formatWhen(opensAtIso.value));
const closesAtLabel = computed(() => formatClock(closesAtIso.value));
const checkedInTeamCount = computed(
@@ -254,7 +280,10 @@ const myRosterCheckedInCount = computed(
// tail of the un-confirmed players, recomputed as people check in, rather than
// a fixed starter/substitute split the schema does not actually have.
const myRosterRows = computed(() => {
- let stillNeeded = Math.max(0, minPlayers.value - myRosterCheckedInCount.value);
+ let stillNeeded = Math.max(
+ 0,
+ minPlayers.value - myRosterCheckedInCount.value,
+ );
return myRoster.value.map((member) => {
const checkedIn = !!member.checked_in_at;
let state: "checked_in" | "waiting" | "not_needed";
@@ -320,8 +349,7 @@ const canCheckInSelf = computed(() => {
});
const registrationOpen = computed(
- () =>
- props.tournament?.status === e_tournament_status_enum.RegistrationOpen,
+ () => props.tournament?.status === e_tournament_status_enum.RegistrationOpen,
);
// An organizer watching a Captains-mode window is reading the field, not being
@@ -352,7 +380,9 @@ const openHint = computed(() => {
});
}
if (isObserver.value && !isAdminMode.value) {
- return t("tournament.check_in.observer_hint", { time: closesAtLabel.value });
+ return t("tournament.check_in.observer_hint", {
+ time: closesAtLabel.value,
+ });
}
if (isPlayersMode.value) {
return t("tournament.check_in.players_hint", { count: minPlayers.value });
@@ -597,7 +627,7 @@ async function checkIn(teamId?: string | null) {
class="mb-1 mt-[0.6rem] font-sans text-[1.05rem] font-bold tracking-[0.01em] text-foreground"
>
{{
- $t("tournament.check_in.opens_at_heading", { time: opensAtLabel })
+ $t("tournament.check_in.opens_at_heading", { time: opensAtWhen })
}}
@@ -624,7 +654,9 @@ async function checkIn(teamId?: string | null) {
-
+
{{ openChip }}
+
+
+
+
+
+
+ {{
+ $t("tournament.form.substitutes_enabled.label")
+ }}
+ {{
+ $t("tournament.form.substitutes_enabled.description")
+ }}
+
+
+
+
+
+
+
+
@@ -365,6 +395,7 @@ export default {
categories: z.string().array().default([]),
auto_start: z.boolean().default(true),
negotiated_scheduling: z.boolean().default(false),
+ substitutes_enabled: z.boolean().default(true),
...registrationSchemaShape(this),
},
useApplicationSettingsStore().settings,
@@ -561,6 +592,7 @@ export default {
scheduling_mode: form.negotiated_scheduling
? "negotiated"
: "auto",
+ substitutes_enabled: form.substitutes_enabled,
...registrationColumns(form),
options: {
data: setupOptionsSetMutation(!!form.map_pool_id),
diff --git a/components/tournament/TournamentDetail.vue b/components/tournament/TournamentDetail.vue
index a345780c3..8916a8c2b 100644
--- a/components/tournament/TournamentDetail.vue
+++ b/components/tournament/TournamentDetail.vue
@@ -112,8 +112,12 @@ const tournamentHeroNameRowClasses = "flex min-w-0 items-center";
const tournamentHeroNameClasses =
"relative m-0 min-w-0 font-sans text-[clamp(1.75rem,4vw,3rem)] font-bold uppercase leading-[0.95] tracking-[0.02em] [font-stretch:80%]";
const tournamentHeroNameMainClasses = "relative text-foreground";
+// The echo is the same text, offset and outlined, so it has to break exactly
+// where the title does -- `left-5/right-[-5px]` hands it the title's own width
+// for that. Held to one line it stopped wrapping with the title and ran out
+// across the banner instead.
const tournamentHeroNameGhostClasses =
- "pointer-events-none absolute left-[5px] top-[5px] right-[-5px] overflow-hidden whitespace-nowrap text-transparent select-none [-webkit-text-stroke:1px_hsl(var(--tac-amber)_/_0.35)]";
+ "pointer-events-none absolute left-[5px] top-[5px] right-[-5px] text-transparent select-none [-webkit-text-stroke:1px_hsl(var(--tac-amber)_/_0.35)]";
const tournamentHeroBadgesClasses = "flex flex-wrap gap-1.5";
const tournamentHeroTagClasses =
"inline-flex items-center rounded border border-[hsl(var(--tac-amber)_/_0.4)] bg-[hsl(var(--tac-amber)_/_0.12)] px-[0.55rem] py-[0.2rem] font-mono text-[0.65rem] font-bold uppercase tracking-[0.18em] text-[hsl(var(--tac-amber))]";
@@ -683,6 +687,10 @@ function clearTeamEnterDelay(el: Element) {
@@ -1209,6 +1217,7 @@ export default {
auto_start: true,
scheduling_mode: true,
awards_enabled: true,
+ substitutes_enabled: true,
e_tournament_status: {
description: true,
},
diff --git a/components/tournament/TournamentMatchOptionsForm.vue b/components/tournament/TournamentMatchOptionsForm.vue
index 09b8a7075..7be11b5c4 100644
--- a/components/tournament/TournamentMatchOptionsForm.vue
+++ b/components/tournament/TournamentMatchOptionsForm.vue
@@ -67,6 +67,39 @@ import SettingsSaveBar from "~/components/settings/SettingsSaveBar.vue";
+
+
+
+
+
+ {{
+ $t("tournament.form.substitutes_enabled.label")
+ }}
+ {{
+ substitutesLocked
+ ? $t("tournament.form.substitutes_enabled.locked")
+ : $t("tournament.form.substitutes_enabled.description")
+ }}
+
+
+
+
+
+
+
@@ -84,7 +117,11 @@ import SettingsSaveBar from "~/components/settings/SettingsSaveBar.vue";
import { useForm } from "vee-validate";
import { generateMutation, generateQuery } from "~/graphql/graphqlGen";
import { mapFields } from "~/graphql/mapGraphql";
-import { $, e_map_pool_types_enum } from "~/generated/zeus";
+import {
+ $,
+ e_map_pool_types_enum,
+ e_tournament_status_enum,
+} from "~/generated/zeus";
import matchOptionsValidator from "~/utilities/match-options-validator";
import { toTypedSchema } from "~/utilities/vee-validate-zod";
import { toast } from "@/components/ui/toast";
@@ -134,6 +171,7 @@ export default {
{
auto_start: z.boolean().default(true),
negotiated_scheduling: z.boolean().default(false),
+ substitutes_enabled: z.boolean().default(true),
},
useApplicationSettingsStore().settings,
),
@@ -178,6 +216,15 @@ export default {
},
},
computed: {
+ substitutesLocked() {
+ return (
+ this.tournament.substitutes_enabled &&
+ ![
+ e_tournament_status_enum.Setup,
+ e_tournament_status_enum.RegistrationOpen,
+ ].includes(this.tournament.status)
+ );
+ },
defaultMapPool() {
return this.map_pools?.find((pool) => {
return pool.type === this.form.values.type;
@@ -196,6 +243,7 @@ export default {
map_veto: true,
auto_start: this.tournament.auto_start,
negotiated_scheduling: this.tournament.scheduling_mode === "negotiated",
+ substitutes_enabled: this.tournament.substitutes_enabled,
});
setupOptions(this.form, this.tournament.options);
@@ -255,6 +303,7 @@ export default {
variables: {
auto_start: form.negotiated_scheduling ? false : form.auto_start,
scheduling_mode: form.negotiated_scheduling ? "negotiated" : "auto",
+ substitutes_enabled: form.substitutes_enabled,
},
mutation: generateMutation({
update_tournaments_by_pk: [
@@ -263,6 +312,7 @@ export default {
_set: {
auto_start: $("auto_start", "Boolean!"),
scheduling_mode: $("scheduling_mode", "String!"),
+ substitutes_enabled: $("substitutes_enabled", "Boolean!"),
},
},
{ __typename: true },
diff --git a/components/tournament/TournamentStageForm.vue b/components/tournament/TournamentStageForm.vue
index e9a812de2..3affe28c2 100644
--- a/components/tournament/TournamentStageForm.vue
+++ b/components/tournament/TournamentStageForm.vue
@@ -611,14 +611,31 @@ import { $ } from "~/generated/zeus";
-
- {{ $t("match.options.advanced.region.title") }}
-
+
+
+ {{ $t("match.options.advanced.region.title") }}
+
+
+ {{
+ $t("match.options.advanced.region.lan_match")
+ }}
+ form.setFieldValue('lan', checked)
+ "
+ />
+
+
-
+
{{
@@ -629,6 +646,7 @@ import { $ } from "~/generated/zeus";
class="pointer-events-none"
:model-value="value"
@update:model-value="handleChange"
+ :disabled="form.values.lan"
/>
@@ -654,7 +672,9 @@ import { $ } from "~/generated/zeus";
-
+
@@ -540,6 +517,7 @@ import { typedGql } from "~/generated/zeus/typedDocumentNode";
import { $, order_by, e_player_roles_enum } from "~/generated/zeus";
import { generateMutation } from "~/graphql/graphqlGen";
import { simpleEventFields } from "~/graphql/simpleEventFields";
+import { simpleTournamentFields } from "~/graphql/simpleTournamentFields";
import { eventMediaUrl } from "~/composables/useEventMediaUpload";
import { toast } from "@/components/ui/toast";
@@ -563,11 +541,11 @@ const eventSubscription = typedGql("subscription")({
{},
{
tournament_id: true,
+ // The full card field set: the event's tournaments render with the
+ // same TournamentCard the /tournaments and /watch lists use.
tournament: {
- id: true,
- name: true,
+ ...simpleTournamentFields,
status: true,
- start: true,
teams: [
{},
{
diff --git a/pages/events/index.vue b/pages/events/index.vue
index 344004402..d10629f5c 100644
--- a/pages/events/index.vue
+++ b/pages/events/index.vue
@@ -1,16 +1,17 @@
@@ -85,10 +65,10 @@ function setPage(p: number) {
-
+
-
+
{{ $t("pages.events.no_events_title") }}
{{
$t("pages.events.no_events_description")
@@ -104,61 +84,81 @@ function setPage(p: number) {
+
+
+
-
{{ $t("pages.events.upcoming") }}
-
-
+
+
+
+
-
-
-
- {{ $t("pages.events.past_events") }}
+
+
+
+ {{ $t("pages.events.past_events") }}
+
+ {
+ finishedRow?.scrollByDirection(d);
+ if (d === 'right') {
+ loadMoreFinished();
+ }
+ }
+ "
+ />
-
-
+
-
+
-
- {
- onPerPageChange(value);
- setPage(1);
- }
- "
- />
@@ -166,90 +166,125 @@ function setPage(p: number) {
import { typedGql } from "~/generated/zeus/typedDocumentNode";
import { $, order_by } from "~/generated/zeus";
import { simpleEventFields } from "~/graphql/simpleEventFields";
-import { eventPhase } from "~/utilities/eventDisplay";
+import { eventPhaseWhere } from "~/utilities/eventDisplay";
+
+const HERO_PAGE_SIZE = 4;
+const FINISHED_PAGE_SIZE = 12;
-// A GraphQL subscription may only select a single top-level field, so the
-// list and its total count are two separate subscriptions.
-const eventsSubscription = typedGql("subscription")({
+const liveEventsSubscription = typedGql("subscription")({
events: [
{
- order_by: [{ starts_at: order_by.desc_nulls_last }],
+ where: eventPhaseWhere("live", $("now", "timestamptz!")),
+ order_by: [{ starts_at: order_by.desc }],
limit: $("limit", "Int!"),
- offset: $("offset", "Int!"),
},
simpleEventFields,
],
});
-const eventsAggregateSubscription = typedGql("subscription")({
- events_aggregate: [
- {},
+const upcomingEventsSubscription = typedGql("subscription")({
+ events: [
{
- aggregate: {
- count: true,
- },
+ where: eventPhaseWhere("upcoming", $("now", "timestamptz!")),
+ order_by: [{ starts_at: order_by.asc }],
+ limit: $("limit", "Int!"),
+ },
+ simpleEventFields,
+ ],
+});
+
+const finishedEventsSubscription = typedGql("subscription")({
+ events: [
+ {
+ where: eventPhaseWhere("finished", $("now", "timestamptz!")),
+ order_by: [{ starts_at: order_by.desc }],
+ limit: $("limit", "Int!"),
},
+ simpleEventFields,
],
});
export default {
data() {
return {
- events: [] as any[],
- eventsTotal: 0,
- perPage: usePerPage("events"),
- loading: true,
+ // Captured once so every event lands in exactly one section.
+ now: new Date().toISOString(),
+ liveEvents: [] as any[],
+ upcomingEvents: [] as any[],
+ finishedEvents: [] as any[],
+ liveLimit: HERO_PAGE_SIZE,
+ upcomingLimit: HERO_PAGE_SIZE,
+ finishedLimit: FINISHED_PAGE_SIZE,
+ loadingLive: true,
+ loadingUpcoming: true,
+ loadingFinished: true,
};
},
apollo: {
$subscribe: {
- events: {
- query: () => eventsSubscription,
+ liveEvents: {
+ query: () => liveEventsSubscription,
+ // The extra row only tells "Show more" whether there is anything left.
+ variables(this: any) {
+ return { now: this.now, limit: this.liveLimit + 1 };
+ },
+ result(this: any, { data }: { data: any }) {
+ this.liveEvents = data?.events ?? [];
+ this.loadingLive = false;
+ },
+ error(this: any) {
+ this.loadingLive = false;
+ },
+ },
+ upcomingEvents: {
+ query: () => upcomingEventsSubscription,
variables(this: any) {
- const q = this.$route?.query || {};
- const rawPage = typeof q.page === "string" ? parseInt(q.page, 10) : 1;
- const page = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
- return {
- limit: this.perPage,
- offset: (page - 1) * this.perPage,
- };
+ return { now: this.now, limit: this.upcomingLimit + 1 };
},
result(this: any, { data }: { data: any }) {
- this.events = data?.events ?? [];
- this.loading = false;
+ this.upcomingEvents = data?.events ?? [];
+ this.loadingUpcoming = false;
},
error(this: any) {
- // Without this, a subscription error never clears loading and the
- // skeleton shows forever.
- this.loading = false;
+ this.loadingUpcoming = false;
},
},
- eventsTotal: {
- query: () => eventsAggregateSubscription,
+ finishedEvents: {
+ query: () => finishedEventsSubscription,
+ variables(this: any) {
+ return { now: this.now, limit: this.finishedLimit };
+ },
result(this: any, { data }: { data: any }) {
- this.eventsTotal = data?.events_aggregate?.aggregate?.count ?? 0;
+ this.finishedEvents = data?.events ?? [];
+ this.loadingFinished = false;
+ },
+ error(this: any) {
+ this.loadingFinished = false;
},
},
},
},
computed: {
- liveEvents(): any[] {
- return (this.events || []).filter((e: any) => eventPhase(e) === "live");
+ loading(): boolean {
+ return this.loadingLive || this.loadingUpcoming || this.loadingFinished;
},
- upcomingEvents(): any[] {
- return (this.events || [])
- .filter((e: any) => eventPhase(e) === "upcoming")
- .sort(
- (a: any, b: any) =>
- new Date(a.starts_at || 0).getTime() -
- new Date(b.starts_at || 0).getTime(),
- );
+ visibleLiveEvents(): any[] {
+ return this.liveEvents.slice(0, this.liveLimit);
},
- finishedEvents(): any[] {
- return (this.events || []).filter(
- (e: any) => eventPhase(e) === "finished",
+ visibleUpcomingEvents(): any[] {
+ return this.upcomingEvents.slice(0, this.upcomingLimit);
+ },
+ hasEvents(): boolean {
+ return (
+ this.liveEvents.length > 0 ||
+ this.upcomingEvents.length > 0 ||
+ this.finishedEvents.length > 0
);
},
+ // Also true while a bigger page loads, so loadMoreFinished can't stack.
+ finishedReachedEnd(): boolean {
+ return this.finishedEvents.length < this.finishedLimit;
+ },
canCreateEvent() {
const me = useAuthStore().me;
if (!me) {
@@ -265,8 +300,17 @@ export default {
},
},
methods: {
- onPerPageChange(value: number) {
- this.perPage = value;
+ showMoreLive() {
+ this.liveLimit += HERO_PAGE_SIZE;
+ },
+ showMoreUpcoming() {
+ this.upcomingLimit += HERO_PAGE_SIZE;
+ },
+ loadMoreFinished() {
+ if (this.finishedReachedEnd) {
+ return;
+ }
+ this.finishedLimit += FINISHED_PAGE_SIZE;
},
},
};
diff --git a/pages/matches/[id]/index.vue b/pages/matches/[id]/index.vue
index dd57855a5..554b11b64 100644
--- a/pages/matches/[id]/index.vue
+++ b/pages/matches/[id]/index.vue
@@ -4,6 +4,7 @@ import { computed, markRaw, onUnmounted, provide, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useApolloClient } from "@vue/apollo-composable";
import gql from "graphql-tag";
+import { NuxtLink } from "#components";
import { useMatchClips } from "~/composables/useMatchClips";
import MatchTabs from "~/components/match/MatchTabs.vue";
import AnimatedStat from "~/components/AnimatedStat.vue";
@@ -294,7 +295,7 @@ const vsBaseClasses =
class="flex items-center gap-3 min-w-0 justify-center text-center lg:justify-start lg:text-left"
>
{
const map: Record = {};
for (const t of this.teamAwards) {
- const teamId = t.tournament_team?.team_id;
- if (!teamId) continue;
- (map[teamId] ??= []).push(t);
+ if (!t.team_id) {
+ continue;
+ }
+ (map[t.team_id] ??= []).push(t);
}
return map;
},
winnerTeamIds(): string[] {
- return Object.keys(this.awardsByTeamId);
+ return [
+ ...new Set(
+ this.teamAwards
+ .filter((t) => t.team_id && t.source === "tournament")
+ .map((t) => t.team_id as string),
+ ),
+ ];
},
teamsFilterCount(): number {
let n = 0;
@@ -364,9 +371,12 @@ export default {
},
{
id: true,
+ team_id: true,
+ source: true,
placement: true,
placement_tier: true,
tournament_id: true,
+ created_at: true,
award: {
id: true,
name: true,
@@ -391,9 +401,6 @@ export default {
silhouette: true,
image_url: true,
},
- tournament_team: {
- team_id: true,
- },
},
],
});
diff --git a/tests/components/InstallPWA.spec.ts b/tests/components/InstallPWA.spec.ts
new file mode 100644
index 000000000..2805a4a2b
--- /dev/null
+++ b/tests/components/InstallPWA.spec.ts
@@ -0,0 +1,115 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { defineComponent, h } from "vue";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import { SidebarProvider } from "@/components/ui/sidebar";
+import InstallPWA from "~/components/InstallPWA.vue";
+import { emulateDevice, fakePwa, userAgents } from "../helpers/pwaDevice";
+
+let pwa: ReturnType | undefined;
+let unmount: (() => void) | undefined;
+
+afterEach(() => {
+ unmount?.();
+ unmount = undefined;
+ pwa?.restore();
+ pwa = undefined;
+ vi.restoreAllMocks();
+});
+
+async function mountInstallButton() {
+ const wrapper = await mountSuspended(
+ defineComponent({
+ render: () =>
+ h(SidebarProvider, null, () =>
+ h(InstallPWA, { isMenuItem: false, showLabel: true }),
+ ),
+ }),
+ );
+ unmount = () => wrapper.unmount();
+ return wrapper;
+}
+
+function installButton(
+ wrapper: Awaited>,
+) {
+ return wrapper
+ .findAll("button")
+ .find((button) => button.text() === "Install App");
+}
+
+describe("InstallPWA", () => {
+ it("shows Android browser-menu steps when there is no native prompt", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const wrapper = await mountInstallButton();
+ const button = installButton(wrapper);
+ expect(button).toBeDefined();
+
+ await button!.trigger("click");
+ await flushPromises();
+
+ expect(pwa.install).not.toHaveBeenCalled();
+ expect(document.body.textContent).toContain(
+ "Select 'Install app' or 'Add to Home screen'",
+ );
+ expect(document.body.textContent).not.toContain("Press the 'Share' button");
+ });
+
+ it("tells an in-app WebView to open the page in a browser first", async () => {
+ emulateDevice({ userAgent: userAgents.androidWebView, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const wrapper = await mountInstallButton();
+ const button = installButton(wrapper);
+ expect(button).toBeDefined();
+
+ await button!.trigger("click");
+ await flushPromises();
+
+ expect(document.body.textContent).toContain(
+ "Open this page in your browser",
+ );
+ });
+
+ it("opens the native prompt on Android when one was captured", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: true });
+
+ const wrapper = await mountInstallButton();
+ await installButton(wrapper)!.trigger("click");
+ await flushPromises();
+
+ expect(pwa.install).toHaveBeenCalledTimes(1);
+ expect(document.body.textContent).not.toContain(
+ "Select 'Install app' or 'Add to Home screen'",
+ );
+ });
+
+ it("keeps the iOS Share steps", async () => {
+ emulateDevice({ userAgent: userAgents.iphoneSafari, width: 390 });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const wrapper = await mountInstallButton();
+ await installButton(wrapper)!.trigger("click");
+ await flushPromises();
+
+ expect(pwa.install).not.toHaveBeenCalled();
+ expect(document.body.textContent).toContain("Press the 'Share' button");
+ expect(document.body.textContent).toContain("Select 'Add to Home Screen'");
+ });
+
+ it("renders nothing inside the installed app", async () => {
+ emulateDevice({
+ userAgent: userAgents.androidChrome,
+ width: 412,
+ standalone: true,
+ });
+ pwa = fakePwa({ showInstallPrompt: true });
+
+ const wrapper = await mountInstallButton();
+
+ expect(installButton(wrapper)).toBeUndefined();
+ });
+});
diff --git a/tests/components/MatchTableRow.spec.ts b/tests/components/MatchTableRow.spec.ts
new file mode 100644
index 000000000..ec8f25191
--- /dev/null
+++ b/tests/components/MatchTableRow.spec.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it, vi } from "vitest";
+import { RouterLink } from "vue-router";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import MatchTableRow from "~/components/MatchTableRow.vue";
+
+function finishedMatch() {
+ return {
+ id: "match-1",
+ status: "Finished",
+ source: null,
+ created_at: "2026-01-01T00:00:00Z",
+ started_at: "2026-01-01T00:00:00Z",
+ ended_at: "2026-01-01T00:40:00Z",
+ lineup_1_id: "lineup-1",
+ lineup_2_id: "lineup-2",
+ winning_lineup_id: "lineup-1",
+ lineup_1: { id: "lineup-1", name: "Alpha", team_id: "team-1" },
+ lineup_2: { id: "lineup-2", name: "Pick-up Five", team_id: null },
+ lineup_counts: { lineup_1_count: 5, lineup_2_count: 5 },
+ max_players_per_lineup: 5,
+ options: { best_of: 1, type: "Competitive" },
+ match_maps: [],
+ streams: [],
+ tournament_brackets: [],
+ };
+}
+
+describe.each([
+ { layout: "compact", compact: true },
+ { layout: "full", compact: false },
+])("MatchTableRow team names ($layout)", ({ compact }) => {
+ it("links a lineup with a team to /teams/", async () => {
+ const wrapper = await mountSuspended(MatchTableRow, {
+ props: { match: finishedMatch(), compact },
+ });
+
+ const links = wrapper.findAll('a[href="/teams/team-1"]');
+ expect(links.length).toBeGreaterThan(0);
+ expect(links[0].text()).toBe("Alpha");
+ expect(wrapper.find("nuxtlink").exists()).toBe(false);
+ });
+
+ it("does not link a lineup without a team", async () => {
+ const wrapper = await mountSuspended(MatchTableRow, {
+ props: { match: finishedMatch(), compact },
+ });
+
+ const pickUp = wrapper
+ .findAll("span")
+ .find((span) => span.text() === "Pick-up Five");
+ expect(pickUp).toBeDefined();
+ expect(pickUp!.element.closest("a")).toBeNull();
+ });
+
+ it("clicking the team name opens the team, not the match", async () => {
+ const router = useRouter();
+ const push = vi.spyOn(router, "push").mockResolvedValue(undefined);
+
+ try {
+ // mountSuspended swaps in a RouterLink whose navigate() is a no-op.
+ const wrapper = await mountSuspended(MatchTableRow, {
+ props: { match: finishedMatch(), compact },
+ global: { components: { RouterLink } },
+ });
+
+ const teamName = wrapper
+ .findAll("h3 > *")
+ .find((element) => element.text() === "Alpha");
+ await teamName!.trigger("click");
+
+ expect(push).toHaveBeenCalledTimes(1);
+ expect(router.resolve(push.mock.calls[0][0]).path).toBe("/teams/team-1");
+ } finally {
+ push.mockRestore();
+ }
+ });
+});
diff --git a/tests/components/TeamsTable.spec.ts b/tests/components/TeamsTable.spec.ts
new file mode 100644
index 000000000..f12545108
--- /dev/null
+++ b/tests/components/TeamsTable.spec.ts
@@ -0,0 +1,206 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import TeamsTable from "~/components/TeamsTable.vue";
+import AwardBadge from "~/components/award/AwardBadge.vue";
+
+const teams = [
+ {
+ id: "team-1",
+ name: "Alpha",
+ short_name: "ALP",
+ avatar_url: null,
+ roster: [
+ {
+ roster_image_url: null,
+ player: {
+ steam_id: "76561198000000001",
+ name: "Roster Player",
+ avatar_url: "https://avatars.example/roster-player.jpg",
+ country: null,
+ elo: { competitive: 1500 },
+ },
+ },
+ ],
+ },
+];
+
+const manualGrant = {
+ id: "manual-team-1",
+ source: "manual",
+ team_id: "team-1",
+ placement: null,
+ placement_tier: null,
+ tournament_id: null,
+ created_at: "2026-09-01T12:00:00Z",
+ award: {
+ id: "award-heroes",
+ name: "Community Heroes",
+ tier: "special",
+ silhouette: null,
+ image_url: null,
+ },
+ tournament: null,
+ tournament_award: null,
+};
+
+const tournamentGrant = {
+ id: "tournament-team-1",
+ source: "tournament",
+ team_id: "team-1",
+ placement: 1,
+ placement_tier: null,
+ tournament_id: "tournament-1",
+ created_at: "2026-03-10T12:00:00Z",
+ award: {
+ id: "award-gold",
+ name: "Tournament Champion",
+ tier: "gold",
+ silhouette: null,
+ image_url: null,
+ },
+ tournament: {
+ id: "tournament-1",
+ name: "Spring Cup",
+ start: "2026-03-01T12:00:00Z",
+ stages: [],
+ },
+ tournament_award: null,
+};
+
+const manualTournamentGrant = {
+ id: "manual-tournament-team-1",
+ source: "manual",
+ team_id: "team-1",
+ placement: null,
+ placement_tier: null,
+ tournament_id: "tournament-1",
+ created_at: "2026-04-05T12:00:00Z",
+ award: {
+ id: "award-spirit",
+ name: "Spirit of the Cup",
+ tier: "special",
+ silhouette: null,
+ image_url: null,
+ },
+ tournament: {
+ id: "tournament-1",
+ name: "Spring Cup",
+ start: "2026-03-01T12:00:00Z",
+ stages: [],
+ },
+ tournament_award: null,
+};
+
+async function mountTable() {
+ return await mountSuspended(TeamsTable, {
+ props: {
+ teams,
+ awardsByTeamId: {
+ "team-1": [manualGrant, tournamentGrant, manualTournamentGrant],
+ },
+ },
+ attachTo: document.body,
+ });
+}
+
+function awardButton(wrapper: any, awardId: string) {
+ const badge = wrapper
+ .findAllComponents(AwardBadge)
+ .find((candidate: any) => candidate.props("award")?.id === awardId);
+ expect(badge, `award badge for ${awardId}`).toBeDefined();
+ return badge.find("*").element.closest("button") as HTMLButtonElement;
+}
+
+describe("TeamsTable awards", () => {
+ let unmount: (() => void) | null = null;
+
+ afterEach(() => {
+ unmount?.();
+ unmount = null;
+ vi.restoreAllMocks();
+ });
+
+ it("labels a manually granted award by its own name, never #null", async () => {
+ const wrapper = await mountTable();
+ unmount = () => wrapper.unmount();
+
+ const labels = wrapper
+ .findAll("button[aria-label]")
+ .map((button) => button.attributes("aria-label"));
+
+ expect(labels.join(" | ")).not.toContain("#null");
+ expect(labels).toContain("Granted — Community Heroes");
+ expect(labels).toContain("1st Place — Spring Cup");
+ });
+
+ it("opens the award for a manual grant and the tournament for a placement", async () => {
+ const push = vi.spyOn(useRouter(), "push").mockResolvedValue(undefined);
+ const wrapper = await mountTable();
+ unmount = () => wrapper.unmount();
+
+ awardButton(wrapper, "award-heroes").click();
+ expect(push).toHaveBeenLastCalledWith("/awards/award-heroes");
+
+ awardButton(wrapper, "award-gold").click();
+ expect(push).toHaveBeenLastCalledWith("/tournaments/tournament-1");
+ });
+
+ it("describes a manual grant with its name, grant date and award link in the tooltip", async () => {
+ const wrapper = await mountTable();
+ unmount = () => wrapper.unmount();
+
+ awardButton(wrapper, "award-heroes").focus();
+ await flushPromises();
+
+ const tooltip = document.body.querySelector(
+ "[data-reka-popper-content-wrapper]",
+ );
+ expect(tooltip).not.toBeNull();
+ const text = tooltip!.textContent ?? "";
+ expect(text).toContain("Community Heroes");
+ expect(text).not.toContain("Tournament");
+ expect(text).toContain("Granted");
+ expect(text).toContain("2026");
+ expect(text).toContain("Click to view award");
+ });
+
+ it("names a manual grant made inside a tournament after the award", async () => {
+ const wrapper = await mountTable();
+ unmount = () => wrapper.unmount();
+
+ const labels = wrapper
+ .findAll("button[aria-label]")
+ .map((button) => button.attributes("aria-label"));
+
+ expect(labels).toContain("Granted — Spirit of the Cup");
+ expect(labels).not.toContain("Granted — Spring Cup");
+ });
+
+ it("opens the award, not the tournament, for a manual grant made inside a tournament", async () => {
+ const push = vi.spyOn(useRouter(), "push").mockResolvedValue(undefined);
+ const wrapper = await mountTable();
+ unmount = () => wrapper.unmount();
+
+ awardButton(wrapper, "award-spirit").click();
+ expect(push).toHaveBeenLastCalledWith("/awards/award-spirit");
+ });
+
+ it("dates a manual grant made inside a tournament by when it was granted", async () => {
+ const wrapper = await mountTable();
+ unmount = () => wrapper.unmount();
+
+ awardButton(wrapper, "award-spirit").focus();
+ await flushPromises();
+
+ const tooltip = document.body.querySelector(
+ "[data-reka-popper-content-wrapper]",
+ );
+ expect(tooltip).not.toBeNull();
+ const text = tooltip!.textContent ?? "";
+ expect(text).toContain("Spirit of the Cup");
+ expect(text).toContain("APR 5, 2026");
+ expect(text).not.toContain("MAR 1, 2026");
+ expect(text).toContain("Click to view award");
+ });
+});
diff --git a/tests/components/award/AwardCase.spec.ts b/tests/components/award/AwardCase.spec.ts
new file mode 100644
index 000000000..0195a71eb
--- /dev/null
+++ b/tests/components/award/AwardCase.spec.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import AwardCase from "~/components/award/AwardCase.vue";
+
+const tournament = {
+ name: "Spring Cup",
+ start: "2026-03-01T12:00:00Z",
+ stages: [],
+};
+
+const manualTournamentGrant = {
+ id: "manual-tournament-1",
+ source: "manual",
+ placement: null,
+ placement_tier: null,
+ tournament_id: "tournament-1",
+ created_at: "2026-04-05T12:00:00Z",
+ award: {
+ id: "award-spirit",
+ name: "Spirit of the Cup",
+ tier: "special",
+ silhouette: null,
+ image_url: null,
+ },
+ tournament,
+ tournament_award: null,
+};
+
+const placementGrant = {
+ id: "tournament-gold-1",
+ source: "tournament",
+ placement: 1,
+ placement_tier: null,
+ tournament_id: "tournament-1",
+ created_at: "2026-03-10T12:00:00Z",
+ award: {
+ id: "award-gold",
+ name: "Tournament Champion",
+ tier: "gold",
+ silhouette: null,
+ image_url: null,
+ },
+ tournament,
+ tournament_award: null,
+};
+
+async function nameplate(awardId: string) {
+ const wrapper = await mountSuspended(AwardCase, {
+ props: { awards: [manualTournamentGrant, placementGrant] },
+ });
+ const link = wrapper.find(`a[href="/awards/${awardId}"]`);
+ expect(link.exists()).toBe(true);
+ return link.text();
+}
+
+describe("AwardCase grant nameplate", () => {
+ it("titles and dates a manual grant made inside a tournament by the award", async () => {
+ const text = await nameplate("award-spirit");
+
+ expect(text).toContain("Spirit of the Cup");
+ expect(text).not.toContain("Spring Cup");
+ expect(text).toContain("APR 5, 2026");
+ });
+
+ it("keeps titling and dating a placement by its tournament", async () => {
+ const text = await nameplate("award-gold");
+
+ expect(text).toContain("Spring Cup");
+ expect(text).toContain("MAR 1, 2026");
+ });
+});
diff --git a/tests/components/award/AwardComposer.spec.ts b/tests/components/award/AwardComposer.spec.ts
new file mode 100644
index 000000000..e96df8c17
--- /dev/null
+++ b/tests/components/award/AwardComposer.spec.ts
@@ -0,0 +1,115 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import {
+ mountSuspended,
+ registerEndpoint,
+} from "@nuxt/test-utils/runtime";
+import { readBody } from "h3";
+import AwardComposer from "~/components/award/AwardComposer.vue";
+
+const { query } = vi.hoisted(() => ({ query: vi.fn() }));
+
+vi.mock("@vue/apollo-composable", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useApolloClient: () => ({ client: { query, mutate: vi.fn() } }),
+}));
+
+const me = {
+ steam_id: "76561198000000042",
+ name: "Granting Admin",
+ avatar_url: null,
+ country: null,
+ role: "administrator",
+ is_banned: false,
+ is_muted: false,
+ is_gagged: false,
+ elo: null,
+};
+
+const award = {
+ id: "award-1",
+ name: "Clutch King",
+ description: null,
+ tier: "special",
+ allow_multiple: false,
+ image_url: null,
+ tournament_id: null,
+ event_id: null,
+ season_id: null,
+ league_season_id: null,
+};
+
+let mounted: { unmount: () => void } | null = null;
+
+const searchBodies: Array<{ query?: string; exclude?: string[] }> = [];
+
+registerEndpoint("/api/players-search", {
+ method: "POST",
+ handler: async (event: any) => {
+ const body = await readBody(event);
+ searchBodies.push(body);
+ const excluded = (body.exclude ?? []).map(String);
+ const hits = excluded.includes(me.steam_id) ? [] : [{ document: me }];
+ return { found: hits.length, hits };
+ },
+});
+
+async function openGrantPicker() {
+ const wrapper = await mountSuspended(AwardComposer, {
+ props: { open: true, grant: true, awardId: award.id, award },
+ attachTo: document.body,
+ });
+ mounted = wrapper;
+ await flushPromises();
+
+ const trigger = [...document.body.querySelectorAll("button")].find(
+ (button) => button.textContent?.includes("Select player"),
+ );
+ expect(trigger).toBeDefined();
+ trigger!.click();
+ await flushPromises();
+ await new Promise((resolve) => setTimeout(resolve, 350));
+ await flushPromises();
+ expect(document.body.querySelector('input[type="search"]')).not.toBeNull();
+
+ return wrapper;
+}
+
+function offeredRows(): string[] {
+ return [...document.body.querySelectorAll(".divide-y > div")].map(
+ (row) => row.textContent ?? "",
+ );
+}
+
+describe("AwardComposer grant recipient picker", () => {
+ beforeEach(() => {
+ query.mockResolvedValue({ data: { awards: [award] } });
+ useAuthStore().me = me as any;
+ searchBodies.length = 0;
+ });
+
+ afterEach(() => {
+ mounted?.unmount();
+ mounted = null;
+ useAuthStore().me = undefined;
+ useSearchStore().onlineOnly = true;
+ });
+
+ it("offers the granting user as a recipient under the online filter", async () => {
+ useSearchStore().onlineOnly = true;
+ await openGrantPicker();
+
+ expect(offeredRows().some((row) => row.includes(me.name))).toBe(true);
+ });
+
+ it("does not exclude the granting user from the player search", async () => {
+ useSearchStore().onlineOnly = false;
+ await openGrantPicker();
+
+ expect(searchBodies.length).toBeGreaterThan(0);
+ for (const body of searchBodies) {
+ expect(body.exclude ?? []).not.toContain(me.steam_id);
+ }
+ expect(offeredRows().some((row) => row.includes(me.name))).toBe(true);
+ });
+});
diff --git a/tests/components/award/AwardModal.spec.ts b/tests/components/award/AwardModal.spec.ts
new file mode 100644
index 000000000..71718d68e
--- /dev/null
+++ b/tests/components/award/AwardModal.spec.ts
@@ -0,0 +1,69 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import AwardModal from "~/components/award/AwardModal.vue";
+
+const manualTournamentGrant = {
+ id: "manual-tournament-1",
+ source: "manual",
+ placement: null,
+ placement_tier: null,
+ tournament_id: "tournament-1",
+ created_at: "2026-04-05T12:00:00Z",
+ note: null,
+ award: {
+ id: "award-spirit",
+ name: "Spirit of the Cup",
+ description: null,
+ tier: "special",
+ silhouette: null,
+ image_url: null,
+ },
+ tournament: {
+ name: "Spring Cup",
+ start: "2026-03-01T12:00:00Z",
+ stages: [],
+ },
+ tournament_award: null,
+};
+
+describe("AwardModal manual grant made inside a tournament", () => {
+ let unmount: (() => void) | null = null;
+
+ afterEach(() => {
+ unmount?.();
+ unmount = null;
+ });
+
+ async function openModal() {
+ const wrapper = await mountSuspended(AwardModal, {
+ props: { open: true, award: manualTournamentGrant },
+ });
+ unmount = () => wrapper.unmount();
+ await flushPromises();
+ const dialog = document.body.querySelector('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ return dialog!;
+ }
+
+ it("is titled and dated by the award, not the tournament", async () => {
+ const dialog = await openModal();
+
+ expect(dialog.querySelector("h2")?.textContent?.trim()).toBe(
+ "Spirit of the Cup",
+ );
+ expect(dialog.textContent).toContain("APR 5, 2026");
+ expect(dialog.textContent).not.toContain("MAR 1, 2026");
+ });
+
+ it("still links to both the tournament and the award", async () => {
+ const dialog = await openModal();
+
+ expect(
+ dialog.querySelector('a[href="/tournaments/tournament-1"]'),
+ ).not.toBeNull();
+ expect(
+ dialog.querySelector('a[href="/awards/award-spirit"]'),
+ ).not.toBeNull();
+ });
+});
diff --git a/tests/components/events/EventHero.spec.ts b/tests/components/events/EventHero.spec.ts
new file mode 100644
index 000000000..c706af834
--- /dev/null
+++ b/tests/components/events/EventHero.spec.ts
@@ -0,0 +1,68 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import EventHero from "~/components/events/EventHero.vue";
+
+function event(banner: { filename: string; mime_type: string }) {
+ return {
+ id: "event-1",
+ name: "Spring Invitational",
+ starts_at: new Date(Date.now() + 86_400_000).toISOString(),
+ ends_at: null,
+ visibility: "Public",
+ banner: { id: "media-1", ...banner },
+ organizers: [],
+ };
+}
+
+describe("EventHero banner", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("lazy-loads a banner image", async () => {
+ const wrapper = await mountSuspended(EventHero, {
+ props: { event: event({ filename: "b.png", mime_type: "image/png" }) },
+ });
+
+ expect(wrapper.find("img").attributes("loading")).toBe("lazy");
+ });
+
+ it("only downloads and plays a banner video once it is on screen", async () => {
+ let onIntersect: (
+ entries: Partial[],
+ ) => void = () => {};
+ vi.stubGlobal(
+ "IntersectionObserver",
+ class {
+ constructor(callback: typeof onIntersect) {
+ onIntersect = callback;
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+ const play = vi
+ .spyOn(HTMLMediaElement.prototype, "play")
+ .mockResolvedValue(undefined);
+ const pause = vi
+ .spyOn(HTMLMediaElement.prototype, "pause")
+ .mockImplementation(() => {});
+
+ const wrapper = await mountSuspended(EventHero, {
+ props: { event: event({ filename: "b.mp4", mime_type: "video/mp4" }) },
+ });
+ const video = wrapper.find("video");
+
+ expect(video.attributes("preload")).toBe("none");
+ expect(video.attributes()).not.toHaveProperty("autoplay");
+ expect(play).not.toHaveBeenCalled();
+
+ onIntersect([{ isIntersecting: true }]);
+ expect(play).toHaveBeenCalledTimes(1);
+
+ onIntersect([{ isIntersecting: false }]);
+ expect(pause).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/components/events/EventSquare.spec.ts b/tests/components/events/EventSquare.spec.ts
new file mode 100644
index 000000000..c94a84403
--- /dev/null
+++ b/tests/components/events/EventSquare.spec.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import EventSquare from "~/components/events/EventSquare.vue";
+import { formatEventDate } from "~/utilities/eventDisplay";
+
+const DAY = 24 * 3_600_000;
+
+function event(starts_at: string, ends_at: string | null) {
+ return {
+ id: "event-1",
+ name: "Spring Invitational",
+ starts_at,
+ ends_at,
+ visibility: "Public",
+ banner: null,
+ };
+}
+
+async function chip(ev: ReturnType, label: string) {
+ const wrapper = await mountSuspended(EventSquare, { props: { event: ev } });
+ const found = wrapper.findAll("span").find((s) => s.text() === label);
+ expect(found, `phase chip "${label}"`).toBeTruthy();
+ return { wrapper, chip: found! };
+}
+
+describe("EventSquare", () => {
+ it("colours a finished event's chip as finished, not amber", async () => {
+ const past = event(
+ new Date(Date.now() - 3 * DAY).toISOString(),
+ new Date(Date.now() - 2 * DAY).toISOString(),
+ );
+
+ const { chip: finishedChip } = await chip(past, "Finished");
+
+ expect(finishedChip.classes()).toContain("text-success");
+ expect(finishedChip.classes().join(" ")).not.toContain("tac-amber");
+ });
+
+ it("keeps the amber chip for upcoming events", async () => {
+ const future = event(new Date(Date.now() + 2 * DAY).toISOString(), null);
+
+ const { chip: upcomingChip } = await chip(future, "Upcoming");
+
+ expect(upcomingChip.classes().join(" ")).toContain("tac-amber");
+ });
+
+ it("shows the start and end date of a finished event", async () => {
+ const starts = new Date(Date.now() - 10 * DAY).toISOString();
+ const ends = new Date(Date.now() - 8 * DAY).toISOString();
+
+ const { wrapper } = await chip(event(starts, ends), "Finished");
+
+ expect(wrapper.text().replace(/\s+/g, " ")).toContain(
+ `${formatEventDate(starts)} – ${formatEventDate(ends)}`,
+ );
+ });
+});
diff --git a/tests/components/events/EventStandings.spec.ts b/tests/components/events/EventStandings.spec.ts
new file mode 100644
index 000000000..04459ed82
--- /dev/null
+++ b/tests/components/events/EventStandings.spec.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it, vi } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import EventStandings from "~/components/events/EventStandings.vue";
+
+const { query } = vi.hoisted(() => ({ query: vi.fn() }));
+
+vi.mock("@vue/apollo-composable", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useApolloClient: () => ({ client: { query } }),
+}));
+
+const award = {
+ award: {
+ id: "award-1",
+ name: "Gold",
+ tier: "gold",
+ silhouette: null,
+ image_url: null,
+ },
+ tournament: { name: "Spring Cup", start: "2026-01-01T00:00:00Z", stages: [] },
+ tournament_award: null,
+ player: null,
+ source: "auto",
+};
+
+function standings() {
+ return {
+ data: {
+ events_by_pk: {
+ tournaments: [
+ {
+ tournament: {
+ id: "tournament-1",
+ name: "Spring Cup",
+ awards: [
+ {
+ ...award,
+ id: "placement-1",
+ placement: 1,
+ player_steam_id: null,
+ tournament_team_id: "tt-1",
+ tournament_team: {
+ name: "Alpha",
+ team: { id: "team-1", name: "Alpha" },
+ },
+ team: { id: "team-1", name: "Alpha" },
+ },
+ {
+ ...award,
+ id: "placement-2",
+ placement: 2,
+ player_steam_id: "76561198000000001",
+ tournament_team_id: "tt-2",
+ tournament_team: { name: "Pick-up Five", team: null },
+ team: null,
+ player: { name: "Roster Player", avatar_url: null },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ };
+}
+
+async function mountStandings() {
+ query.mockResolvedValue(standings());
+ const wrapper = await mountSuspended(EventStandings, {
+ props: { eventId: "event-1" },
+ });
+ await flushPromises();
+ return wrapper;
+}
+
+describe("EventStandings team links", () => {
+ it("links the placement winner and the medal table row to /teams/", async () => {
+ const wrapper = await mountStandings();
+
+ const links = wrapper.findAll('a[href="/teams/team-1"]');
+ expect(links.map((link) => link.text())).toEqual(["Alpha", "Alpha"]);
+ expect(wrapper.find("nuxtlink").exists()).toBe(false);
+ });
+
+ it("does not link an ad-hoc tournament team", async () => {
+ const wrapper = await mountStandings();
+
+ const adHoc = wrapper
+ .findAll("span")
+ .filter((span) => span.text() === "Pick-up Five");
+ expect(adHoc.length).toBeGreaterThan(0);
+ for (const span of adHoc) {
+ expect(span.element.closest("a")).toBeNull();
+ }
+ });
+});
diff --git a/tests/components/events/EventTeamsPanel.spec.ts b/tests/components/events/EventTeamsPanel.spec.ts
new file mode 100644
index 000000000..68d7ad2d8
--- /dev/null
+++ b/tests/components/events/EventTeamsPanel.spec.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import EventTeamsPanel from "~/components/events/EventTeamsPanel.vue";
+
+describe("EventTeamsPanel", () => {
+ it("links a team with a team page to /teams/", async () => {
+ const wrapper = await mountSuspended(EventTeamsPanel, {
+ props: {
+ teams: [{ id: "abc", name: "Team A", short_name: "TA" }],
+ players: [],
+ },
+ });
+
+ const link = wrapper.find('a[href="/teams/abc"]');
+ expect(link.exists()).toBe(true);
+ expect(link.text()).toContain("Team A");
+ expect(wrapper.find("nuxtlink").exists()).toBe(false);
+ });
+
+ it("does not link a team without a team page", async () => {
+ const wrapper = await mountSuspended(EventTeamsPanel, {
+ props: {
+ teams: [{ id: null, name: "Ad-hoc Five" }],
+ players: [],
+ },
+ });
+
+ expect(wrapper.text()).toContain("Ad-hoc Five");
+ expect(wrapper.find("a").exists()).toBe(false);
+ expect(wrapper.find("nuxtlink").exists()).toBe(false);
+ });
+});
diff --git a/tests/components/tournament/TournamentMatchOptionsForm.spec.ts b/tests/components/tournament/TournamentMatchOptionsForm.spec.ts
new file mode 100644
index 000000000..7c35fdffa
--- /dev/null
+++ b/tests/components/tournament/TournamentMatchOptionsForm.spec.ts
@@ -0,0 +1,74 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import TournamentMatchOptionsForm from "~/components/tournament/TournamentMatchOptionsForm.vue";
+
+const tournament = (status: string, substitutes_enabled = true) => ({
+ id: "tournament-1",
+ status,
+ auto_start: true,
+ scheduling_mode: "auto",
+ substitutes_enabled,
+ options: {
+ id: "options-1",
+ type: "Competitive",
+ mr: 12,
+ best_of: 1,
+ map_pool: { id: "pool-1", maps: [] },
+ },
+});
+
+describe("TournamentMatchOptionsForm substitutes switch", () => {
+ let unmount: (() => void) | null = null;
+
+ afterEach(() => {
+ unmount?.();
+ unmount = null;
+ });
+
+ async function substitutesSwitch(status: string, enabled = true) {
+ const wrapper = await mountSuspended(TournamentMatchOptionsForm, {
+ props: { tournament: tournament(status, enabled) },
+ global: {
+ stubs: {
+ MatchOptions: { template: "
" },
+ SettingsSaveBar: true,
+ },
+ },
+ });
+ unmount = () => wrapper.unmount();
+ await flushPromises();
+
+ const row = wrapper
+ .findAll("div.flex.flex-row")
+ .find((div) => div.text().includes("Allow Substitutes"));
+ expect(row).toBeDefined();
+ return row!;
+ }
+
+ it("can still be turned off while registration is open", async () => {
+ const row = await substitutesSwitch("RegistrationOpen");
+
+ expect(row.find("button[role='switch']").attributes("disabled")).toBe(
+ undefined,
+ );
+ expect(row.text()).not.toContain("once registration has closed");
+ });
+
+ it("is locked on once registration has closed", async () => {
+ const row = await substitutesSwitch("Live");
+
+ expect(
+ row.find("button[role='switch']").attributes("disabled"),
+ ).toBeDefined();
+ expect(row.text()).toContain("once registration has closed");
+ });
+
+ it("can still be turned back on after registration has closed", async () => {
+ const row = await substitutesSwitch("Live", false);
+
+ expect(row.find("button[role='switch']").attributes("disabled")).toBe(
+ undefined,
+ );
+ });
+});
diff --git a/tests/components/tournament/TournamentStageForm.spec.ts b/tests/components/tournament/TournamentStageForm.spec.ts
new file mode 100644
index 000000000..160f1ec43
--- /dev/null
+++ b/tests/components/tournament/TournamentStageForm.spec.ts
@@ -0,0 +1,155 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import TournamentStageForm from "~/components/tournament/TournamentStageForm.vue";
+import { e_player_roles_enum } from "~/generated/zeus";
+
+const region = (value: string, description: string, is_lan: boolean) => ({
+ value,
+ description,
+ is_lan,
+ status: "Online",
+ has_node: true,
+});
+
+const tournament = (regions: string[]) => ({
+ id: "tournament-1",
+ status: "Setup",
+ options: {
+ id: "options-1",
+ type: "Competitive",
+ mr: 12,
+ best_of: 1,
+ tv_delay: 115,
+ veto_pick_timeout: 60,
+ region_veto: false,
+ regions,
+ check_in_setting: "Captains",
+ ready_setting: "Players",
+ tech_timeout_setting: "Admin",
+ match_mode: "auto",
+ knife_round: true,
+ default_models: true,
+ overtime: true,
+ coaches: true,
+ number_of_substitutes: 0,
+ timeout_setting: "Admin",
+ map_pool: { id: "pool-1", maps: [] },
+ },
+});
+
+let unmount: (() => void) | null = null;
+
+beforeEach(() => {
+ useAuthStore().me = {
+ steam_id: "76561198000000001",
+ name: "Organizer",
+ role: e_player_roles_enum.administrator,
+ } as ReturnType["me"];
+
+ useApplicationSettingsStore().availableRegions = [
+ region("eu", "Europe", false),
+ region("lan", "Main Hall", true),
+ ];
+});
+
+afterEach(() => {
+ unmount?.();
+ unmount = null;
+ useAuthStore().me = undefined;
+ useApplicationSettingsStore().availableRegions = [];
+});
+
+async function mountStageForm(regions: string[]) {
+ const wrapper = await mountSuspended(TournamentStageForm, {
+ props: { order: 1, tournament: tournament(regions) },
+ global: { stubs: { SettingsSaveBar: true } },
+ });
+ unmount = () => wrapper.unmount();
+ await flushPromises();
+ return wrapper;
+}
+
+function regionCard(wrapper: Awaited>) {
+ const card = wrapper
+ .findAll("div.p-6.space-y-6")
+ .find((div) => div.text().includes("Region Settings"));
+ expect(card).toBeDefined();
+ return card!;
+}
+
+function regionTrigger(wrapper: Awaited>) {
+ return regionCard(wrapper).find("button[role='combobox']");
+}
+
+async function openRegions(
+ wrapper: Awaited>,
+) {
+ await regionTrigger(wrapper).trigger("pointerdown", {
+ button: 0,
+ pointerType: "mouse",
+ });
+ await flushPromises();
+
+ return Array.from(
+ document.body.querySelectorAll('[role="option"]'),
+ ).map((option) => option.textContent?.trim());
+}
+
+function lanSwitch(wrapper: Awaited>) {
+ const row = regionCard(wrapper)
+ .findAll("div.flex.justify-between.items-center")
+ .find((div) => div.text().includes("LAN Match"));
+ return row?.find("button[role='switch']");
+}
+
+describe("TournamentStageForm region settings", () => {
+ it("shows the inherited online region as the current selection", async () => {
+ const wrapper = await mountStageForm(["eu"]);
+
+ expect(regionTrigger(wrapper).text()).toContain("Europe");
+ });
+
+ it("shows the inherited LAN region as the current selection", async () => {
+ const wrapper = await mountStageForm(["lan"]);
+
+ expect(regionTrigger(wrapper).text()).toContain("Main Hall");
+ expect(lanSwitch(wrapper)?.attributes("aria-checked")).toBe("true");
+ expect((wrapper.vm as any).isDirty).toBe(false);
+ });
+
+ it("offers the LAN regions once the LAN switch is on", async () => {
+ const wrapper = await mountStageForm(["eu"]);
+
+ expect(await openRegions(wrapper)).toEqual(["Europe"]);
+
+ await lanSwitch(wrapper)!.trigger("click");
+ await flushPromises();
+
+ expect(await openRegions(wrapper)).toEqual(["Main Hall"]);
+ expect((wrapper.vm as any).form.values.regions).toEqual(["lan"]);
+ });
+
+ it("turns region veto off while the stage is on LAN", async () => {
+ const wrapper = await mountStageForm(["eu"]);
+
+ (wrapper.vm as any).form.setFieldValue("region_veto", true);
+ await flushPromises();
+
+ await lanSwitch(wrapper)!.trigger("click");
+ await flushPromises();
+
+ expect((wrapper.vm as any).form.values.region_veto).toBe(false);
+ });
+
+ it("hides the LAN switch when no LAN region exists", async () => {
+ useApplicationSettingsStore().availableRegions = [
+ region("eu", "Europe", false),
+ region("na", "North America", false),
+ ];
+
+ const wrapper = await mountStageForm(["eu"]);
+
+ expect(lanSwitch(wrapper)).toBeUndefined();
+ });
+});
diff --git a/tests/composables/usePwaInstall.spec.ts b/tests/composables/usePwaInstall.spec.ts
new file mode 100644
index 000000000..1cfa87a08
--- /dev/null
+++ b/tests/composables/usePwaInstall.spec.ts
@@ -0,0 +1,130 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { defineComponent, h } from "vue";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import { usePwaInstall } from "~/composables/usePwaInstall";
+import { emulateDevice, fakePwa, userAgents } from "../helpers/pwaDevice";
+
+let pwa: ReturnType | undefined;
+
+afterEach(() => {
+ pwa?.restore();
+ pwa = undefined;
+ vi.restoreAllMocks();
+});
+
+async function mountInstallState() {
+ let state!: ReturnType;
+ await mountSuspended(
+ defineComponent({
+ setup() {
+ state = usePwaInstall();
+ return () => h("div");
+ },
+ }),
+ );
+ return state;
+}
+
+describe("usePwaInstall", () => {
+ it("offers manual install steps on Android when the browser never fired beforeinstallprompt", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const state = await mountInstallState();
+
+ expect(state.installed.value).toBe(false);
+ expect(state.canInstall.value).toBe(true);
+ expect(state.manualInstall.value).toBe("android");
+ });
+
+ it("tells in-app WebViews to open the page in a browser", async () => {
+ emulateDevice({ userAgent: userAgents.androidWebView, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const state = await mountInstallState();
+
+ expect(state.canInstall.value).toBe(true);
+ expect(state.manualInstall.value).toBe("in_app");
+ });
+
+ it("prefers the native prompt on Android once it has been captured", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: true, outcome: "dismissed" });
+
+ const state = await mountInstallState();
+
+ expect(state.canInstall.value).toBe(true);
+ expect(state.manualInstall.value).toBeNull();
+
+ await state.install();
+
+ expect(pwa.install).toHaveBeenCalledTimes(1);
+ });
+
+ it("treats an accepted native prompt as installed before appinstalled lands", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: true, outcome: "accepted" });
+
+ const state = await mountInstallState();
+ await state.install();
+ await flushPromises();
+
+ expect(state.installed.value).toBe(true);
+ expect(state.canInstall.value).toBe(false);
+ });
+
+ it("shares an accepted install with every other consumer in the tab", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: true, outcome: "accepted" });
+
+ const button = await mountInstallState();
+ const page = await mountInstallState();
+
+ await button.install();
+ await flushPromises();
+
+ expect(page.installed.value).toBe(true);
+ expect(page.canInstall.value).toBe(false);
+
+ const laterMounted = await mountInstallState();
+
+ expect(laterMounted.installed.value).toBe(true);
+ expect(laterMounted.canInstall.value).toBe(false);
+ });
+
+ it("offers nothing once running as the installed app", async () => {
+ emulateDevice({
+ userAgent: userAgents.androidChrome,
+ width: 412,
+ standalone: true,
+ });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const state = await mountInstallState();
+
+ expect(state.installed.value).toBe(true);
+ expect(state.canInstall.value).toBe(false);
+ expect(state.manualInstall.value).toBeNull();
+ });
+
+ it("keeps offering the Share steps on iOS", async () => {
+ emulateDevice({ userAgent: userAgents.iphoneSafari, width: 390 });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const state = await mountInstallState();
+
+ expect(state.canInstall.value).toBe(true);
+ expect(state.manualInstall.value).toBe("ios");
+ });
+
+ it("offers nothing on a desktop browser without a native prompt", async () => {
+ emulateDevice({ userAgent: userAgents.desktopChrome });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const state = await mountInstallState();
+
+ expect(state.canInstall.value).toBe(false);
+ expect(state.manualInstall.value).toBeNull();
+ });
+});
diff --git a/tests/guards/dynamicNuxtLink.spec.ts b/tests/guards/dynamicNuxtLink.spec.ts
new file mode 100644
index 000000000..833b53075
--- /dev/null
+++ b/tests/guards/dynamicNuxtLink.spec.ts
@@ -0,0 +1,72 @@
+// @vitest-environment node
+import { readdirSync, readFileSync } from "node:fs";
+import { join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+// resolveDynamicComponent can't find "NuxtLink"; it renders an inert .
+const root = fileURLToPath(new URL("../..", import.meta.url));
+const scannedDirs = ["components", "pages", "layouts"];
+
+const IS_ATTRIBUTE = /(?:^|\s)(:|v-bind:)?is\s*=\s*("([^"]*)"|'([^']*)')/g;
+const NUXT_LINK_NAME = /^(NuxtLink|nuxt-link)$/;
+const NUXT_LINK_STRING = /(["'`])(NuxtLink|nuxt-link)\1/;
+const RENDER_FUNCTION_STRING =
+ /\b(resolveDynamicComponent|h)\(\s*(["'`])(NuxtLink|nuxt-link)\2/g;
+
+function sourceFiles(dir: string): string[] {
+ return readdirSync(join(root, dir), { recursive: true, encoding: "utf8" })
+ .filter((file) => /\.(vue|ts|tsx)$/.test(file))
+ .map((file) => join(root, dir, file));
+}
+
+function lineOf(source: string, index: number): number {
+ return source.slice(0, index).split("\n").length;
+}
+
+function findStringNuxtLinks(file: string, source: string): string[] {
+ const hits: string[] = [];
+ const location = (index: number) =>
+ `${relative(root, file)}:${lineOf(source, index)}`;
+
+ for (const match of source.matchAll(IS_ATTRIBUTE)) {
+ const bound = Boolean(match[1]);
+ const value = match[3] ?? match[4] ?? "";
+ if (
+ bound ? NUXT_LINK_STRING.test(value) : NUXT_LINK_NAME.test(value.trim())
+ ) {
+ hits.push(`${location(match.index)} ${match[0].trim()}`);
+ }
+ }
+
+ for (const match of source.matchAll(RENDER_FUNCTION_STRING)) {
+ hits.push(`${location(match.index)} ${match[0]}`);
+ }
+
+ return hits;
+}
+
+describe("NuxtLink used as a dynamic component", () => {
+ it("detects the string form it guards against", () => {
+ expect(
+ findStringNuxtLinks(
+ "x.vue",
+ `\n`,
+ ),
+ ).toHaveLength(2);
+ expect(
+ findStringNuxtLinks(
+ "x.vue",
+ ``,
+ ),
+ ).toHaveLength(0);
+ });
+
+ it("is never referenced by name in components, pages or layouts", () => {
+ const hits = scannedDirs
+ .flatMap(sourceFiles)
+ .flatMap((file) => findStringNuxtLinks(file, readFileSync(file, "utf8")));
+
+ expect(hits).toEqual([]);
+ });
+});
diff --git a/tests/helpers/fakeHasura.ts b/tests/helpers/fakeHasura.ts
new file mode 100644
index 000000000..e51d46a35
--- /dev/null
+++ b/tests/helpers/fakeHasura.ts
@@ -0,0 +1,124 @@
+import { Kind, valueFromASTUntyped } from "graphql";
+import type { DocumentNode, FieldNode, OperationDefinitionNode } from "graphql";
+
+type Row = Record;
+
+function compareValues(a: any, b: any): number {
+ if (typeof a === "string" && typeof b === "string") {
+ const aTime = Date.parse(a);
+ const bTime = Date.parse(b);
+ if (Number.isFinite(aTime) && Number.isFinite(bTime)) {
+ return aTime - bTime;
+ }
+ }
+ if (a === b) {
+ return 0;
+ }
+ return a < b ? -1 : 1;
+}
+
+function matchesOperator(value: any, operator: string, operand: any): boolean {
+ if (operator === "_is_null") {
+ return (value == null) === operand;
+ }
+ // SQL semantics: every other comparison against NULL is not true.
+ if (value == null) {
+ return false;
+ }
+ switch (operator) {
+ case "_eq":
+ return compareValues(value, operand) === 0;
+ case "_neq":
+ return compareValues(value, operand) !== 0;
+ case "_gt":
+ return compareValues(value, operand) > 0;
+ case "_gte":
+ return compareValues(value, operand) >= 0;
+ case "_lt":
+ return compareValues(value, operand) < 0;
+ case "_lte":
+ return compareValues(value, operand) <= 0;
+ case "_in":
+ return (operand as any[]).some((v) => compareValues(value, v) === 0);
+ }
+ throw new Error(`fakeHasura: unsupported operator ${operator}`);
+}
+
+export function matchesWhere(row: Row, where: Record): boolean {
+ return Object.entries(where).every(([key, condition]) => {
+ if (key === "_and") {
+ return (condition as any[]).every((w) => matchesWhere(row, w));
+ }
+ if (key === "_or") {
+ return (condition as any[]).some((w) => matchesWhere(row, w));
+ }
+ if (key === "_not") {
+ return !matchesWhere(row, condition);
+ }
+ return Object.entries(condition as Record).every(
+ ([operator, operand]) => matchesOperator(row[key], operator, operand),
+ );
+ });
+}
+
+function sortRows(rows: Row[], orderBy: Record[]): Row[] {
+ const terms = orderBy.flatMap((entry) => Object.entries(entry));
+ return [...rows].sort((a, b) => {
+ for (const [column, direction] of terms) {
+ const desc = direction.startsWith("desc");
+ const nullsFirst = direction.endsWith("nulls_first")
+ ? true
+ : direction.endsWith("nulls_last")
+ ? false
+ : desc;
+ const aNull = a[column] == null;
+ const bNull = b[column] == null;
+ if (aNull || bNull) {
+ if (aNull && bNull) {
+ continue;
+ }
+ return aNull === nullsFirst ? -1 : 1;
+ }
+ const diff = compareValues(a[column], b[column]);
+ if (diff !== 0) {
+ return desc ? -diff : diff;
+ }
+ }
+ return 0;
+ });
+}
+
+export function resolveRootField(
+ document: DocumentNode,
+ variables: Record | undefined,
+ table: Row[],
+): Record {
+ const operation = document.definitions.find(
+ (d): d is OperationDefinitionNode => d.kind === Kind.OPERATION_DEFINITION,
+ );
+ if (!operation) {
+ throw new Error("fakeHasura: document has no operation");
+ }
+ const field = operation.selectionSet.selections[0] as FieldNode;
+ const args: Record = Object.fromEntries(
+ (field.arguments ?? []).map((arg) => [
+ arg.name.value,
+ valueFromASTUntyped(arg.value, variables ?? {}),
+ ]),
+ );
+ const key = field.alias?.value ?? field.name.value;
+
+ const rows = table.filter((row) => matchesWhere(row, args.where ?? {}));
+ if (field.name.value.endsWith("_aggregate")) {
+ return { [key]: { aggregate: { count: rows.length } } };
+ }
+
+ const offset = args.offset ?? 0;
+ const sorted = sortRows(rows, [args.order_by ?? []].flat());
+ return {
+ [key]: sorted.slice(
+ offset,
+ args.limit == null ? undefined : offset + args.limit,
+ ),
+ };
+}
diff --git a/tests/helpers/pwaDevice.ts b/tests/helpers/pwaDevice.ts
new file mode 100644
index 000000000..2d86ab4d7
--- /dev/null
+++ b/tests/helpers/pwaDevice.ts
@@ -0,0 +1,79 @@
+import { vi } from "vitest";
+import { clearNuxtState, useNuxtApp } from "#imports";
+
+export const userAgents = {
+ androidChrome:
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36",
+ androidWebView:
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/AP2A.240805.005; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/128.0.0.0 Mobile Safari/537.36",
+ iphoneSafari:
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
+ desktopChrome:
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
+};
+
+export function emulateDevice({
+ userAgent,
+ width = 1280,
+ standalone = false,
+}: {
+ userAgent: string;
+ width?: number;
+ standalone?: boolean;
+}) {
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue(userAgent);
+ vi.spyOn(window, "matchMedia").mockImplementation((query: string) => {
+ const maxWidth = query.match(/max-width:\s*(\d+)px/);
+ const matches = query.includes("display-mode: standalone")
+ ? standalone
+ : maxWidth
+ ? width <= Number(maxWidth[1])
+ : false;
+
+ return {
+ matches,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ } as MediaQueryList;
+ });
+}
+
+export function fakePwa({
+ showInstallPrompt = false,
+ outcome = "dismissed",
+}: {
+ showInstallPrompt?: boolean;
+ outcome?: "accepted" | "dismissed";
+} = {}) {
+ const pwa = useNuxtApp().$pwa!;
+ const original = {
+ install: pwa.install,
+ showInstallPrompt: pwa.showInstallPrompt,
+ isPWAInstalled: pwa.isPWAInstalled,
+ };
+
+ const install = vi.fn(async () => {
+ pwa.showInstallPrompt = false;
+ return { outcome, platform: "web" };
+ });
+
+ pwa.install = install;
+ pwa.showInstallPrompt = showInstallPrompt;
+ pwa.isPWAInstalled = false;
+ clearNuxtState("pwa-installed-here");
+
+ return {
+ install,
+ restore() {
+ pwa.install = original.install;
+ pwa.showInstallPrompt = original.showInstallPrompt;
+ pwa.isPWAInstalled = original.isPWAInstalled;
+ clearNuxtState("pwa-installed-here");
+ },
+ };
+}
diff --git a/tests/layouts/components/ProfileMenu.spec.ts b/tests/layouts/components/ProfileMenu.spec.ts
new file mode 100644
index 000000000..e47aa1a5d
--- /dev/null
+++ b/tests/layouts/components/ProfileMenu.spec.ts
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import ProfileMenu from "~/layouts/components/ProfileMenu.vue";
+import { emulateDevice, fakePwa, userAgents } from "../../helpers/pwaDevice";
+
+let pwa: ReturnType | undefined;
+let unmount: (() => void) | undefined;
+
+beforeEach(() => {
+ useAuthStore().me = undefined;
+});
+
+afterEach(() => {
+ unmount?.();
+ unmount = undefined;
+ pwa?.restore();
+ pwa = undefined;
+ vi.restoreAllMocks();
+});
+
+async function mountOpenMenu(props: Record = {}) {
+ const wrapper = await mountSuspended(ProfileMenu, {
+ props: { open: true, ...props },
+ slots: { trigger: () => "profile" },
+ });
+ unmount = () => wrapper.unmount();
+ await flushPromises();
+ expect(document.body.querySelector('[role="menu"]')).not.toBeNull();
+ return wrapper;
+}
+
+function installRow() {
+ return Array.from(
+ document.body.querySelectorAll('[role="menuitem"]'),
+ ).find((item) => item.textContent?.trim() === "Install App");
+}
+
+describe("ProfileMenu install entry", () => {
+ it("offers Install App when asked to and a native prompt is waiting", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: true });
+
+ await mountOpenMenu({ showInstall: true });
+
+ const row = installRow();
+ expect(row).toBeDefined();
+
+ row!.click();
+ await flushPromises();
+
+ expect(pwa.install).toHaveBeenCalledTimes(1);
+ });
+
+ it("opens the Android steps from the menu when there is no native prompt", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const wrapper = await mountOpenMenu({ showInstall: true });
+
+ const row = installRow();
+ expect(row).toBeDefined();
+
+ row!.click();
+ await wrapper.setProps({ open: false });
+ await flushPromises();
+
+ expect(pwa.install).not.toHaveBeenCalled();
+ expect(document.body.textContent).toContain(
+ "Select 'Install app' or 'Add to Home screen'",
+ );
+ });
+
+ it("leaves the entry out unless the shell asks for it", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: true });
+
+ await mountOpenMenu();
+
+ expect(installRow()).toBeUndefined();
+ });
+
+ it("leaves the entry out inside the installed app", async () => {
+ emulateDevice({
+ userAgent: userAgents.androidChrome,
+ width: 412,
+ standalone: true,
+ });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ await mountOpenMenu({ showInstall: true });
+
+ expect(installRow()).toBeUndefined();
+ });
+});
diff --git a/tests/layouts/components/TopNav.spec.ts b/tests/layouts/components/TopNav.spec.ts
new file mode 100644
index 000000000..20e899a7f
--- /dev/null
+++ b/tests/layouts/components/TopNav.spec.ts
@@ -0,0 +1,114 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { computed, defineComponent, h, ref } from "vue";
+import { flushPromises } from "@vue/test-utils";
+import { mockNuxtImport, mountSuspended } from "@nuxt/test-utils/runtime";
+import { SidebarProvider } from "@/components/ui/sidebar";
+import TopNav from "~/layouts/components/TopNav.vue";
+import { e_player_roles_enum } from "~/generated/zeus";
+import { emulateDevice, fakePwa, userAgents } from "../../helpers/pwaDevice";
+
+mockNuxtImport("usePendingImports", () => () => ({ pendingImports: ref([]) }));
+mockNuxtImport("useCurrentLeagueSeason", () => () => ({
+ currentSeasonTo: computed(() => "/"),
+ currentSeason: ref(null),
+}));
+
+vi.mock("@/composables/useHubState", () => ({
+ useHubState: () => ({ openLastOrDefaultHub: () => {} }),
+}));
+
+const stubs = {
+ MatchLobbies: true,
+ DraftRoomNav: true,
+ SystemStatus: true,
+ PlayerDisplay: true,
+ PlayerPendingImports: true,
+ Logout: true,
+};
+
+let pwa: ReturnType | undefined;
+let unmount: (() => void) | undefined;
+
+beforeEach(() => {
+ useAuthStore().me = {
+ steam_id: "76561198000000001",
+ name: "Player",
+ role: e_player_roles_enum.user,
+ } as ReturnType["me"];
+});
+
+afterEach(() => {
+ unmount?.();
+ unmount = undefined;
+ useAuthStore().me = undefined;
+ pwa?.restore();
+ pwa = undefined;
+ vi.restoreAllMocks();
+});
+
+async function mountTopNav() {
+ const wrapper = await mountSuspended(
+ defineComponent({
+ render: () => h(SidebarProvider, null, () => h(TopNav)),
+ }),
+ { global: { stubs } },
+ );
+ unmount = () => wrapper.unmount();
+ await flushPromises();
+ return wrapper;
+}
+
+async function openProfileMenu(
+ wrapper: Awaited>,
+) {
+ const trigger = wrapper.find('button[aria-haspopup="menu"]');
+ expect(trigger.exists()).toBe(true);
+ await trigger.trigger("click");
+ await flushPromises();
+ expect(document.body.querySelector('[role="menu"]')).not.toBeNull();
+}
+
+function menuInstallRow() {
+ return Array.from(
+ document.body.querySelectorAll('[role="menuitem"]'),
+ ).find((item) => item.textContent?.trim() === "Install App");
+}
+
+describe("TopNav install entry", () => {
+ it("gives a phone-width player an Install App entry in the profile menu", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: true });
+
+ const wrapper = await mountTopNav();
+ await openProfileMenu(wrapper);
+
+ expect(menuInstallRow()).toBeDefined();
+ });
+
+ it("keeps the desktop header button and leaves the menu alone", async () => {
+ emulateDevice({ userAgent: userAgents.desktopChrome, width: 1280 });
+ pwa = fakePwa({ showInstallPrompt: true });
+
+ const wrapper = await mountTopNav();
+
+ expect(wrapper.find("svg.lucide-monitor-down").exists()).toBe(true);
+
+ await openProfileMenu(wrapper);
+
+ expect(menuInstallRow()).toBeUndefined();
+ });
+
+ it("offers nothing to a phone already running the installed app", async () => {
+ emulateDevice({
+ userAgent: userAgents.androidChrome,
+ width: 412,
+ standalone: true,
+ });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const wrapper = await mountTopNav();
+ await openProfileMenu(wrapper);
+
+ expect(menuInstallRow()).toBeUndefined();
+ });
+});
diff --git a/tests/pages/events/index.spec.ts b/tests/pages/events/index.spec.ts
new file mode 100644
index 000000000..583df3cf9
--- /dev/null
+++ b/tests/pages/events/index.spec.ts
@@ -0,0 +1,195 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { flushPromises } from "@vue/test-utils";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import EventsIndex from "~/pages/events/index.vue";
+import EventHero from "~/components/events/EventHero.vue";
+import EventSquare from "~/components/events/EventSquare.vue";
+import HorizontalScrollRow from "~/components/common/HorizontalScrollRow.vue";
+import { resolveRootField } from "../../helpers/fakeHasura";
+
+const HOUR = 3_600_000;
+const DAY = 24 * HOUR;
+
+function at(offsetMs: number) {
+ return new Date(Date.now() + offsetMs).toISOString();
+}
+
+function event(name: string, starts_at: string, ends_at: string | null) {
+ return {
+ id: `id-${name.toLowerCase().replace(/\s+/g, "-")}`,
+ name,
+ description: null,
+ starts_at,
+ ends_at,
+ visibility: "Public",
+ media_access: "Organizers",
+ hide_creator_organizer: false,
+ banner_media_id: null,
+ banner: null,
+ is_organizer: false,
+ can_upload_media: false,
+ organizer_steam_id: "76561198000000001",
+ organizer: null,
+ organizers: [],
+ tournaments_aggregate: { aggregate: { count: 0 } },
+ teams_aggregate: { aggregate: { count: 0 } },
+ players_aggregate: { aggregate: { count: 0 } },
+ media_aggregate: { aggregate: { count: 0 } },
+ };
+}
+
+function upcoming(n: number) {
+ return event(`Upcoming ${String(n).padStart(2, "0")}`, at(n * DAY), null);
+}
+
+function finished(n: number) {
+ return event(
+ `Finished ${String(n).padStart(2, "0")}`,
+ at(-(n + 1) * DAY),
+ at(-n * DAY),
+ );
+}
+
+let table: any[] = [];
+
+function serve() {
+ const client = (useNuxtApp() as any).$apollo.defaultClient;
+ vi.spyOn(client, "subscribe").mockImplementation((options: any) => ({
+ subscribe(observer: any) {
+ const data = resolveRootField(options.query, options.variables, table);
+ Promise.resolve().then(() => observer.next({ data }));
+ return { unsubscribe() {}, closed: false };
+ },
+ }));
+}
+
+async function mountPage() {
+ serve();
+ // mountSuspended drops app mixins; apollo-option's mixin starts $subscribe.
+ const wrapper = await mountSuspended(EventsIndex, {
+ global: { mixins: (useNuxtApp().vueApp as any)._context.mixins },
+ });
+ await flushPromises();
+ return wrapper;
+}
+
+function names(wrapper: any, component: any): string[] {
+ return wrapper
+ .findAllComponents(component)
+ .map((card: any) => card.props("event").name);
+}
+
+describe("events index sections", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("renders upcoming events as the large hero cards and past events as compact cards", async () => {
+ table = [
+ event("Live Cup", at(-HOUR), at(DAY)),
+ upcoming(2),
+ upcoming(1),
+ finished(1),
+ finished(2),
+ ];
+
+ const wrapper = await mountPage();
+
+ const heroes = names(wrapper, EventHero);
+ const squares = names(wrapper, EventSquare);
+ expect(heroes).toEqual(["Live Cup", "Upcoming 01", "Upcoming 02"]);
+ expect(squares).toEqual(["Finished 01", "Finished 02"]);
+ });
+
+ it("orders upcoming events soonest first", async () => {
+ table = [upcoming(3), upcoming(1), upcoming(2)];
+
+ const wrapper = await mountPage();
+
+ expect(names(wrapper, EventHero)).toEqual([
+ "Upcoming 01",
+ "Upcoming 02",
+ "Upcoming 03",
+ ]);
+ });
+
+ it("shows the soonest upcoming and live events when there are more events than one page", async () => {
+ table = [
+ ...Array.from({ length: 15 }, (_, i) => upcoming(i + 1)),
+ event("Live Cup", at(-HOUR), null),
+ ...Array.from({ length: 15 }, (_, i) => finished(i + 1)),
+ ];
+
+ const wrapper = await mountPage();
+
+ const heroes = names(wrapper, EventHero);
+ expect(heroes[0]).toBe("Live Cup");
+ expect(heroes[1]).toBe("Upcoming 01");
+ expect(names(wrapper, EventSquare)[0]).toBe("Finished 01");
+ });
+
+ it.each([
+ {
+ label: "Upcoming",
+ rows: () => Array.from({ length: 10 }, (_, i) => upcoming(i + 1)),
+ },
+ {
+ label: "Live now",
+ rows: () =>
+ Array.from({ length: 10 }, (_, i) =>
+ event(
+ `Live ${String(i + 1).padStart(2, "0")}`,
+ at(-(i + 1) * HOUR),
+ null,
+ ),
+ ),
+ },
+ ])(
+ "limits the $label heroes and shows more on request",
+ async ({ label, rows }) => {
+ table = rows();
+ const expected = table.map((row) => row.name);
+
+ const wrapper = await mountPage();
+ const section = () =>
+ wrapper.findAll("section").find((s) => s.text().startsWith(label))!;
+ const showMore = () =>
+ section()
+ .findAll("button")
+ .find((b) => b.text() === "Show more");
+
+ const initial = names(section(), EventHero);
+ expect(initial.length).toBeGreaterThan(0);
+ expect(initial.length).toBeLessThan(expected.length);
+ expect(initial).toEqual(expected.slice(0, initial.length));
+
+ for (let i = 0; i < expected.length && showMore(); i++) {
+ await showMore()!.trigger("click");
+ await flushPromises();
+ }
+
+ expect(names(section(), EventHero)).toEqual(expected);
+ expect(showMore()).toBeUndefined();
+ },
+ );
+
+ it("loads more past events as the compact row nears its end", async () => {
+ table = Array.from({ length: 30 }, (_, i) => finished(i + 1));
+
+ const wrapper = await mountPage();
+
+ const initial = names(wrapper, EventSquare);
+ expect(initial.length).toBeGreaterThan(0);
+ expect(initial.length).toBeLessThan(30);
+ expect(initial[0]).toBe("Finished 01");
+
+ for (let i = 0; i < 5; i++) {
+ wrapper.findComponent(HorizontalScrollRow).vm.$emit("approaching-end");
+ await flushPromises();
+ }
+
+ const all = names(wrapper, EventSquare);
+ expect(all).toHaveLength(30);
+ expect(all[29]).toBe("Finished 30");
+ });
+});
diff --git a/tests/pages/settings/notification-preferences.spec.ts b/tests/pages/settings/notification-preferences.spec.ts
new file mode 100644
index 000000000..881c46b53
--- /dev/null
+++ b/tests/pages/settings/notification-preferences.spec.ts
@@ -0,0 +1,102 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { defineComponent, h, ref } from "vue";
+import { flushPromises } from "@vue/test-utils";
+import { mockNuxtImport, mountSuspended } from "@nuxt/test-utils/runtime";
+import { SidebarProvider } from "@/components/ui/sidebar";
+import NotificationPreferences from "~/pages/settings/notification-preferences.vue";
+import { emulateDevice, fakePwa, userAgents } from "../../helpers/pwaDevice";
+
+mockNuxtImport("usePushNotifications", () => () => ({
+ supported: ref(true),
+ isDenied: ref(false),
+ busy: ref(false),
+ subscribed: ref(false),
+ lastError: ref(null),
+ refresh: async () => {},
+ subscribe: async () => false,
+ unsubscribe: async () => {},
+}));
+
+mockNuxtImport("useNotificationPreferences", () => () => ({
+ preferences: ref({ push: [], in_app: [] }),
+ quietHours: ref({ start: null, end: null, timezone: null }),
+ load: async () => {},
+ set: async () => {},
+ loadQuietHours: async () => {},
+ setQuietHours: async () => {},
+}));
+
+const ALREADY_INSTALLED = "Already installed?";
+
+let pwa: ReturnType | undefined;
+let unmount: (() => void) | undefined;
+
+afterEach(() => {
+ unmount?.();
+ unmount = undefined;
+ pwa?.restore();
+ pwa = undefined;
+ vi.restoreAllMocks();
+});
+
+async function mountPage() {
+ const wrapper = await mountSuspended(
+ defineComponent({
+ render: () => h(SidebarProvider, null, () => h(NotificationPreferences)),
+ }),
+ );
+ unmount = () => wrapper.unmount();
+ await flushPromises();
+ return wrapper;
+}
+
+function installButton(wrapper: Awaited>) {
+ return wrapper
+ .findAll("button")
+ .find((button) => button.text() === "Install App");
+}
+
+describe("notification preferences install gate", () => {
+ it("gives Android without a native prompt the install steps, not 'Already installed?'", async () => {
+ emulateDevice({ userAgent: userAgents.androidChrome, width: 412 });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const wrapper = await mountPage();
+
+ expect(wrapper.text()).not.toContain(ALREADY_INSTALLED);
+
+ const button = installButton(wrapper);
+ expect(button).toBeDefined();
+
+ await button!.trigger("click");
+ await flushPromises();
+
+ expect(document.body.textContent).toContain(
+ "Select 'Install app' or 'Add to Home screen'",
+ );
+ });
+
+ it("drops the install card entirely inside the installed app", async () => {
+ emulateDevice({
+ userAgent: userAgents.androidChrome,
+ width: 412,
+ standalone: true,
+ });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const wrapper = await mountPage();
+
+ expect(wrapper.text()).not.toContain(ALREADY_INSTALLED);
+ expect(installButton(wrapper)).toBeUndefined();
+ });
+
+ it("keeps the iOS install button", async () => {
+ emulateDevice({ userAgent: userAgents.iphoneSafari, width: 390 });
+ pwa = fakePwa({ showInstallPrompt: false });
+
+ const wrapper = await mountPage();
+
+ expect(installButton(wrapper)).toBeDefined();
+ expect(wrapper.text()).not.toContain(ALREADY_INSTALLED);
+ });
+});
diff --git a/tests/pages/teams/teamAwards.spec.ts b/tests/pages/teams/teamAwards.spec.ts
new file mode 100644
index 000000000..29cbb21d8
--- /dev/null
+++ b/tests/pages/teams/teamAwards.spec.ts
@@ -0,0 +1,267 @@
+import { describe, expect, it } from "vitest";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import {
+ Kind,
+ valueFromASTUntyped,
+ type DocumentNode,
+ type FieldNode,
+ type OperationDefinitionNode,
+ type SelectionSetNode,
+} from "graphql";
+import TeamPage from "~/pages/teams/[id].vue";
+import TeamsIndexPage from "~/pages/teams/index.vue";
+import AwardCase from "~/components/award/AwardCase.vue";
+
+const award = (id: string, name: string, tier: string) => ({
+ id,
+ name,
+ description: null,
+ tier,
+ silhouette: null,
+ image_url: null,
+ system_key: null,
+ allow_multiple: false,
+});
+
+const team1 = { id: "team-1", name: "Alpha", short_name: "ALP" };
+const team2 = { id: "team-2", name: "Bravo", short_name: "BRV" };
+
+const rows = [
+ {
+ id: "manual-team-1",
+ award_id: "award-heroes",
+ award: award("award-heroes", "Community Heroes", "special"),
+ source: "manual",
+ team_id: team1.id,
+ team: team1,
+ player_steam_id: null,
+ tournament_id: null,
+ tournament: null,
+ tournament_team_id: null,
+ tournament_team: null,
+ tournament_award: null,
+ placement: null,
+ placement_tier: null,
+ note: null,
+ created_at: "2026-09-01T00:00:00Z",
+ },
+ {
+ id: "manual-player-1",
+ award_id: "award-heroes",
+ award: award("award-heroes", "Community Heroes", "special"),
+ source: "manual",
+ team_id: null,
+ team: null,
+ player_steam_id: "76561198000000001",
+ tournament_id: null,
+ tournament: null,
+ tournament_team_id: null,
+ tournament_team: null,
+ tournament_award: null,
+ placement: null,
+ placement_tier: null,
+ note: null,
+ created_at: "2026-09-01T00:00:00Z",
+ },
+ {
+ id: "tournament-team-1",
+ award_id: "award-gold",
+ award: award("award-gold", "Tournament Champion", "gold"),
+ source: "tournament",
+ team_id: team1.id,
+ team: team1,
+ player_steam_id: null,
+ tournament_id: "tournament-1",
+ tournament: {
+ id: "tournament-1",
+ name: "Spring Cup",
+ start: "2026-03-01T00:00:00Z",
+ stages: [],
+ },
+ tournament_team_id: "tt-1",
+ tournament_team: {
+ name: "Alpha",
+ team_id: team1.id,
+ team: team1,
+ },
+ tournament_award: null,
+ placement: 1,
+ placement_tier: null,
+ note: null,
+ created_at: "2026-03-10T00:00:00Z",
+ },
+ {
+ id: "manual-team-2",
+ award_id: "award-heroes",
+ award: award("award-heroes", "Community Heroes", "special"),
+ source: "manual",
+ team_id: team2.id,
+ team: team2,
+ player_steam_id: null,
+ tournament_id: null,
+ tournament: null,
+ tournament_team_id: null,
+ tournament_team: null,
+ tournament_award: null,
+ placement: null,
+ placement_tier: null,
+ note: null,
+ created_at: "2026-09-02T00:00:00Z",
+ },
+];
+
+function rootField(document: DocumentNode): FieldNode {
+ const operation = document.definitions.find(
+ (definition): definition is OperationDefinitionNode =>
+ definition.kind === Kind.OPERATION_DEFINITION,
+ )!;
+ return operation.selectionSet.selections[0] as FieldNode;
+}
+
+const OPERATORS: Record boolean> = {
+ _eq: (value, expected) => value != null && String(value) === String(expected),
+ _is_null: (value, expected) => (value == null) === expected,
+};
+
+function matchesWhere(row: any, where: Record): boolean {
+ return Object.entries(where).every(([key, condition]) => {
+ if (key === "_and") {
+ return condition.every((part: any) => matchesWhere(row, part));
+ }
+ if (key === "_or") {
+ return condition.some((part: any) => matchesWhere(row, part));
+ }
+ if (key === "_not") {
+ return !matchesWhere(row, condition);
+ }
+ const value = row?.[key];
+ const operators = Object.keys(condition).filter((name) =>
+ name.startsWith("_"),
+ );
+ if (operators.length === 0) {
+ return value != null && matchesWhere(value, condition);
+ }
+ return operators.every((operator) => {
+ if (!OPERATORS[operator]) {
+ throw new Error(`unsupported operator ${operator}`);
+ }
+ return OPERATORS[operator](value, condition[operator]);
+ });
+ });
+}
+
+function project(value: any, selectionSet?: SelectionSetNode): any {
+ if (!selectionSet || value == null) {
+ return value ?? null;
+ }
+ if (Array.isArray(value)) {
+ return value.map((item) => project(item, selectionSet));
+ }
+ const result: Record = {};
+ for (const selection of selectionSet.selections) {
+ if (selection.kind !== Kind.FIELD) {
+ continue;
+ }
+ const key = selection.alias?.value ?? selection.name.value;
+ result[key] = project(value[selection.name.value], selection.selectionSet);
+ }
+ return result;
+}
+
+function runSubscription(
+ document: DocumentNode,
+ variables: Record,
+): any[] {
+ const field = rootField(document);
+ const whereArgument = field.arguments?.find(
+ (argument) => argument.name.value === "where",
+ );
+ const where = whereArgument
+ ? valueFromASTUntyped(whereArgument.value, variables)
+ : {};
+ return rows
+ .filter((row) => matchesWhere(row, where))
+ .map((row) => project(row, field.selectionSet));
+}
+
+function computedContext(component: any, state: Record) {
+ const context: Record = { ...state };
+ for (const [name, definition] of Object.entries(component.computed)) {
+ const getter =
+ typeof definition === "function" ? definition : definition.get;
+ Object.defineProperty(context, name, {
+ get: () => getter.call(context),
+ });
+ }
+ return context;
+}
+
+describe("team page awards", () => {
+ const subscription = (TeamPage as any).apollo.$subscribe.teamAwards;
+
+ function teamAwardsFor(teamId: string) {
+ // Nuxt compiles this.$route in pages to read this._.provides first.
+ const context: Record = {
+ _: { provides: {} },
+ $route: { params: { id: teamId } },
+ teamAwards: [],
+ };
+ const data = {
+ award_recipients: runSubscription(
+ subscription.query,
+ subscription.variables.call(context),
+ ),
+ };
+ subscription.result.call(context, { data });
+ return context.teamAwards as any[];
+ }
+
+ it("includes a manually granted team award that has no tournament", () => {
+ const ids = teamAwardsFor(team1.id).map((grant) => grant.id);
+
+ expect(ids).toContain("manual-team-1");
+ expect(ids).toContain("tournament-team-1");
+ expect(ids).not.toContain("manual-player-1");
+ expect(ids).not.toContain("manual-team-2");
+ });
+
+ it("shows the manual team award in the team's award case", async () => {
+ const wrapper = await mountSuspended(AwardCase, {
+ props: { awards: teamAwardsFor(team1.id), hideMvp: true },
+ });
+
+ expect(wrapper.text()).toContain("Community Heroes");
+ expect(wrapper.text()).toContain("Spring Cup");
+ });
+});
+
+describe("teams list awards", () => {
+ const subscription = (TeamsIndexPage as any).apollo.$subscribe.teamAwards;
+
+ function listContext() {
+ const context = computedContext(TeamsIndexPage, { teamAwards: [] });
+ subscription.result.call(context, {
+ data: {
+ award_recipients: runSubscription(subscription.query.call(context), {}),
+ },
+ });
+ return context;
+ }
+
+ it("groups a manually granted team award under its team", () => {
+ const { awardsByTeamId } = listContext();
+
+ expect(
+ (awardsByTeamId[team1.id] ?? []).map((grant: any) => grant.id).sort(),
+ ).toEqual(["manual-team-1", "tournament-team-1"]);
+ expect(
+ (awardsByTeamId[team2.id] ?? []).map((grant: any) => grant.id),
+ ).toEqual(["manual-team-2"]);
+ });
+
+ it("does not count a manual award towards the tournament winners filter", () => {
+ const { winnerTeamIds } = listContext();
+
+ expect(winnerTeamIds).toEqual([team1.id]);
+ });
+});
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 000000000..135b900a5
--- /dev/null
+++ b/tests/setup.ts
@@ -0,0 +1,10 @@
+import { afterAll } from "vitest";
+
+// vaul-vue's Drawer and plugins/preloader.client.ts leave real timers (up to
+// 300ms) that read `document`, which the environment deletes on teardown.
+afterAll(async () => {
+ if (typeof document === "undefined") {
+ return;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 300));
+});
diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts
new file mode 100644
index 000000000..4c8d00266
--- /dev/null
+++ b/tests/smoke.spec.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { defineComponent, h } from "vue";
+import { mountSuspended } from "@nuxt/test-utils/runtime";
+import { NuxtLink } from "#components";
+import { cn } from "~/lib/utils";
+
+describe("vitest nuxt environment", () => {
+ it("resolves app aliases and nuxt auto-imports", () => {
+ expect(cn("a", "b")).toBe("a b");
+ expect(useRuntimeConfig().public).toHaveProperty("apiDomain");
+ });
+
+ it("renders components inside the nuxt app with i18n and the real router", async () => {
+ const wrapper = await mountSuspended(
+ defineComponent({
+ render: () =>
+ h(NuxtLink, { to: "/teams/abc" }, () =>
+ useNuxtApp().$i18n.t("event.teams.title"),
+ ),
+ }),
+ );
+
+ expect(wrapper.html()).toBe('Teams');
+ });
+});
diff --git a/tests/utilities/eventDisplay.spec.ts b/tests/utilities/eventDisplay.spec.ts
new file mode 100644
index 000000000..de0ef5b49
--- /dev/null
+++ b/tests/utilities/eventDisplay.spec.ts
@@ -0,0 +1,63 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { eventPhase, eventPhaseWhere } from "~/utilities/eventDisplay";
+import type { EventPhase } from "~/utilities/eventDisplay";
+import { matchesWhere } from "../helpers/fakeHasura";
+
+const NOW = "2026-06-15T12:00:00.000Z";
+const HOUR = 3_600_000;
+
+function shift(offsetMs: number) {
+ return new Date(Date.parse(NOW) + offsetMs).toISOString();
+}
+
+const events = [
+ {
+ name: "ended yesterday",
+ starts_at: shift(-48 * HOUR),
+ ends_at: shift(-24 * HOUR),
+ },
+ { name: "ends this instant", starts_at: shift(-HOUR), ends_at: NOW },
+ { name: "running", starts_at: shift(-HOUR), ends_at: shift(HOUR) },
+ { name: "open-ended", starts_at: shift(-24 * HOUR), ends_at: null },
+ { name: "starts this instant", starts_at: NOW, ends_at: null },
+ { name: "starts tomorrow", starts_at: shift(24 * HOUR), ends_at: null },
+ {
+ name: "scheduled window",
+ starts_at: shift(24 * HOUR),
+ ends_at: shift(48 * HOUR),
+ },
+ {
+ name: "ends before it starts",
+ starts_at: shift(24 * HOUR),
+ ends_at: shift(-HOUR),
+ },
+];
+
+describe("eventPhaseWhere", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(NOW));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it.each(["live", "upcoming", "finished"])(
+ "selects exactly the events eventPhase calls %s",
+ (phase) => {
+ const where = eventPhaseWhere(phase, NOW);
+
+ const selected = events
+ .filter((e) => matchesWhere(e, where))
+ .map((e) => e.name);
+ const expected = events
+ .filter((e) => eventPhase(e) === phase)
+ .map((e) => e.name);
+
+ expect(expected.length).toBeGreaterThan(0);
+ expect(selected).toEqual(expected);
+ },
+ );
+});
diff --git a/tests/utilities/tournamentStageTeams.spec.ts b/tests/utilities/tournamentStageTeams.spec.ts
new file mode 100644
index 000000000..788a7534b
--- /dev/null
+++ b/tests/utilities/tournamentStageTeams.spec.ts
@@ -0,0 +1,353 @@
+import { describe, expect, it } from "vitest";
+import {
+ advancingTeams,
+ groupsSplitNextStage,
+ stageTeamLimits,
+ teamCountOptions,
+} from "~/utilities/tournamentStageTeams";
+
+const roundRobin = (max_teams: number) => ({
+ type: "RoundRobin",
+ max_teams,
+});
+
+describe("stageTeamLimits", () => {
+ it("round robin needs three teams per group, at any stage", () => {
+ expect(
+ stageTeamLimits({ type: "RoundRobin", order: 1, groups: 4 }).min,
+ ).toBe(12);
+ expect(
+ stageTeamLimits({ type: "RoundRobin", order: 2, groups: 1 }).min,
+ ).toBe(3);
+ });
+
+ it("round robin caps each group at 32 teams", () => {
+ expect(
+ stageTeamLimits({ type: "RoundRobin", order: 1, groups: 1 }).max,
+ ).toBe(32);
+ expect(
+ stageTeamLimits({ type: "RoundRobin", order: 1, groups: 4 }).max,
+ ).toBe(128);
+ expect(
+ stageTeamLimits({ type: "RoundRobin", order: 1, groups: 16 }).max,
+ ).toBe(256);
+ });
+
+ it("a first elimination stage still needs four teams per group", () => {
+ expect(
+ stageTeamLimits({ type: "SingleElimination", order: 1, groups: 2 }).min,
+ ).toBe(8);
+ expect(
+ stageTeamLimits({ type: "DoubleElimination", order: 1, groups: 1 }).min,
+ ).toBe(4);
+ });
+
+ it("single elimination after a round robin can be a two-team final", () => {
+ expect(
+ stageTeamLimits({
+ type: "SingleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: roundRobin(8),
+ }).min,
+ ).toBe(2);
+ expect(
+ stageTeamLimits({
+ type: "SingleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: {
+ type: "Swiss",
+ max_teams: 16,
+ swiss_no_elimination: true,
+ },
+ }).min,
+ ).toBe(2);
+ });
+
+ it("a stage after a Valve Swiss takes at most the half that reaches 3 wins", () => {
+ expect(
+ stageTeamLimits({
+ type: "SingleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: { type: "Swiss", max_teams: 16 },
+ }).max,
+ ).toBe(8);
+ expect(
+ stageTeamLimits({
+ type: "SingleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: { type: "Swiss", max_teams: 15 },
+ }).max,
+ ).toBe(8);
+ });
+
+ it("a Valve Swiss must hold twice the teams the next stage takes", () => {
+ expect(
+ stageTeamLimits({
+ type: "Swiss",
+ order: 1,
+ groups: 1,
+ nextStage: { type: "SingleElimination", max_teams: 8 },
+ }).maxTeamsFloor,
+ ).toBe(16);
+ expect(
+ stageTeamLimits({
+ type: "Swiss",
+ order: 1,
+ groups: 1,
+ swissNoElimination: true,
+ nextStage: { type: "SingleElimination", max_teams: 8 },
+ }).maxTeamsFloor,
+ ).toBe(8);
+ });
+
+ it("single elimination after a Valve Swiss can be a two-team final too", () => {
+ expect(
+ stageTeamLimits({
+ type: "SingleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: { type: "Swiss", max_teams: 16 },
+ }).min,
+ ).toBe(2);
+ expect(
+ stageTeamLimits({
+ type: "SingleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: { type: "Swiss", min_teams: 32, max_teams: 64 },
+ }).min,
+ ).toBe(2);
+ });
+
+ it("double elimination keeps four teams after a round robin", () => {
+ expect(
+ stageTeamLimits({
+ type: "DoubleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: roundRobin(8),
+ }).min,
+ ).toBe(4);
+ });
+
+ it("a stage after a round robin can't hold more teams than the round robin", () => {
+ expect(
+ stageTeamLimits({
+ type: "DoubleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: roundRobin(20),
+ }).max,
+ ).toBe(20);
+ });
+
+ it("a round robin must hold at least as many teams as the next stage advances", () => {
+ const limits = stageTeamLimits({
+ type: "RoundRobin",
+ order: 1,
+ groups: 2,
+ nextStage: { type: "SingleElimination", max_teams: 8 },
+ });
+ expect(limits.min).toBe(6);
+ expect(limits.maxTeamsFloor).toBe(8);
+
+ expect(
+ stageTeamLimits({
+ type: "SingleElimination",
+ order: 1,
+ groups: 1,
+ nextStage: { type: "SingleElimination", max_teams: 8 },
+ }).maxTeamsFloor,
+ ).toBe(0);
+ });
+});
+
+describe("stageTeamLimits after an elimination stage", () => {
+ const bracketRounds = (...matchesPerRound: number[]) =>
+ matchesPerRound.flatMap((matches, index) =>
+ Array.from({ length: matches }, () => ({ round: index + 1 })),
+ );
+
+ it("must take every team the previous bracket's last round sends on", () => {
+ const groupFinals = {
+ type: "SingleElimination",
+ min_teams: 32,
+ max_teams: 32,
+ groups: 8,
+ brackets: bracketRounds(16, 8),
+ };
+ expect(
+ stageTeamLimits({
+ type: "SingleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: groupFinals,
+ }).min,
+ ).toBe(8);
+ expect(
+ stageTeamLimits({
+ type: "RoundRobin",
+ order: 2,
+ groups: 1,
+ previousStage: groupFinals,
+ }).min,
+ ).toBe(8);
+ });
+
+ it("falls back to half the previous stage's per-group minimum when it has no brackets", () => {
+ expect(
+ stageTeamLimits({
+ type: "DoubleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: {
+ type: "SingleElimination",
+ min_teams: 16,
+ max_teams: 16,
+ groups: 1,
+ brackets: [],
+ },
+ }).min,
+ ).toBe(8);
+ });
+
+ it("still never offers fewer than four", () => {
+ expect(
+ stageTeamLimits({
+ type: "SingleElimination",
+ order: 2,
+ groups: 1,
+ previousStage: {
+ type: "SingleElimination",
+ min_teams: 8,
+ max_teams: 8,
+ groups: 1,
+ brackets: bracketRounds(4, 2, 1),
+ },
+ }).min,
+ ).toBe(4);
+ });
+});
+
+describe("groupsSplitNextStage", () => {
+ const nextStage = (min_teams: number) => ({
+ type: "SingleElimination",
+ min_teams,
+ max_teams: 16,
+ });
+
+ it("needs the next stage's minimum to split evenly across this stage's groups", () => {
+ expect(
+ groupsSplitNextStage({
+ type: "SingleElimination",
+ groups: 4,
+ minTeams: 16,
+ nextStage: nextStage(10),
+ }),
+ ).toBe(false);
+ expect(
+ groupsSplitNextStage({
+ type: "SingleElimination",
+ groups: 4,
+ minTeams: 16,
+ nextStage: nextStage(12),
+ }),
+ ).toBe(true);
+ });
+
+ it("checks the next stage's minimum after saving halves this stage's into it", () => {
+ expect(
+ groupsSplitNextStage({
+ type: "SingleElimination",
+ groups: 4,
+ minTeams: 16,
+ nextStage: nextStage(6),
+ }),
+ ).toBe(true);
+ expect(
+ groupsSplitNextStage({
+ type: "DoubleElimination",
+ groups: 2,
+ minTeams: 9,
+ nextStage: nextStage(3),
+ }),
+ ).toBe(true);
+ expect(
+ groupsSplitNextStage({
+ type: "DoubleElimination",
+ groups: 2,
+ minTeams: 11,
+ nextStage: nextStage(3),
+ }),
+ ).toBe(true);
+ expect(
+ groupsSplitNextStage({
+ type: "DoubleElimination",
+ groups: 4,
+ minTeams: 20,
+ nextStage: nextStage(3),
+ }),
+ ).toBe(false);
+ });
+
+ it("doesn't apply to round robins, single groups, or a last stage", () => {
+ expect(
+ groupsSplitNextStage({
+ type: "RoundRobin",
+ groups: 4,
+ minTeams: 16,
+ nextStage: nextStage(10),
+ }),
+ ).toBe(true);
+ expect(
+ groupsSplitNextStage({
+ type: "SingleElimination",
+ groups: 1,
+ minTeams: 16,
+ nextStage: nextStage(7),
+ }),
+ ).toBe(true);
+ expect(
+ groupsSplitNextStage({
+ type: "SingleElimination",
+ groups: 4,
+ minTeams: 16,
+ nextStage: null,
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("advancingTeams", () => {
+ it("is the whole field for ranked stages, half for a Valve Swiss, and unbounded for elimination", () => {
+ expect(advancingTeams(roundRobin(12))).toBe(12);
+ expect(
+ advancingTeams({
+ type: "Swiss",
+ max_teams: 16,
+ swiss_no_elimination: true,
+ }),
+ ).toBe(16);
+ expect(advancingTeams({ type: "Swiss", max_teams: 32 })).toBe(16);
+ expect(
+ advancingTeams({ type: "SingleElimination", max_teams: 16 }),
+ ).toBeNull();
+ expect(advancingTeams(null)).toBeNull();
+ });
+});
+
+describe("teamCountOptions", () => {
+ it("lists every count between the limits in ascending order", () => {
+ expect(teamCountOptions({ min: 2, max: 5, step: 1 })).toEqual([2, 3, 4, 5]);
+ });
+
+ it("keeps Swiss counts even", () => {
+ expect(teamCountOptions({ min: 10, max: 16, step: 2 }, 11)).toEqual([
+ 12, 14, 16,
+ ]);
+ });
+});
diff --git a/utilities/eventDisplay.ts b/utilities/eventDisplay.ts
index a8b352a5f..5c32196b9 100644
--- a/utilities/eventDisplay.ts
+++ b/utilities/eventDisplay.ts
@@ -31,6 +31,21 @@ export function eventPhase(event: {
return "upcoming";
}
+// Must classify events exactly like eventPhase; change the two together.
+export function eventPhaseWhere(phase: EventPhase, now: Now) {
+ if (phase === "finished") {
+ return { ends_at: { _lt: now } };
+ }
+
+ const notEnded = {
+ _or: [{ ends_at: { _is_null: true } }, { ends_at: { _gte: now } }],
+ };
+ if (phase === "live") {
+ return { _and: [notEnded, { starts_at: { _lte: now } }] };
+ }
+ return { _and: [notEnded, { starts_at: { _gt: now } }] };
+}
+
export function phaseBadgeVariant(
phase: EventPhase,
): "default" | "secondary" | "destructive" | "outline" {
diff --git a/utilities/tournamentStageTeams.ts b/utilities/tournamentStageTeams.ts
new file mode 100644
index 000000000..78c71a3cb
--- /dev/null
+++ b/utilities/tournamentStageTeams.ts
@@ -0,0 +1,185 @@
+// Mirrors the team-count rules of the api's validate_tournament_stage and the
+// groups check in taiu_tournament_stages; change them together. The form is
+// only stricter where the database accepts a degenerate count: elimination
+// stages under 4 (except a final after a ranked stage), Swiss outside 10-64
+// or odd, and round robin groups over 32 (every pairing is a bracket row,
+// rebuilt on each stage edit).
+import { e_tournament_stage_types_enum } from "~/generated/zeus";
+
+const MAX_TEAMS = 256;
+const MAX_ROUND_ROBIN_GROUP_TEAMS = 32;
+
+export interface AdjacentStage {
+ type: string;
+ max_teams: number;
+ min_teams?: number | null;
+ groups?: number | null;
+ swiss_no_elimination?: boolean | null;
+ brackets?: Array<{ round: number }> | null;
+}
+
+export interface StageTeamLimits {
+ min: number;
+ max: number;
+ step: number;
+ maxTeamsFloor: number;
+}
+
+export function ranksWholeField(stage?: AdjacentStage | null): boolean {
+ if (!stage) {
+ return false;
+ }
+ return (
+ stage.type === e_tournament_stage_types_enum.RoundRobin ||
+ (stage.type === e_tournament_stage_types_enum.Swiss &&
+ !!stage.swiss_no_elimination)
+ );
+}
+
+export function advancingTeams(stage?: AdjacentStage | null): number | null {
+ if (!stage) {
+ return null;
+ }
+ if (ranksWholeField(stage)) {
+ return stage.max_teams;
+ }
+ // A Valve Swiss advances only its 3-0, 3-1 and 3-2 teams: half the field,
+ // rounded up because an odd field's bye is a free win.
+ if (stage.type === e_tournament_stage_types_enum.Swiss) {
+ return Math.ceil(stage.max_teams / 2);
+ }
+ return null;
+}
+
+// An elimination stage sends on one team per match in its current last round,
+// or, before it has brackets, half of its per-group minimum.
+export function lastRoundTeams(stage?: AdjacentStage | null): number {
+ if (!stage || advancingTeams(stage) !== null) {
+ return 0;
+ }
+ const rounds = (stage.brackets ?? []).map((bracket) => bracket.round);
+ if (rounds.length > 0) {
+ const lastRound = Math.max(...rounds);
+ return rounds.filter((round) => round === lastRound).length;
+ }
+ if (!stage.groups || stage.min_teams == null) {
+ return 0;
+ }
+ return Math.floor(Math.floor(stage.min_teams / stage.groups) / 2);
+}
+
+export function stageTeamLimits({
+ type,
+ order,
+ groups,
+ swissNoElimination = false,
+ previousStage,
+ nextStage,
+}: {
+ type: string;
+ order: number;
+ groups: number | string;
+ swissNoElimination?: boolean;
+ previousStage?: AdjacentStage | null;
+ nextStage?: AdjacentStage | null;
+}): StageTeamLimits {
+ const groupCount = Math.max(1, Math.floor(Number(groups)) || 1);
+ const previousAdvancing = advancingTeams(previousStage);
+ let min = 4;
+ let max = MAX_TEAMS;
+ let step = 1;
+
+ switch (type) {
+ case e_tournament_stage_types_enum.RoundRobin:
+ min = 3 * groupCount;
+ max = Math.min(MAX_TEAMS, MAX_ROUND_ROBIN_GROUP_TEAMS * groupCount);
+ break;
+ case e_tournament_stage_types_enum.Swiss:
+ min = 10;
+ max = 64;
+ step = 2;
+ break;
+ case e_tournament_stage_types_enum.SingleElimination:
+ if (previousAdvancing !== null) {
+ min = 2;
+ } else if (order === 1) {
+ min = 4 * groupCount;
+ }
+ break;
+ case e_tournament_stage_types_enum.DoubleElimination:
+ if (order === 1) {
+ min = 4 * groupCount;
+ }
+ break;
+ }
+
+ min = Math.max(min, lastRoundTeams(previousStage));
+ if (min % step !== 0) {
+ min += step - (min % step);
+ }
+
+ if (previousAdvancing !== null) {
+ max = Math.min(max, previousAdvancing);
+ }
+
+ let maxTeamsFloor = 0;
+ if (nextStage) {
+ if (
+ ranksWholeField({
+ type,
+ max_teams: max,
+ swiss_no_elimination: swissNoElimination,
+ })
+ ) {
+ maxTeamsFloor = nextStage.max_teams;
+ } else if (type === e_tournament_stage_types_enum.Swiss) {
+ maxTeamsFloor = nextStage.max_teams * 2;
+ }
+ }
+
+ return { min, max, step, maxTeamsFloor };
+}
+
+// Saving a grouped elimination stage first halves its minimum into the next
+// stage (Postgres rounds the halved float to the nearest even integer), then
+// requires the next stage's minimum to split evenly across the groups.
+export function groupsSplitNextStage({
+ type,
+ groups,
+ minTeams,
+ nextStage,
+}: {
+ type: string;
+ groups: number | string;
+ minTeams: number | string;
+ nextStage?: AdjacentStage | null;
+}): boolean {
+ const groupCount = Math.floor(Number(groups)) || 1;
+ if (
+ !nextStage ||
+ groupCount <= 1 ||
+ type === e_tournament_stage_types_enum.RoundRobin
+ ) {
+ return true;
+ }
+ const half = Number(minTeams) / 2;
+ const halved =
+ Number.isInteger(half) || Math.floor(half) % 2 === 0
+ ? Math.floor(half)
+ : Math.ceil(half);
+ const nextMin = Math.max(nextStage.min_teams ?? 0, halved);
+ return nextMin % groupCount === 0;
+}
+
+export function teamCountOptions(
+ limits: Pick,
+ atLeast = limits.min,
+): number[] {
+ const options: number[] = [];
+ for (let count = limits.min; count <= limits.max; count += limits.step) {
+ if (count >= atLeast) {
+ options.push(count);
+ }
+ }
+ return options;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 000000000..c03251391
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,21 @@
+import { defineVitestConfig } from "@nuxt/test-utils/config";
+
+export default defineVitestConfig({
+ test: {
+ include: ["tests/**/*.spec.ts"],
+ setupFiles: ["tests/setup.ts"],
+ // The first test in a file pays for the Nuxt boot.
+ testTimeout: 30000,
+ // Any other default splits into two projects that boot Nuxt in one process
+ // and randomly lose auto-imports; opt out per file with @vitest-environment.
+ environment: "nuxt",
+ environmentOptions: {
+ nuxt: {
+ overrides: {
+ // Keeps the test boot from regenerating the dev server's .nuxt.
+ buildDir: ".nuxt/vitest",
+ },
+ },
+ },
+ },
+});
diff --git a/yarn.lock b/yarn.lock
index 776e99b42..66d476409 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -17,6 +17,7 @@ __metadata:
"@imgly/background-removal": "npm:^1.7.0"
"@nuxt/devtools": "npm:^2.4.0"
"@nuxt/image": "npm:^1.10.0"
+ "@nuxt/test-utils": "npm:^3.23.0"
"@nuxtjs/apollo": "npm:5.0.0-alpha.16"
"@nuxtjs/i18n": "npm:^9.5.4"
"@nuxtjs/tailwindcss": "npm:^6.14.0"
@@ -31,6 +32,7 @@ __metadata:
"@vee-validate/zod": "npm:^4.15.1"
"@vite-pwa/nuxt": "npm:^1.0.0"
"@vue/apollo-composable": "npm:^4.2.2"
+ "@vue/test-utils": "npm:^2.4.6"
"@vueuse/core": "npm:^14.2.1"
ansi-to-html: "npm:^0.7.2"
autoprefixer: "npm:^10.4.21"
@@ -48,6 +50,7 @@ __metadata:
graphql-tag: "npm:^2.12.6"
graphql-ws: "npm:^6.0.4"
graphql-zeus: "npm:^7.0.5"
+ happy-dom: "npm:^20.0.11"
javascript-time-ago: "npm:^2.5.11"
lucide-vue-next: "npm:^0.508.0"
marked: "npm:^18.0.5"
@@ -76,6 +79,7 @@ __metadata:
uuid: "npm:^11.1.0"
vaul-vue: "npm:^0.4.1"
vee-validate: "npm:^4.15.1"
+ vitest: "npm:^3.2.7"
vue: "npm:^3.5.34"
vue-chartjs: "npm:^5.3.2"
vue-router: "npm:^4.5.1"
@@ -1283,6 +1287,27 @@ __metadata:
languageName: node
linkType: hard
+"@clack/core@npm:1.0.0-alpha.7":
+ version: 1.0.0-alpha.7
+ resolution: "@clack/core@npm:1.0.0-alpha.7"
+ dependencies:
+ picocolors: "npm:^1.0.0"
+ sisteransi: "npm:^1.0.5"
+ checksum: 10c0/e3d706101c57252d41c77abfb16d1f163bc9e4b81cacbc70c022a859c500ceb7024049d8ff907c8869dcaa9e141e4ef6bb57b83c46027fcdc188072f7b0cc728
+ languageName: node
+ linkType: hard
+
+"@clack/prompts@npm:1.0.0-alpha.9":
+ version: 1.0.0-alpha.9
+ resolution: "@clack/prompts@npm:1.0.0-alpha.9"
+ dependencies:
+ "@clack/core": "npm:1.0.0-alpha.7"
+ picocolors: "npm:^1.0.0"
+ sisteransi: "npm:^1.0.5"
+ checksum: 10c0/e3022a5cf8e07661628a46f6cc811c2f8f03fb49e5d5a1b6243f08960264f5a61fc9546a348eb2fe0e8fdfb15c4bbea78474291d06a0eb153ea9e491ae42b116
+ languageName: node
+ linkType: hard
+
"@cloudflare/kv-asset-handler@npm:0.4.0, @cloudflare/kv-asset-handler@npm:^0.4.0":
version: 0.4.0
resolution: "@cloudflare/kv-asset-handler@npm:0.4.0"
@@ -1444,6 +1469,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/aix-ppc64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/aix-ppc64@npm:0.28.2"
+ conditions: os=aix & cpu=ppc64
+ languageName: node
+ linkType: hard
+
"@esbuild/android-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/android-arm64@npm:0.25.4"
@@ -1451,6 +1483,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/android-arm64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/android-arm64@npm:0.28.2"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/android-arm@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/android-arm@npm:0.25.4"
@@ -1458,6 +1497,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/android-arm@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/android-arm@npm:0.28.2"
+ conditions: os=android & cpu=arm
+ languageName: node
+ linkType: hard
+
"@esbuild/android-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/android-x64@npm:0.25.4"
@@ -1465,6 +1511,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/android-x64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/android-x64@npm:0.28.2"
+ conditions: os=android & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/darwin-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/darwin-arm64@npm:0.25.4"
@@ -1472,6 +1525,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/darwin-arm64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/darwin-arm64@npm:0.28.2"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/darwin-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/darwin-x64@npm:0.25.4"
@@ -1479,6 +1539,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/darwin-x64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/darwin-x64@npm:0.28.2"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/freebsd-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/freebsd-arm64@npm:0.25.4"
@@ -1486,6 +1553,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/freebsd-arm64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/freebsd-arm64@npm:0.28.2"
+ conditions: os=freebsd & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/freebsd-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/freebsd-x64@npm:0.25.4"
@@ -1493,6 +1567,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/freebsd-x64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/freebsd-x64@npm:0.28.2"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-arm64@npm:0.25.4"
@@ -1500,6 +1581,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-arm64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/linux-arm64@npm:0.28.2"
+ conditions: os=linux & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-arm@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-arm@npm:0.25.4"
@@ -1507,6 +1595,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-arm@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/linux-arm@npm:0.28.2"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-ia32@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-ia32@npm:0.25.4"
@@ -1514,6 +1609,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-ia32@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/linux-ia32@npm:0.28.2"
+ conditions: os=linux & cpu=ia32
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-loong64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-loong64@npm:0.25.4"
@@ -1521,6 +1623,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-loong64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/linux-loong64@npm:0.28.2"
+ conditions: os=linux & cpu=loong64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-mips64el@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-mips64el@npm:0.25.4"
@@ -1528,6 +1637,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-mips64el@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/linux-mips64el@npm:0.28.2"
+ conditions: os=linux & cpu=mips64el
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-ppc64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-ppc64@npm:0.25.4"
@@ -1535,6 +1651,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-ppc64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/linux-ppc64@npm:0.28.2"
+ conditions: os=linux & cpu=ppc64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-riscv64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-riscv64@npm:0.25.4"
@@ -1542,6 +1665,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-riscv64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/linux-riscv64@npm:0.28.2"
+ conditions: os=linux & cpu=riscv64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-s390x@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-s390x@npm:0.25.4"
@@ -1549,6 +1679,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-s390x@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/linux-s390x@npm:0.28.2"
+ conditions: os=linux & cpu=s390x
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/linux-x64@npm:0.25.4"
@@ -1556,6 +1693,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-x64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/linux-x64@npm:0.28.2"
+ conditions: os=linux & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/netbsd-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/netbsd-arm64@npm:0.25.4"
@@ -1563,6 +1707,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/netbsd-arm64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/netbsd-arm64@npm:0.28.2"
+ conditions: os=netbsd & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/netbsd-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/netbsd-x64@npm:0.25.4"
@@ -1570,6 +1721,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/netbsd-x64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/netbsd-x64@npm:0.28.2"
+ conditions: os=netbsd & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/openbsd-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/openbsd-arm64@npm:0.25.4"
@@ -1577,6 +1735,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/openbsd-arm64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/openbsd-arm64@npm:0.28.2"
+ conditions: os=openbsd & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/openbsd-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/openbsd-x64@npm:0.25.4"
@@ -1584,6 +1749,20 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/openbsd-x64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/openbsd-x64@npm:0.28.2"
+ conditions: os=openbsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@esbuild/openharmony-arm64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/openharmony-arm64@npm:0.28.2"
+ conditions: os=openharmony & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/sunos-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/sunos-x64@npm:0.25.4"
@@ -1591,6 +1770,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/sunos-x64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/sunos-x64@npm:0.28.2"
+ conditions: os=sunos & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/win32-arm64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/win32-arm64@npm:0.25.4"
@@ -1598,6 +1784,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/win32-arm64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/win32-arm64@npm:0.28.2"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/win32-ia32@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/win32-ia32@npm:0.25.4"
@@ -1605,6 +1798,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/win32-ia32@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/win32-ia32@npm:0.28.2"
+ conditions: os=win32 & cpu=ia32
+ languageName: node
+ linkType: hard
+
"@esbuild/win32-x64@npm:0.25.4":
version: 0.25.4
resolution: "@esbuild/win32-x64@npm:0.25.4"
@@ -1612,6 +1812,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/win32-x64@npm:0.28.2":
+ version: 0.28.2
+ resolution: "@esbuild/win32-x64@npm:0.28.2"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"@eslint-community/eslint-utils@npm:^4.4.0":
version: 4.7.0
resolution: "@eslint-community/eslint-utils@npm:4.7.0"
@@ -2319,6 +2526,13 @@ __metadata:
languageName: node
linkType: hard
+"@napi-rs/lzma-linux-x64-gnu@npm:1.5.1":
+ version: 1.5.1
+ resolution: "@napi-rs/lzma-linux-x64-gnu@npm:1.5.1"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@napi-rs/wasm-runtime@npm:^0.2.7, @napi-rs/wasm-runtime@npm:^0.2.9":
version: 0.2.9
resolution: "@napi-rs/wasm-runtime@npm:0.2.9"
@@ -2678,6 +2892,35 @@ __metadata:
languageName: node
linkType: hard
+"@nuxt/kit@npm:^3.20.2":
+ version: 3.21.11
+ resolution: "@nuxt/kit@npm:3.21.11"
+ dependencies:
+ c12: "npm:^3.3.4"
+ consola: "npm:^3.4.2"
+ defu: "npm:^6.1.7"
+ destr: "npm:^2.0.5"
+ errx: "npm:^0.1.2"
+ exsolve: "npm:^1.1.1"
+ ignore: "npm:^7.0.6"
+ jiti: "npm:^2.7.0"
+ klona: "npm:^2.0.6"
+ knitwork: "npm:^1.3.0"
+ mlly: "npm:^1.8.2"
+ ohash: "npm:^2.0.11"
+ pathe: "npm:^2.0.3"
+ pkg-types: "npm:^2.3.1"
+ rc9: "npm:^3.0.1"
+ scule: "npm:^1.3.0"
+ semver: "npm:^7.8.5"
+ tinyglobby: "npm:^0.2.17"
+ ufo: "npm:^1.6.4"
+ unctx: "npm:^2.5.0"
+ untyped: "npm:^2.0.0"
+ checksum: 10c0/ed7ad107766b589b1a343a3218725bfed01c586cdfbbd0863181539eaac53f75ae8018e5490707701f3565466094078edcb3a07aa55a5317024cf78493dcb476
+ languageName: node
+ linkType: hard
+
"@nuxt/kit@npm:^4.0.2":
version: 4.4.8
resolution: "@nuxt/kit@npm:4.4.8"
@@ -2741,6 +2984,73 @@ __metadata:
languageName: node
linkType: hard
+"@nuxt/test-utils@npm:^3.23.0":
+ version: 3.23.0
+ resolution: "@nuxt/test-utils@npm:3.23.0"
+ dependencies:
+ "@clack/prompts": "npm:1.0.0-alpha.9"
+ "@nuxt/kit": "npm:^3.20.2"
+ c12: "npm:^3.3.3"
+ consola: "npm:^3.4.2"
+ defu: "npm:^6.1.4"
+ destr: "npm:^2.0.5"
+ estree-walker: "npm:^3.0.3"
+ exsolve: "npm:^1.0.8"
+ fake-indexeddb: "npm:^6.2.5"
+ get-port-please: "npm:^3.2.0"
+ h3: "npm:^1.15.4"
+ h3-next: "npm:h3@^2.0.1-rc.7"
+ local-pkg: "npm:^1.1.2"
+ magic-string: "npm:^0.30.21"
+ node-fetch-native: "npm:^1.6.7"
+ node-mock-http: "npm:^1.0.4"
+ nypm: "npm:^0.6.2"
+ ofetch: "npm:^1.5.1"
+ pathe: "npm:^2.0.3"
+ perfect-debounce: "npm:^2.0.0"
+ radix3: "npm:^1.1.2"
+ scule: "npm:^1.3.0"
+ std-env: "npm:^3.10.0"
+ tinyexec: "npm:^1.0.2"
+ ufo: "npm:^1.6.1"
+ unplugin: "npm:^2.3.11"
+ vitest-environment-nuxt: "npm:^1.0.1"
+ vue: "npm:^3.5.26"
+ peerDependencies:
+ "@cucumber/cucumber": ^10.3.1 || >=11.0.0
+ "@jest/globals": ^29.5.0 || >=30.0.0
+ "@playwright/test": ^1.43.1
+ "@testing-library/vue": ^7.0.0 || ^8.0.1
+ "@vue/test-utils": ^2.4.2
+ happy-dom: "*"
+ jsdom: "*"
+ playwright-core: ^1.43.1
+ vitest: ^3.2.0
+ peerDependenciesMeta:
+ "@cucumber/cucumber":
+ optional: true
+ "@jest/globals":
+ optional: true
+ "@playwright/test":
+ optional: true
+ "@testing-library/vue":
+ optional: true
+ "@vitest/ui":
+ optional: true
+ "@vue/test-utils":
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+ playwright-core:
+ optional: true
+ vitest:
+ optional: true
+ checksum: 10c0/0a4af0d7e2245a791407f4cd3cf3dfb0c38cfb460e7717ffdc1c12f4c12d6faeaa4d78b55ffedf2aa670e196874b301ac69f6f9979123fcc17998407ade49397
+ languageName: node
+ linkType: hard
+
"@nuxt/vite-builder@npm:3.17.2":
version: 3.17.2
resolution: "@nuxt/vite-builder@npm:3.17.2"
@@ -2861,6 +3171,13 @@ __metadata:
languageName: node
linkType: hard
+"@one-ini/wasm@npm:0.2.1":
+ version: 0.2.1
+ resolution: "@one-ini/wasm@npm:0.2.1"
+ checksum: 10c0/6bc49c43b757bc53786f655845b3af0676348ca910d07d1adb1b56e174faf9149fb3453f3e3fed02b3de4c400367e597cb58b6b7d3590a52289e97faf99fc37f
+ languageName: node
+ linkType: hard
+
"@originjs/vite-plugin-federation@npm:^1.4.1":
version: 1.4.1
resolution: "@originjs/vite-plugin-federation@npm:1.4.1"
@@ -3583,6 +3900,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-android-arm-eabi@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-android-arm-eabi@npm:4.63.3"
+ conditions: os=android & cpu=arm
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-android-arm64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-android-arm64@npm:4.40.2"
@@ -3590,6 +3914,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-android-arm64@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-android-arm64@npm:4.63.3"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-darwin-arm64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-darwin-arm64@npm:4.40.2"
@@ -3597,6 +3928,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-darwin-arm64@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-darwin-arm64@npm:4.63.3"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-darwin-x64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-darwin-x64@npm:4.40.2"
@@ -3604,6 +3942,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-darwin-x64@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-darwin-x64@npm:4.63.3"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-freebsd-arm64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-freebsd-arm64@npm:4.40.2"
@@ -3611,6 +3956,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-freebsd-arm64@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-freebsd-arm64@npm:4.63.3"
+ conditions: os=freebsd & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-freebsd-x64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-freebsd-x64@npm:4.40.2"
@@ -3618,6 +3970,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-freebsd-x64@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-freebsd-x64@npm:4.63.3"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-arm-gnueabihf@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.40.2"
@@ -3625,6 +3984,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-arm-gnueabihf@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.63.3"
+ conditions: os=linux & cpu=arm & libc=glibc
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-arm-musleabihf@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.40.2"
@@ -3632,6 +3998,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-arm-musleabihf@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.63.3"
+ conditions: os=linux & cpu=arm & libc=musl
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-arm64-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.40.2"
@@ -3639,6 +4012,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-arm64-gnu@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.63.3"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-arm64-musl@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.40.2"
@@ -3646,6 +4026,27 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-arm64-musl@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-arm64-musl@npm:4.63.3"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@rollup/rollup-linux-loong64-gnu@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.63.3"
+ conditions: os=linux & cpu=loong64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rollup/rollup-linux-loong64-musl@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-loong64-musl@npm:4.63.3"
+ conditions: os=linux & cpu=loong64 & libc=musl
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-loongarch64-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.40.2"
@@ -3660,6 +4061,20 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-ppc64-gnu@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.63.3"
+ conditions: os=linux & cpu=ppc64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rollup/rollup-linux-ppc64-musl@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.63.3"
+ conditions: os=linux & cpu=ppc64 & libc=musl
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-riscv64-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.40.2"
@@ -3667,6 +4082,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-riscv64-gnu@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.63.3"
+ conditions: os=linux & cpu=riscv64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-riscv64-musl@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.40.2"
@@ -3674,6 +4096,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-riscv64-musl@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.63.3"
+ conditions: os=linux & cpu=riscv64 & libc=musl
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-s390x-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.40.2"
@@ -3681,6 +4110,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-s390x-gnu@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.63.3"
+ conditions: os=linux & cpu=s390x & libc=glibc
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-x64-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.40.2"
@@ -3688,6 +4124,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-x64-gnu@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-x64-gnu@npm:4.63.3"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-linux-x64-musl@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-x64-musl@npm:4.40.2"
@@ -3695,6 +4138,27 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-linux-x64-musl@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-linux-x64-musl@npm:4.63.3"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@rollup/rollup-openbsd-x64@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-openbsd-x64@npm:4.63.3"
+ conditions: os=openbsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rollup/rollup-openharmony-arm64@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-openharmony-arm64@npm:4.63.3"
+ conditions: os=openharmony & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-win32-arm64-msvc@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.40.2"
@@ -3702,6 +4166,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-win32-arm64-msvc@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.63.3"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-win32-ia32-msvc@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.40.2"
@@ -3709,6 +4180,20 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-win32-ia32-msvc@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.63.3"
+ conditions: os=win32 & cpu=ia32
+ languageName: node
+ linkType: hard
+
+"@rollup/rollup-win32-x64-gnu@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-win32-x64-gnu@npm:4.63.3"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-win32-x64-msvc@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.40.2"
@@ -3716,6 +4201,13 @@ __metadata:
languageName: node
linkType: hard
+"@rollup/rollup-win32-x64-msvc@npm:4.63.3":
+ version: 4.63.3
+ resolution: "@rollup/rollup-win32-x64-msvc@npm:4.63.3"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"@sindresorhus/is@npm:^7.0.1":
version: 7.0.1
resolution: "@sindresorhus/is@npm:7.0.1"
@@ -3856,6 +4348,23 @@ __metadata:
languageName: node
linkType: hard
+"@types/chai@npm:^5.2.2":
+ version: 5.2.3
+ resolution: "@types/chai@npm:5.2.3"
+ dependencies:
+ "@types/deep-eql": "npm:*"
+ assertion-error: "npm:^2.0.1"
+ checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f
+ languageName: node
+ linkType: hard
+
+"@types/deep-eql@npm:*":
+ version: 4.0.2
+ resolution: "@types/deep-eql@npm:4.0.2"
+ checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844
+ languageName: node
+ linkType: hard
+
"@types/estree@npm:*, @types/estree@npm:1.0.7, @types/estree@npm:^1.0.0":
version: 1.0.7
resolution: "@types/estree@npm:1.0.7"
@@ -3870,6 +4379,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/estree@npm:1.0.9":
+ version: 1.0.9
+ resolution: "@types/estree@npm:1.0.9"
+ checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387
+ languageName: node
+ linkType: hard
+
"@types/execa@npm:^0.9.0":
version: 0.9.0
resolution: "@types/execa@npm:0.9.0"
@@ -3888,6 +4404,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/node@npm:>=20.0.0":
+ version: 26.6.1
+ resolution: "@types/node@npm:26.6.1"
+ dependencies:
+ undici-types: "npm:~8.9.0"
+ checksum: 10c0/c90d3db40e7c9c92f0854ecdcaabd9c1923b7e2a43cf2c97cf147a5aba7e18a1c80034088edfb1bb2153ceffdd87be7ce0a256b80ec7cd92e619c564133ae5fe
+ languageName: node
+ linkType: hard
+
"@types/node@npm:^11.9.4":
version: 11.15.54
resolution: "@types/node@npm:11.15.54"
@@ -3992,6 +4517,22 @@ __metadata:
languageName: node
linkType: hard
+"@types/whatwg-mimetype@npm:^3.0.2":
+ version: 3.0.2
+ resolution: "@types/whatwg-mimetype@npm:3.0.2"
+ checksum: 10c0/dad39d1e4abe760a0a963c84bbdbd26b1df0eb68aff83bdf6ecbb50ad781ead777f6906d19a87007790b750f7500a12e5624d31fc6a1529d14bd19b5c3a316d1
+ languageName: node
+ linkType: hard
+
+"@types/ws@npm:^8.18.1":
+ version: 8.18.1
+ resolution: "@types/ws@npm:8.18.1"
+ dependencies:
+ "@types/node": "npm:*"
+ checksum: 10c0/61aff1129143fcc4312f083bc9e9e168aa3026b7dd6e70796276dcfb2c8211c4292603f9c4864fae702f2ed86e4abd4d38aa421831c2fd7f856c931a481afbab
+ languageName: node
+ linkType: hard
+
"@types/yauzl@npm:^2.9.1":
version: 2.10.3
resolution: "@types/yauzl@npm:2.10.3"
@@ -4166,27 +4707,110 @@ __metadata:
languageName: node
linkType: hard
-"@vitejs/plugin-vue-jsx@npm:^4.1.2":
- version: 4.1.2
- resolution: "@vitejs/plugin-vue-jsx@npm:4.1.2"
+"@vitejs/plugin-vue-jsx@npm:^4.1.2":
+ version: 4.1.2
+ resolution: "@vitejs/plugin-vue-jsx@npm:4.1.2"
+ dependencies:
+ "@babel/core": "npm:^7.26.7"
+ "@babel/plugin-transform-typescript": "npm:^7.26.7"
+ "@vue/babel-plugin-jsx": "npm:^1.2.5"
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0
+ vue: ^3.0.0
+ checksum: 10c0/a1b8bf971696c0f733aee9692f19d0504953e3c7644e2a3d648d3fd5b2aa95ce06cf2f91aa4caa96cca861b94b86683e1b456f3b2a668ad6d12013b58ebb4916
+ languageName: node
+ linkType: hard
+
+"@vitejs/plugin-vue@npm:^5.2.3":
+ version: 5.2.3
+ resolution: "@vitejs/plugin-vue@npm:5.2.3"
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0
+ vue: ^3.2.25
+ checksum: 10c0/34a719c70567582c8f345378e4f46a7edd9242e7f1595cdda408d97fccf210b8ee66b97c8555ec867f777be86ae530bd19166a542b1d7ecd6894bc544096f047
+ languageName: node
+ linkType: hard
+
+"@vitest/expect@npm:3.2.7":
+ version: 3.2.7
+ resolution: "@vitest/expect@npm:3.2.7"
+ dependencies:
+ "@types/chai": "npm:^5.2.2"
+ "@vitest/spy": "npm:3.2.7"
+ "@vitest/utils": "npm:3.2.7"
+ chai: "npm:^5.2.0"
+ tinyrainbow: "npm:^2.0.0"
+ checksum: 10c0/5a13fee261d1020d47a3517f4d9a2cb209d7475399c815f6ebe22be116ddb0c6c401592f07d24f9a8b1c192155d11f651397ee28061f886ea61f1f9181a8fd48
+ languageName: node
+ linkType: hard
+
+"@vitest/mocker@npm:3.2.7":
+ version: 3.2.7
+ resolution: "@vitest/mocker@npm:3.2.7"
+ dependencies:
+ "@vitest/spy": "npm:3.2.7"
+ estree-walker: "npm:^3.0.3"
+ magic-string: "npm:^0.30.17"
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+ checksum: 10c0/e8d9a155723e77b3e14c5a1eba35d8966b94420ebc733ffd7119b7bede22006580c468eec2aec6612fb77852cbcdfa94c6f49218f3ca1427aa9363d545c624fd
+ languageName: node
+ linkType: hard
+
+"@vitest/pretty-format@npm:3.2.7, @vitest/pretty-format@npm:^3.2.7":
+ version: 3.2.7
+ resolution: "@vitest/pretty-format@npm:3.2.7"
+ dependencies:
+ tinyrainbow: "npm:^2.0.0"
+ checksum: 10c0/f556dd5f9e5240c7ab054d795622292c1b23f6aad3332d1e250f33d801783efbb7883387640b76d22cc91b81f30cb735b40371ec3e4f5293e735495f45d624df
+ languageName: node
+ linkType: hard
+
+"@vitest/runner@npm:3.2.7":
+ version: 3.2.7
+ resolution: "@vitest/runner@npm:3.2.7"
+ dependencies:
+ "@vitest/utils": "npm:3.2.7"
+ pathe: "npm:^2.0.3"
+ strip-literal: "npm:^3.0.0"
+ checksum: 10c0/d73ba6df95175ea2b545d37fde3e743d113ecd608693b444af1d4dc27956abe4d5b500e25e09ffe46ce720de6b7cec68c264fca20366fc6da0d9ce75c52047cc
+ languageName: node
+ linkType: hard
+
+"@vitest/snapshot@npm:3.2.7":
+ version: 3.2.7
+ resolution: "@vitest/snapshot@npm:3.2.7"
dependencies:
- "@babel/core": "npm:^7.26.7"
- "@babel/plugin-transform-typescript": "npm:^7.26.7"
- "@vue/babel-plugin-jsx": "npm:^1.2.5"
- peerDependencies:
- vite: ^5.0.0 || ^6.0.0
- vue: ^3.0.0
- checksum: 10c0/a1b8bf971696c0f733aee9692f19d0504953e3c7644e2a3d648d3fd5b2aa95ce06cf2f91aa4caa96cca861b94b86683e1b456f3b2a668ad6d12013b58ebb4916
+ "@vitest/pretty-format": "npm:3.2.7"
+ magic-string: "npm:^0.30.17"
+ pathe: "npm:^2.0.3"
+ checksum: 10c0/c58a17a2c98788e77d30d39837c024852eca91618efe9b53ec399cf76332365b8cc592a69617e0f7d696df716dcc341dac61c6cbea86ae98cdb49a4a84f48d19
languageName: node
linkType: hard
-"@vitejs/plugin-vue@npm:^5.2.3":
- version: 5.2.3
- resolution: "@vitejs/plugin-vue@npm:5.2.3"
- peerDependencies:
- vite: ^5.0.0 || ^6.0.0
- vue: ^3.2.25
- checksum: 10c0/34a719c70567582c8f345378e4f46a7edd9242e7f1595cdda408d97fccf210b8ee66b97c8555ec867f777be86ae530bd19166a542b1d7ecd6894bc544096f047
+"@vitest/spy@npm:3.2.7":
+ version: 3.2.7
+ resolution: "@vitest/spy@npm:3.2.7"
+ dependencies:
+ tinyspy: "npm:^4.0.3"
+ checksum: 10c0/cf2728b4ddc6137e0ca749215c7714845e8221a5f799e933bc623216a9958fd8b032cdd7f033ed8375df9e6ed84a18b64687ca2530f50e8a76bc3b1d16d88c42
+ languageName: node
+ linkType: hard
+
+"@vitest/utils@npm:3.2.7":
+ version: 3.2.7
+ resolution: "@vitest/utils@npm:3.2.7"
+ dependencies:
+ "@vitest/pretty-format": "npm:3.2.7"
+ loupe: "npm:^3.1.4"
+ tinyrainbow: "npm:^2.0.0"
+ checksum: 10c0/fea57d6cf66853926f54ea6e9bd249b707120b0abba565d96a3880c7f04d5c5cd4778546879b9074cc73c0b81b475c1873791d4b3b3e5a324115b5bcfd3a46bb
languageName: node
linkType: hard
@@ -4440,6 +5064,23 @@ __metadata:
languageName: node
linkType: hard
+"@vue/test-utils@npm:^2.4.6":
+ version: 2.5.1
+ resolution: "@vue/test-utils@npm:2.5.1"
+ dependencies:
+ js-beautify: "npm:^2.0.0"
+ vue-component-type-helpers: "npm:^3.0.0"
+ peerDependencies:
+ "@vue/compiler-dom": 3.x
+ "@vue/server-renderer": 3.x
+ vue: 3.x
+ peerDependenciesMeta:
+ "@vue/server-renderer":
+ optional: true
+ checksum: 10c0/f44597b0db0689f4d30e32d1f8d367bf2ab040e80431b46cb9ce423fae5485bc56c03c15c530fa29c6a824868355cc59c33ed4704304215d17bc75ac3565e385
+ languageName: node
+ linkType: hard
+
"@vueuse/core@npm:^10.8.0":
version: 10.11.1
resolution: "@vueuse/core@npm:10.11.1"
@@ -4607,6 +5248,13 @@ __metadata:
languageName: node
linkType: hard
+"abbrev@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "abbrev@npm:5.0.0"
+ checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5
+ languageName: node
+ linkType: hard
+
"abort-controller@npm:^3.0.0":
version: 3.0.0
resolution: "abort-controller@npm:3.0.0"
@@ -4984,6 +5632,13 @@ __metadata:
languageName: node
linkType: hard
+"assertion-error@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "assertion-error@npm:2.0.1"
+ checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8
+ languageName: node
+ linkType: hard
+
"ast-kit@npm:^1.0.1, ast-kit@npm:^1.4.0":
version: 1.4.3
resolution: "ast-kit@npm:1.4.3"
@@ -5148,6 +5803,13 @@ __metadata:
languageName: node
linkType: hard
+"balanced-match@npm:^4.0.2":
+ version: 4.0.4
+ resolution: "balanced-match@npm:4.0.4"
+ checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b
+ languageName: node
+ linkType: hard
+
"bare-events@npm:^2.2.0, bare-events@npm:^2.5.4":
version: 2.5.4
resolution: "bare-events@npm:2.5.4"
@@ -5318,6 +5980,15 @@ __metadata:
languageName: node
linkType: hard
+"brace-expansion@npm:^5.0.8":
+ version: 5.0.12
+ resolution: "brace-expansion@npm:5.0.12"
+ dependencies:
+ balanced-match: "npm:^4.0.2"
+ checksum: 10c0/a40ac64d3bdb5bb222456514d3633c1f627f03b844f509c69e70f98b78be0ce85e21b44bbfecdd0ddd4cc0855ca4242b41ea96ddccf88c7c3c7a84405474bb3a
+ languageName: node
+ linkType: hard
+
"braces@npm:^3.0.3, braces@npm:~3.0.2":
version: 3.0.3
resolution: "braces@npm:3.0.3"
@@ -5362,6 +6033,15 @@ __metadata:
languageName: node
linkType: hard
+"buffer-image-size@npm:^0.6.4":
+ version: 0.6.4
+ resolution: "buffer-image-size@npm:0.6.4"
+ dependencies:
+ "@types/node": "npm:*"
+ checksum: 10c0/7158205c8726caa521d3ae6ef7bc341eff211ff86f7278d122e45062e1720afef2f7ad8e845edc96c04f499b292c4ec8a74df9836a25074881260ba00b863448
+ languageName: node
+ linkType: hard
+
"buffer@npm:^5.5.0":
version: 5.7.1
resolution: "buffer@npm:5.7.1"
@@ -5405,7 +6085,7 @@ __metadata:
languageName: node
linkType: hard
-"c12@npm:^3.0.2, c12@npm:^3.0.3, c12@npm:^3.3.4":
+"c12@npm:^3.0.2, c12@npm:^3.0.3, c12@npm:^3.3.3, c12@npm:^3.3.4":
version: 3.3.4
resolution: "c12@npm:3.3.4"
dependencies:
@@ -5538,6 +6218,19 @@ __metadata:
languageName: node
linkType: hard
+"chai@npm:^5.2.0":
+ version: 5.3.3
+ resolution: "chai@npm:5.3.3"
+ dependencies:
+ assertion-error: "npm:^2.0.1"
+ check-error: "npm:^2.1.1"
+ deep-eql: "npm:^5.0.1"
+ loupe: "npm:^3.1.0"
+ pathval: "npm:^2.0.0"
+ checksum: 10c0/b360fd4d38861622e5010c2f709736988b05c7f31042305fa3f4e9911f6adb80ccfb4e302068bf8ed10e835c2e2520cba0f5edc13d878b886987e5aa62483f53
+ languageName: node
+ linkType: hard
+
"chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.2":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
@@ -5571,6 +6264,13 @@ __metadata:
languageName: node
linkType: hard
+"check-error@npm:^2.1.1":
+ version: 2.1.3
+ resolution: "check-error@npm:2.1.3"
+ checksum: 10c0/878e99038fb6476316b74668cd6a498c7e66df3efe48158fa40db80a06ba4258742ac3ee2229c4a2a98c5e73f5dff84eb3e50ceb6b65bbd8f831eafc8338607d
+ languageName: node
+ linkType: hard
+
"chokidar@npm:^3.6.0":
version: 3.6.0
resolution: "chokidar@npm:3.6.0"
@@ -5638,6 +6338,13 @@ __metadata:
languageName: node
linkType: hard
+"citty@npm:^0.2.2":
+ version: 0.2.2
+ resolution: "citty@npm:0.2.2"
+ checksum: 10c0/c896c9dcd187d2a16685706d11428dcdec9eb59aa13fe50aff7b12e1d3521f1bf434d6a5316fe75a341f8ba5ce1d2beceb84f7f261d018985a73d3f6f2a793e3
+ languageName: node
+ linkType: hard
+
"class-variance-authority@npm:^0.7.1":
version: 0.7.1
resolution: "class-variance-authority@npm:0.7.1"
@@ -5884,6 +6591,13 @@ __metadata:
languageName: node
linkType: hard
+"commander@npm:^14.0.3":
+ version: 14.0.3
+ resolution: "commander@npm:14.0.3"
+ checksum: 10c0/755652564bbf56ff2ff083313912b326450d3f8d8c85f4b71416539c9a05c3c67dbd206821ca72635bf6b160e2afdefcb458e86b317827d5cb333b69ce7f1a24
+ languageName: node
+ linkType: hard
+
"commander@npm:^2.20.0, commander@npm:^2.20.3":
version: 2.20.3
resolution: "commander@npm:2.20.3"
@@ -5997,6 +6711,16 @@ __metadata:
languageName: node
linkType: hard
+"config-chain@npm:^1.1.13":
+ version: 1.1.13
+ resolution: "config-chain@npm:1.1.13"
+ dependencies:
+ ini: "npm:^1.3.4"
+ proto-list: "npm:~1.2.1"
+ checksum: 10c0/39d1df18739d7088736cc75695e98d7087aea43646351b028dfabd5508d79cf6ef4c5bcd90471f52cd87ae470d1c5490c0a8c1a292fbe6ee9ff688061ea0963e
+ languageName: node
+ linkType: hard
+
"config-maker@npm:^0.0.6":
version: 0.0.6
resolution: "config-maker@npm:0.0.6"
@@ -6072,6 +6796,13 @@ __metadata:
languageName: node
linkType: hard
+"cookie-es@npm:^1.2.3":
+ version: 1.2.3
+ resolution: "cookie-es@npm:1.2.3"
+ checksum: 10c0/429eae6f5130a7380ea024d787d7e1ecc644ca84f9c43dfb70f18761a831d2ba591d28f837ce350892cfff0857d711f3a4ad93a082637bceb478823c339c1a97
+ languageName: node
+ linkType: hard
+
"cookie-es@npm:^2.0.0":
version: 2.0.0
resolution: "cookie-es@npm:2.0.0"
@@ -6259,7 +6990,7 @@ __metadata:
languageName: node
linkType: hard
-"crossws@npm:>=0.2.0 <0.4.0, crossws@npm:^0.3.4":
+"crossws@npm:>=0.2.0 <0.4.0, crossws@npm:^0.3.4, crossws@npm:^0.3.5":
version: 0.3.5
resolution: "crossws@npm:0.3.5"
dependencies:
@@ -6864,6 +7595,18 @@ __metadata:
languageName: node
linkType: hard
+"debug@npm:^4.4.1":
+ version: 4.4.3
+ resolution: "debug@npm:4.4.3"
+ dependencies:
+ ms: "npm:^2.1.3"
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6
+ languageName: node
+ linkType: hard
+
"decache@npm:^4.6.2":
version: 4.6.2
resolution: "decache@npm:4.6.2"
@@ -6889,6 +7632,13 @@ __metadata:
languageName: node
linkType: hard
+"deep-eql@npm:^5.0.1":
+ version: 5.0.2
+ resolution: "deep-eql@npm:5.0.2"
+ checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247
+ languageName: node
+ linkType: hard
+
"deep-equal@npm:~1.0.1":
version: 1.0.1
resolution: "deep-equal@npm:1.0.1"
@@ -7313,6 +8063,20 @@ __metadata:
languageName: node
linkType: hard
+"editorconfig@npm:^3.0.2":
+ version: 3.0.2
+ resolution: "editorconfig@npm:3.0.2"
+ dependencies:
+ "@one-ini/wasm": "npm:0.2.1"
+ commander: "npm:^14.0.3"
+ minimatch: "npm:~10.2.4"
+ semver: "npm:^7.7.4"
+ bin:
+ editorconfig: bin/editorconfig
+ checksum: 10c0/22b4594adc4e5832c88c0926adb1dcd289351edb9c2b0bd68ecf56ad107bf3741ab407aa44999e503889d62e38519d42ca38aae183e4ffc0b5fd94cebdd73d08
+ languageName: node
+ linkType: hard
+
"ee-first@npm:1.1.1":
version: 1.1.1
resolution: "ee-first@npm:1.1.1"
@@ -7464,6 +8228,13 @@ __metadata:
languageName: node
linkType: hard
+"errx@npm:^0.1.2":
+ version: 0.1.2
+ resolution: "errx@npm:0.1.2"
+ checksum: 10c0/1e172e7a1b451b1fe8b800f847b6645e4813e2efe7a93ca3342476ff0cc8b50720e6d03f7cb1982334f8d537018f12e134ca30a4dd2bd5869cf490068ec415c1
+ languageName: node
+ linkType: hard
+
"es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9":
version: 1.23.9
resolution: "es-abstract@npm:1.23.9"
@@ -7662,6 +8433,95 @@ __metadata:
languageName: node
linkType: hard
+"esbuild@npm:^0.27.0 || ^0.28.0":
+ version: 0.28.2
+ resolution: "esbuild@npm:0.28.2"
+ dependencies:
+ "@esbuild/aix-ppc64": "npm:0.28.2"
+ "@esbuild/android-arm": "npm:0.28.2"
+ "@esbuild/android-arm64": "npm:0.28.2"
+ "@esbuild/android-x64": "npm:0.28.2"
+ "@esbuild/darwin-arm64": "npm:0.28.2"
+ "@esbuild/darwin-x64": "npm:0.28.2"
+ "@esbuild/freebsd-arm64": "npm:0.28.2"
+ "@esbuild/freebsd-x64": "npm:0.28.2"
+ "@esbuild/linux-arm": "npm:0.28.2"
+ "@esbuild/linux-arm64": "npm:0.28.2"
+ "@esbuild/linux-ia32": "npm:0.28.2"
+ "@esbuild/linux-loong64": "npm:0.28.2"
+ "@esbuild/linux-mips64el": "npm:0.28.2"
+ "@esbuild/linux-ppc64": "npm:0.28.2"
+ "@esbuild/linux-riscv64": "npm:0.28.2"
+ "@esbuild/linux-s390x": "npm:0.28.2"
+ "@esbuild/linux-x64": "npm:0.28.2"
+ "@esbuild/netbsd-arm64": "npm:0.28.2"
+ "@esbuild/netbsd-x64": "npm:0.28.2"
+ "@esbuild/openbsd-arm64": "npm:0.28.2"
+ "@esbuild/openbsd-x64": "npm:0.28.2"
+ "@esbuild/openharmony-arm64": "npm:0.28.2"
+ "@esbuild/sunos-x64": "npm:0.28.2"
+ "@esbuild/win32-arm64": "npm:0.28.2"
+ "@esbuild/win32-ia32": "npm:0.28.2"
+ "@esbuild/win32-x64": "npm:0.28.2"
+ dependenciesMeta:
+ "@esbuild/aix-ppc64":
+ optional: true
+ "@esbuild/android-arm":
+ optional: true
+ "@esbuild/android-arm64":
+ optional: true
+ "@esbuild/android-x64":
+ optional: true
+ "@esbuild/darwin-arm64":
+ optional: true
+ "@esbuild/darwin-x64":
+ optional: true
+ "@esbuild/freebsd-arm64":
+ optional: true
+ "@esbuild/freebsd-x64":
+ optional: true
+ "@esbuild/linux-arm":
+ optional: true
+ "@esbuild/linux-arm64":
+ optional: true
+ "@esbuild/linux-ia32":
+ optional: true
+ "@esbuild/linux-loong64":
+ optional: true
+ "@esbuild/linux-mips64el":
+ optional: true
+ "@esbuild/linux-ppc64":
+ optional: true
+ "@esbuild/linux-riscv64":
+ optional: true
+ "@esbuild/linux-s390x":
+ optional: true
+ "@esbuild/linux-x64":
+ optional: true
+ "@esbuild/netbsd-arm64":
+ optional: true
+ "@esbuild/netbsd-x64":
+ optional: true
+ "@esbuild/openbsd-arm64":
+ optional: true
+ "@esbuild/openbsd-x64":
+ optional: true
+ "@esbuild/openharmony-arm64":
+ optional: true
+ "@esbuild/sunos-x64":
+ optional: true
+ "@esbuild/win32-arm64":
+ optional: true
+ "@esbuild/win32-ia32":
+ optional: true
+ "@esbuild/win32-x64":
+ optional: true
+ bin:
+ esbuild: bin/esbuild
+ checksum: 10c0/9b19edb63bd7780fd2e8e65a1394a0e8a05a17d697f73f0647ffe3c134709d8dbe744ab3fd57b35c9470db268cae7678fafd3dcd3ce1f34fc590568c2433f5d2
+ languageName: node
+ linkType: hard
+
"escalade@npm:^3.1.1, escalade@npm:^3.2.0":
version: 3.2.0
resolution: "escalade@npm:3.2.0"
@@ -7898,6 +8758,13 @@ __metadata:
languageName: node
linkType: hard
+"expect-type@npm:^1.2.1":
+ version: 1.4.0
+ resolution: "expect-type@npm:1.4.0"
+ checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7
+ languageName: node
+ linkType: hard
+
"exponential-backoff@npm:^3.1.1":
version: 3.1.3
resolution: "exponential-backoff@npm:3.1.3"
@@ -7951,6 +8818,13 @@ __metadata:
languageName: node
linkType: hard
+"exsolve@npm:^1.1.1":
+ version: 1.1.1
+ resolution: "exsolve@npm:1.1.1"
+ checksum: 10c0/0b157a7600408bd356ff78cff5ad9c8c0dd7ca5e7f246216c3c9b66196e76277d3130021122b9c5078a3462751fd7aa988fe3dc1e3b80c79cb873c1623f628e1
+ languageName: node
+ linkType: hard
+
"external-editor@npm:^3.1.0":
version: 3.1.0
resolution: "external-editor@npm:3.1.0"
@@ -7991,6 +8865,13 @@ __metadata:
languageName: node
linkType: hard
+"fake-indexeddb@npm:^6.2.5":
+ version: 6.2.5
+ resolution: "fake-indexeddb@npm:6.2.5"
+ checksum: 10c0/6c5e2fe84a61daa06d7ad63699d1041fe61847f15f92db12415634b3db94f363a64be9e08a3c3c4434af9c3c0132086b85c4d5dc5e8e06edae1e7daf70ce1f3c
+ languageName: node
+ linkType: hard
+
"fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
@@ -8489,6 +9370,13 @@ __metadata:
languageName: node
linkType: hard
+"get-port-please@npm:^3.2.0":
+ version: 3.2.0
+ resolution: "get-port-please@npm:3.2.0"
+ checksum: 10c0/7e48443110b463e76ef47efc381c9f16d78798f9ea9f6d928dad2b5cee53a199cf64e6e2f22603e5f8a1f742e3d4a144cd367f6ef82ac48759bfd2beb48ee9e5
+ languageName: node
+ linkType: hard
+
"get-proto@npm:^1.0.0, get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
@@ -8671,6 +9559,17 @@ __metadata:
languageName: node
linkType: hard
+"glob@npm:^13.0.6":
+ version: 13.0.6
+ resolution: "glob@npm:13.0.6"
+ dependencies:
+ minimatch: "npm:^10.2.2"
+ minipass: "npm:^7.1.3"
+ path-scurry: "npm:^2.0.2"
+ checksum: 10c0/269c236f11a9b50357fe7a8c6aadac667e01deb5242b19c84975628f05f4438d8ee1354bb62c5d6c10f37fd59911b54d7799730633a2786660d8c69f1d18120a
+ languageName: node
+ linkType: hard
+
"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.2.0, glob@npm:^7.2.3":
version: 7.2.3
resolution: "glob@npm:7.2.3"
@@ -8921,6 +9820,26 @@ __metadata:
languageName: node
linkType: hard
+"h3-next@npm:h3@^2.0.1-rc.7":
+ version: 2.0.1-rc.32
+ resolution: "h3@npm:2.0.1-rc.32"
+ dependencies:
+ rou3: "npm:^0.9.2"
+ srvx: "npm:^1.0.5"
+ peerDependencies:
+ crossws: ^0.4.12
+ ocache: ">=0.3.0"
+ peerDependenciesMeta:
+ crossws:
+ optional: true
+ ocache:
+ optional: true
+ bin:
+ h3: ./bin/h3.mjs
+ checksum: 10c0/182881ebf9c866f79ca120e8bc1b8a8850c34673758b67593fed7217cf04387294241e63397e20dd690c39cad05c6ce070eefc0707d7813ecfa9e358c66edd59
+ languageName: node
+ linkType: hard
+
"h3@npm:^1.10.0, h3@npm:^1.12.0, h3@npm:^1.15.1, h3@npm:^1.15.2, h3@npm:^1.15.3":
version: 1.15.3
resolution: "h3@npm:1.15.3"
@@ -8938,6 +9857,38 @@ __metadata:
languageName: node
linkType: hard
+"h3@npm:^1.15.4":
+ version: 1.15.11
+ resolution: "h3@npm:1.15.11"
+ dependencies:
+ cookie-es: "npm:^1.2.3"
+ crossws: "npm:^0.3.5"
+ defu: "npm:^6.1.6"
+ destr: "npm:^2.0.5"
+ iron-webcrypto: "npm:^1.2.1"
+ node-mock-http: "npm:^1.0.4"
+ radix3: "npm:^1.1.2"
+ ufo: "npm:^1.6.3"
+ uncrypto: "npm:^0.1.3"
+ checksum: 10c0/6ccb421b9f92e02e6330c2b6697b18ef18e9550e4a1708f224ca517c40ecd201cd00967d0feb26e718595e86e985edec1755933cf8792d34fb8504f1c7cc261d
+ languageName: node
+ linkType: hard
+
+"happy-dom@npm:^20.0.11":
+ version: 20.14.5
+ resolution: "happy-dom@npm:20.14.5"
+ dependencies:
+ "@types/node": "npm:>=20.0.0"
+ "@types/whatwg-mimetype": "npm:^3.0.2"
+ "@types/ws": "npm:^8.18.1"
+ buffer-image-size: "npm:^0.6.4"
+ entities: "npm:^7.0.1"
+ whatwg-mimetype: "npm:^3.0.0"
+ ws: "npm:^8.21.0"
+ checksum: 10c0/c2f82774aaa42944918e5a8bd69e34e98f87f5d3f144b44829ba53ed1f49f11ad35598e96ee3cf49fc65dbddaed57ae3760d693e60ee245de87e3af1aeadf63e
+ languageName: node
+ linkType: hard
+
"has-bigints@npm:^1.0.2":
version: 1.1.0
resolution: "has-bigints@npm:1.1.0"
@@ -9214,6 +10165,13 @@ __metadata:
languageName: node
linkType: hard
+"ignore@npm:^7.0.6":
+ version: 7.0.9
+ resolution: "ignore@npm:7.0.9"
+ checksum: 10c0/fec02e67b8c01a873d3b60eff6cce5e390365d7c45fea57f60f4ff52b9be5873c366867a3b8e154a3140330b72b41b94f19e1f28adfe0ef558c6b28bbf02a1b4
+ languageName: node
+ linkType: hard
+
"image-meta@npm:^0.2.0, image-meta@npm:^0.2.1":
version: 0.2.1
resolution: "image-meta@npm:0.2.1"
@@ -9286,6 +10244,13 @@ __metadata:
languageName: node
linkType: hard
+"ini@npm:^1.3.4, ini@npm:~1.3.0":
+ version: 1.3.8
+ resolution: "ini@npm:1.3.8"
+ checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a
+ languageName: node
+ linkType: hard
+
"ini@npm:^4.1.3":
version: 4.1.3
resolution: "ini@npm:4.1.3"
@@ -9293,13 +10258,6 @@ __metadata:
languageName: node
linkType: hard
-"ini@npm:~1.3.0":
- version: 1.3.8
- resolution: "ini@npm:1.3.8"
- checksum: 10c0/ec93838d2328b619532e4f1ff05df7909760b6f66d9c9e2ded11e5c1897d6f2f9980c54dd638f88654b00919ce31e827040631eab0a3969e4d1abefa0719516a
- languageName: node
- linkType: hard
-
"inquirer@npm:^9.1.2":
version: 9.3.7
resolution: "inquirer@npm:9.3.7"
@@ -9967,6 +10925,30 @@ __metadata:
languageName: node
linkType: hard
+"js-beautify@npm:^2.0.0":
+ version: 2.0.3
+ resolution: "js-beautify@npm:2.0.3"
+ dependencies:
+ config-chain: "npm:^1.1.13"
+ editorconfig: "npm:^3.0.2"
+ glob: "npm:^13.0.6"
+ js-cookie: "npm:^3.0.8"
+ nopt: "npm:^10.0.1"
+ bin:
+ css-beautify: js/bin/css-beautify.js
+ html-beautify: js/bin/html-beautify.js
+ js-beautify: js/bin/js-beautify.js
+ checksum: 10c0/2b08a70c70b031c1b057b3da844d1f372a08173c3561f8f5c61640ab74a227b3173107a176eecd78da749a19f7a3d175e3d340cf615fb2d76b4cfd7aeefdfc59
+ languageName: node
+ linkType: hard
+
+"js-cookie@npm:^3.0.8":
+ version: 3.0.8
+ resolution: "js-cookie@npm:3.0.8"
+ checksum: 10c0/421912a4a55535bda32b3059835864e1182c3af5b4516df00a060edc1fa5a53d38bb8d5a91d5d305e396206f4fd11e829f17850aa5aa8164118c04d8ebf1ff5d
+ languageName: node
+ linkType: hard
+
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -10130,6 +11112,13 @@ __metadata:
languageName: node
linkType: hard
+"knitwork@npm:^1.3.0":
+ version: 1.3.0
+ resolution: "knitwork@npm:1.3.0"
+ checksum: 10c0/727127cfea8b3b54ad70e71f52561ebae992e6b27e93d412ba807d96348c5e3827acd6235f8105d5e47a9f078592c988cb48549253babebcb23fe980c0219a22
+ languageName: node
+ linkType: hard
+
"koa-compose@npm:^4.1.0":
version: 4.1.0
resolution: "koa-compose@npm:4.1.0"
@@ -10299,6 +11288,17 @@ __metadata:
languageName: node
linkType: hard
+"local-pkg@npm:^1.1.2":
+ version: 1.2.1
+ resolution: "local-pkg@npm:1.2.1"
+ dependencies:
+ mlly: "npm:^1.7.4"
+ pkg-types: "npm:^2.3.0"
+ quansync: "npm:^0.2.11"
+ checksum: 10c0/d21ccfc2595b2eddf7369f19797533573feb06820d8b5766467902a7a54e36b3a1f2b8cc2aa4481a2ad432c8e798576eabfac2f1ec0646a8e11a68694c2ef9d4
+ languageName: node
+ linkType: hard
+
"locate-path@npm:^3.0.0":
version: 3.0.0
resolution: "locate-path@npm:3.0.0"
@@ -10470,6 +11470,13 @@ __metadata:
languageName: node
linkType: hard
+"loupe@npm:^3.1.0, loupe@npm:^3.1.4":
+ version: 3.2.1
+ resolution: "loupe@npm:3.2.1"
+ checksum: 10c0/910c872cba291309664c2d094368d31a68907b6f5913e989d301b5c25f30e97d76d77f23ab3bf3b46d0f601ff0b6af8810c10c31b91d2c6b2f132809ca2cc705
+ languageName: node
+ linkType: hard
+
"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3":
version: 10.4.3
resolution: "lru-cache@npm:10.4.3"
@@ -10824,6 +11831,15 @@ __metadata:
languageName: node
linkType: hard
+"minimatch@npm:^10.2.2, minimatch@npm:~10.2.4":
+ version: 10.2.6
+ resolution: "minimatch@npm:10.2.6"
+ dependencies:
+ brace-expansion: "npm:^5.0.8"
+ checksum: 10c0/4559a836243b98bd4d17ea9f7edae698717c76399eea7be374f3737f33164e4907f19e9726891ddeb122f750a5a7fa80d2ac43e851d6e5984dc4ff42ec127d3a
+ languageName: node
+ linkType: hard
+
"minimatch@npm:^3.1.1, minimatch@npm:^3.1.2":
version: 3.1.2
resolution: "minimatch@npm:3.1.2"
@@ -10932,6 +11948,13 @@ __metadata:
languageName: node
linkType: hard
+"minipass@npm:^7.1.3":
+ version: 7.1.3
+ resolution: "minipass@npm:7.1.3"
+ checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb
+ languageName: node
+ linkType: hard
+
"minisearch@npm:^7.1.2":
version: 7.1.2
resolution: "minisearch@npm:7.1.2"
@@ -11112,7 +12135,16 @@ __metadata:
resolution: "nanoid@npm:3.3.11"
bin:
nanoid: bin/nanoid.cjs
- checksum: 10c0/40e7f70b3d15f725ca072dfc4f74e81fcf1fbb02e491cf58ac0c79093adc9b0a73b152bcde57df4b79cd097e13023d7504acb38404a4da7bc1cd8e887b82fe0b
+ checksum: 10c0/40e7f70b3d15f725ca072dfc4f74e81fcf1fbb02e491cf58ac0c79093adc9b0a73b152bcde57df4b79cd097e13023d7504acb38404a4da7bc1cd8e887b82fe0b
+ languageName: node
+ linkType: hard
+
+"nanoid@npm:^3.3.18":
+ version: 3.3.19
+ resolution: "nanoid@npm:3.3.19"
+ bin:
+ nanoid: bin/nanoid.cjs
+ checksum: 10c0/ce7afa6258bfaa00bdc8de291cfe9913280eaa403b7d9cff3db4e4200c1913e7163577df74897958da144db473a96c67f74f57db0f542fbfe409df677bae3255
languageName: node
linkType: hard
@@ -11326,6 +12358,13 @@ __metadata:
languageName: node
linkType: hard
+"node-fetch-native@npm:^1.6.7":
+ version: 1.6.7
+ resolution: "node-fetch-native@npm:1.6.7"
+ checksum: 10c0/8b748300fb053d21ca4d3db9c3ff52593d5e8f8a2d9fe90cbfad159676e324b954fdaefab46aeca007b5b9edab3d150021c4846444e4e8ab1f4e44cd3807be87
+ languageName: node
+ linkType: hard
+
"node-fetch@npm:^2.6.7, node-fetch@npm:^2.7.0":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
@@ -11396,6 +12435,13 @@ __metadata:
languageName: node
linkType: hard
+"node-mock-http@npm:^1.0.4":
+ version: 1.0.5
+ resolution: "node-mock-http@npm:1.0.5"
+ checksum: 10c0/14a36b28423953af95e4659aa1e07208b5a38daaa89c1aac28a193f5420744a2511eb288503019b2fc8de582c70612d2e475d0df1d1a0e44fe3dfd7fc8f5399c
+ languageName: node
+ linkType: hard
+
"node-releases@npm:^2.0.19":
version: 2.0.19
resolution: "node-releases@npm:2.0.19"
@@ -11412,6 +12458,17 @@ __metadata:
languageName: node
linkType: hard
+"nopt@npm:^10.0.1":
+ version: 10.0.1
+ resolution: "nopt@npm:10.0.1"
+ dependencies:
+ abbrev: "npm:^5.0.0"
+ bin:
+ nopt: bin/nopt.js
+ checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a
+ languageName: node
+ linkType: hard
+
"nopt@npm:^5.0.0":
version: 5.0.0
resolution: "nopt@npm:5.0.0"
@@ -11633,6 +12690,19 @@ __metadata:
languageName: node
linkType: hard
+"nypm@npm:^0.6.2":
+ version: 0.6.10
+ resolution: "nypm@npm:0.6.10"
+ dependencies:
+ citty: "npm:^0.2.2"
+ pathe: "npm:^2.0.3"
+ tinyexec: "npm:^1.3.1"
+ bin:
+ nypm: ./dist/cli.mjs
+ checksum: 10c0/2276e4d43231e2ec4cc511c7ce8e6fe3439d4f090a1ee4ba875053bcd4fc67841be250fdee297645cf1250dabf33271d171375e4b68eb607009c6a1fba2a91dd
+ languageName: node
+ linkType: hard
+
"object-assign@npm:^4.0.1, object-assign@npm:^4.1.1":
version: 4.1.1
resolution: "object-assign@npm:4.1.1"
@@ -11686,6 +12756,17 @@ __metadata:
languageName: node
linkType: hard
+"ofetch@npm:^1.5.1":
+ version: 1.5.1
+ resolution: "ofetch@npm:1.5.1"
+ dependencies:
+ destr: "npm:^2.0.5"
+ node-fetch-native: "npm:^1.6.7"
+ ufo: "npm:^1.6.1"
+ checksum: 10c0/97ebc600512ea0ab401e97c73313218cc53c9b530b32ec8c995c347b0c68887129993168d1753f527761a64c6f93a5d823ce1378ccec95fc65a606f323a79a6c
+ languageName: node
+ linkType: hard
+
"ohash@npm:^2.0.11":
version: 2.0.11
resolution: "ohash@npm:2.0.11"
@@ -12199,6 +13280,16 @@ __metadata:
languageName: node
linkType: hard
+"path-scurry@npm:^2.0.2":
+ version: 2.0.2
+ resolution: "path-scurry@npm:2.0.2"
+ dependencies:
+ lru-cache: "npm:^11.0.0"
+ minipass: "npm:^7.1.2"
+ checksum: 10c0/b35ad37cf6557a87fd057121ce2be7695380c9138d93e87ae928609da259ea0a170fac6f3ef1eb3ece8a068e8b7f2f3adf5bb2374cf4d4a57fe484954fcc9482
+ languageName: node
+ linkType: hard
+
"path-to-regexp@npm:0.1.12":
version: 0.1.12
resolution: "path-to-regexp@npm:0.1.12"
@@ -12241,6 +13332,13 @@ __metadata:
languageName: node
linkType: hard
+"pathval@npm:^2.0.0":
+ version: 2.0.1
+ resolution: "pathval@npm:2.0.1"
+ checksum: 10c0/460f4709479fbf2c45903a65655fc8f0a5f6d808f989173aeef5fdea4ff4f303dc13f7870303999add60ec49d4c14733895c0a869392e9866f1091fa64fd7581
+ languageName: node
+ linkType: hard
+
"pend@npm:~1.2.0":
version: 1.2.0
resolution: "pend@npm:1.2.0"
@@ -12255,7 +13353,7 @@ __metadata:
languageName: node
linkType: hard
-"perfect-debounce@npm:^2.1.0":
+"perfect-debounce@npm:^2.0.0, perfect-debounce@npm:^2.1.0":
version: 2.1.0
resolution: "perfect-debounce@npm:2.1.0"
checksum: 10c0/c4f833816f249129cea996d60b1351b640cf06954ab4eecaca440234ef70f4aefe6483637049750f5b00e15a5036307ea77f831c9779d6ad878457680af49b6d
@@ -12832,6 +13930,17 @@ __metadata:
languageName: node
linkType: hard
+"postcss@npm:^8.5.6":
+ version: 8.5.28
+ resolution: "postcss@npm:8.5.28"
+ dependencies:
+ nanoid: "npm:^3.3.18"
+ picocolors: "npm:^1.1.1"
+ source-map-js: "npm:^1.2.1"
+ checksum: 10c0/9fe44215a6628d89c8a75184b92f5b2f77e16f9e5b13b39b9009bb7e8e6eccddf82c8854bae922db1f17ace6ef3445073fe38abff513caeb03e5285b1b55620a
+ languageName: node
+ linkType: hard
+
"prebuild-install@npm:^7.1.1":
version: 7.1.3
resolution: "prebuild-install@npm:7.1.3"
@@ -12949,6 +14058,13 @@ __metadata:
languageName: node
linkType: hard
+"proto-list@npm:~1.2.1":
+ version: 1.2.4
+ resolution: "proto-list@npm:1.2.4"
+ checksum: 10c0/b9179f99394ec8a68b8afc817690185f3b03933f7b46ce2e22c1930dc84b60d09f5ad222beab4e59e58c6c039c7f7fcf620397235ef441a356f31f9744010e12
+ languageName: node
+ linkType: hard
+
"protobufjs@npm:^7.2.4":
version: 7.5.8
resolution: "protobufjs@npm:7.5.8"
@@ -13041,6 +14157,13 @@ __metadata:
languageName: node
linkType: hard
+"quansync@npm:^0.2.11":
+ version: 0.2.11
+ resolution: "quansync@npm:0.2.11"
+ checksum: 10c0/cb9a1f8ebce074069f2f6a78578873ffedd9de9f6aa212039b44c0870955c04a71c3b1311b5d97f8ac2f2ec476de202d0a5c01160cb12bc0a11b7ef36d22ef56
+ languageName: node
+ linkType: hard
+
"quansync@npm:^0.2.8":
version: 0.2.10
resolution: "quansync@npm:0.2.10"
@@ -13665,6 +14788,106 @@ __metadata:
languageName: node
linkType: hard
+"rollup@npm:^4.43.0":
+ version: 4.63.3
+ resolution: "rollup@npm:4.63.3"
+ dependencies:
+ "@napi-rs/lzma-linux-x64-gnu": "npm:1.5.1"
+ "@rollup/rollup-android-arm-eabi": "npm:4.63.3"
+ "@rollup/rollup-android-arm64": "npm:4.63.3"
+ "@rollup/rollup-darwin-arm64": "npm:4.63.3"
+ "@rollup/rollup-darwin-x64": "npm:4.63.3"
+ "@rollup/rollup-freebsd-arm64": "npm:4.63.3"
+ "@rollup/rollup-freebsd-x64": "npm:4.63.3"
+ "@rollup/rollup-linux-arm-gnueabihf": "npm:4.63.3"
+ "@rollup/rollup-linux-arm-musleabihf": "npm:4.63.3"
+ "@rollup/rollup-linux-arm64-gnu": "npm:4.63.3"
+ "@rollup/rollup-linux-arm64-musl": "npm:4.63.3"
+ "@rollup/rollup-linux-loong64-gnu": "npm:4.63.3"
+ "@rollup/rollup-linux-loong64-musl": "npm:4.63.3"
+ "@rollup/rollup-linux-ppc64-gnu": "npm:4.63.3"
+ "@rollup/rollup-linux-ppc64-musl": "npm:4.63.3"
+ "@rollup/rollup-linux-riscv64-gnu": "npm:4.63.3"
+ "@rollup/rollup-linux-riscv64-musl": "npm:4.63.3"
+ "@rollup/rollup-linux-s390x-gnu": "npm:4.63.3"
+ "@rollup/rollup-linux-x64-gnu": "npm:4.63.3"
+ "@rollup/rollup-linux-x64-musl": "npm:4.63.3"
+ "@rollup/rollup-openbsd-x64": "npm:4.63.3"
+ "@rollup/rollup-openharmony-arm64": "npm:4.63.3"
+ "@rollup/rollup-win32-arm64-msvc": "npm:4.63.3"
+ "@rollup/rollup-win32-ia32-msvc": "npm:4.63.3"
+ "@rollup/rollup-win32-x64-gnu": "npm:4.63.3"
+ "@rollup/rollup-win32-x64-msvc": "npm:4.63.3"
+ "@types/estree": "npm:1.0.9"
+ fsevents: "npm:~2.3.2"
+ dependenciesMeta:
+ "@napi-rs/lzma-linux-x64-gnu":
+ optional: true
+ "@rollup/rollup-android-arm-eabi":
+ optional: true
+ "@rollup/rollup-android-arm64":
+ optional: true
+ "@rollup/rollup-darwin-arm64":
+ optional: true
+ "@rollup/rollup-darwin-x64":
+ optional: true
+ "@rollup/rollup-freebsd-arm64":
+ optional: true
+ "@rollup/rollup-freebsd-x64":
+ optional: true
+ "@rollup/rollup-linux-arm-gnueabihf":
+ optional: true
+ "@rollup/rollup-linux-arm-musleabihf":
+ optional: true
+ "@rollup/rollup-linux-arm64-gnu":
+ optional: true
+ "@rollup/rollup-linux-arm64-musl":
+ optional: true
+ "@rollup/rollup-linux-loong64-gnu":
+ optional: true
+ "@rollup/rollup-linux-loong64-musl":
+ optional: true
+ "@rollup/rollup-linux-ppc64-gnu":
+ optional: true
+ "@rollup/rollup-linux-ppc64-musl":
+ optional: true
+ "@rollup/rollup-linux-riscv64-gnu":
+ optional: true
+ "@rollup/rollup-linux-riscv64-musl":
+ optional: true
+ "@rollup/rollup-linux-s390x-gnu":
+ optional: true
+ "@rollup/rollup-linux-x64-gnu":
+ optional: true
+ "@rollup/rollup-linux-x64-musl":
+ optional: true
+ "@rollup/rollup-openbsd-x64":
+ optional: true
+ "@rollup/rollup-openharmony-arm64":
+ optional: true
+ "@rollup/rollup-win32-arm64-msvc":
+ optional: true
+ "@rollup/rollup-win32-ia32-msvc":
+ optional: true
+ "@rollup/rollup-win32-x64-gnu":
+ optional: true
+ "@rollup/rollup-win32-x64-msvc":
+ optional: true
+ fsevents:
+ optional: true
+ bin:
+ rollup: dist/bin/rollup
+ checksum: 10c0/d4327c91f7316961aa9bc38198406dbfa4baa0583340a5f01cc6d915c049c2744fca7f7b305d965411bd647a6b2aedd0d4439c654fef224fdf2342e51648a6fb
+ languageName: node
+ linkType: hard
+
+"rou3@npm:^0.9.2":
+ version: 0.9.2
+ resolution: "rou3@npm:0.9.2"
+ checksum: 10c0/4b0ab14fbd3a7c60ffee1af644f618a9e76dd218cd4fbb585b8fa3601a3f25857c2b1656a215389379a7aef1e1e1897fd55e835658e9c3a5d9683571b36b3bd6
+ languageName: node
+ linkType: hard
+
"run-applescript@npm:^7.0.0":
version: 7.0.0
resolution: "run-applescript@npm:7.0.0"
@@ -13831,6 +15054,15 @@ __metadata:
languageName: node
linkType: hard
+"semver@npm:^7.7.4, semver@npm:^7.8.5":
+ version: 7.8.5
+ resolution: "semver@npm:7.8.5"
+ bin:
+ semver: bin/semver.js
+ checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c
+ languageName: node
+ linkType: hard
+
"send@npm:0.19.0":
version: 0.19.0
resolution: "send@npm:0.19.0"
@@ -14155,6 +15387,13 @@ __metadata:
languageName: node
linkType: hard
+"siginfo@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "siginfo@npm:2.0.0"
+ checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34
+ languageName: node
+ linkType: hard
+
"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7":
version: 3.0.7
resolution: "signal-exit@npm:3.0.7"
@@ -14386,6 +15625,15 @@ __metadata:
languageName: node
linkType: hard
+"srvx@npm:^1.0.5":
+ version: 1.0.5
+ resolution: "srvx@npm:1.0.5"
+ bin:
+ srvx: ./bin/srvx.mjs
+ checksum: 10c0/7ff87707840af95b399a11ab0b2fba6ded66a1d4658324913a1c95c916304056e5e618a9fc93803c1351aaf6acf4f7f3c5f55f18e82110a2ce876246d770c881
+ languageName: node
+ linkType: hard
+
"ssri@npm:^13.0.0":
version: 13.0.0
resolution: "ssri@npm:13.0.0"
@@ -14402,6 +15650,13 @@ __metadata:
languageName: node
linkType: hard
+"stackback@npm:0.0.2":
+ version: 0.0.2
+ resolution: "stackback@npm:0.0.2"
+ checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983
+ languageName: node
+ linkType: hard
+
"stacktracey@npm:^2.1.8":
version: 2.1.8
resolution: "stacktracey@npm:2.1.8"
@@ -14433,6 +15688,13 @@ __metadata:
languageName: node
linkType: hard
+"std-env@npm:^3.10.0":
+ version: 3.10.0
+ resolution: "std-env@npm:3.10.0"
+ checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f
+ languageName: node
+ linkType: hard
+
"std-env@npm:^3.7.0, std-env@npm:^3.8.1, std-env@npm:^3.9.0":
version: 3.9.0
resolution: "std-env@npm:3.9.0"
@@ -14994,6 +16256,13 @@ __metadata:
languageName: node
linkType: hard
+"tinybench@npm:^2.9.0":
+ version: 2.9.0
+ resolution: "tinybench@npm:2.9.0"
+ checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c
+ languageName: node
+ linkType: hard
+
"tinyexec@npm:^0.3.2":
version: 0.3.2
resolution: "tinyexec@npm:0.3.2"
@@ -15008,6 +16277,13 @@ __metadata:
languageName: node
linkType: hard
+"tinyexec@npm:^1.0.2, tinyexec@npm:^1.3.1":
+ version: 1.3.1
+ resolution: "tinyexec@npm:1.3.1"
+ checksum: 10c0/c590369cb3fa73cc18a3998caec8a00f9712d91309c881be3ac52b160fb2f80c7b1fbf3591858ab6abab47ad55385b0c86ec19982e32441a01f55298dab9ccd2
+ languageName: node
+ linkType: hard
+
"tinyglobby@npm:0.2.13":
version: 0.2.13
resolution: "tinyglobby@npm:0.2.13"
@@ -15018,7 +16294,7 @@ __metadata:
languageName: node
linkType: hard
-"tinyglobby@npm:^0.2.10, tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.17":
+"tinyglobby@npm:^0.2.10, tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17":
version: 0.2.17
resolution: "tinyglobby@npm:0.2.17"
dependencies:
@@ -15028,6 +16304,27 @@ __metadata:
languageName: node
linkType: hard
+"tinypool@npm:^1.1.1":
+ version: 1.1.1
+ resolution: "tinypool@npm:1.1.1"
+ checksum: 10c0/bf26727d01443061b04fa863f571016950888ea994ba0cd8cba3a1c51e2458d84574341ab8dbc3664f1c3ab20885c8cf9ff1cc4b18201f04c2cde7d317fff69b
+ languageName: node
+ linkType: hard
+
+"tinyrainbow@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "tinyrainbow@npm:2.0.0"
+ checksum: 10c0/c83c52bef4e0ae7fb8ec6a722f70b5b6fa8d8be1c85792e829f56c0e1be94ab70b293c032dc5048d4d37cfe678f1f5babb04bdc65fd123098800148ca989184f
+ languageName: node
+ linkType: hard
+
+"tinyspy@npm:^4.0.3":
+ version: 4.0.6
+ resolution: "tinyspy@npm:4.0.6"
+ checksum: 10c0/1066760cea2a18ca69b9d934b73212ff5a45414387a67270b6944591a8afe59031e3d82b48cf7ca2d16b6c640d654e3a4a5726a63f2509aec3ef2d98f62ae5b8
+ languageName: node
+ linkType: hard
+
"tippy.js@npm:^6.3.7":
version: 6.3.7
resolution: "tippy.js@npm:6.3.7"
@@ -15444,6 +16741,13 @@ __metadata:
languageName: node
linkType: hard
+"undici-types@npm:~8.9.0":
+ version: 8.9.0
+ resolution: "undici-types@npm:8.9.0"
+ checksum: 10c0/51c712c20c087368f62c6fc0f2bc49fa76bfe5dec28ff01584bb677ff29b0622569783c02d43e151143808c48c0503f804093bd2c491b10f2a830138034cb451
+ languageName: node
+ linkType: hard
+
"undici@npm:^5.28.5":
version: 5.29.0
resolution: "undici@npm:5.29.0"
@@ -15902,6 +17206,21 @@ __metadata:
languageName: node
linkType: hard
+"vite-node@npm:3.2.4":
+ version: 3.2.4
+ resolution: "vite-node@npm:3.2.4"
+ dependencies:
+ cac: "npm:^6.7.14"
+ debug: "npm:^4.4.1"
+ es-module-lexer: "npm:^1.7.0"
+ pathe: "npm:^2.0.3"
+ vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ bin:
+ vite-node: vite-node.mjs
+ checksum: 10c0/6ceca67c002f8ef6397d58b9539f80f2b5d79e103a18367288b3f00a8ab55affa3d711d86d9112fce5a7fa658a212a087a005a045eb8f4758947dd99af2a6c6b
+ languageName: node
+ linkType: hard
+
"vite-node@npm:^3.1.2":
version: 3.1.3
resolution: "vite-node@npm:3.1.3"
@@ -16023,6 +17342,61 @@ __metadata:
languageName: node
linkType: hard
+"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0":
+ version: 7.3.6
+ resolution: "vite@npm:7.3.6"
+ dependencies:
+ esbuild: "npm:^0.27.0 || ^0.28.0"
+ fdir: "npm:^6.5.0"
+ fsevents: "npm:~2.3.3"
+ picomatch: "npm:^4.0.3"
+ postcss: "npm:^8.5.6"
+ rollup: "npm:^4.43.0"
+ tinyglobby: "npm:^0.2.15"
+ peerDependencies:
+ "@types/node": ^20.19.0 || >=22.12.0
+ jiti: ">=1.21.0"
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: ">=0.54.8"
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+ bin:
+ vite: bin/vite.js
+ checksum: 10c0/c6d359f84ad362f5c97ff7988b77c90add04162c60cdf6059375a7d9e3217848c53a8cb6b6c48517bef84b6bba4c9bc85319a889b5236acb7d5a2bc8cb7b9a8d
+ languageName: node
+ linkType: hard
+
"vite@npm:^5.0.0 || ^6.0.0, vite@npm:^6.3.4":
version: 6.3.5
resolution: "vite@npm:6.3.5"
@@ -16078,6 +17452,71 @@ __metadata:
languageName: node
linkType: hard
+"vitest-environment-nuxt@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "vitest-environment-nuxt@npm:1.0.1"
+ dependencies:
+ "@nuxt/test-utils": "npm:>=3.13.1"
+ checksum: 10c0/453e36926799fb9914a7087488d5ad7ac3974762fbbe576c5735edc984ab377d403fa374bbd6e17cc891a41a191d0339371cb725ee75e65f81a25e6759affa99
+ languageName: node
+ linkType: hard
+
+"vitest@npm:^3.2.7":
+ version: 3.2.7
+ resolution: "vitest@npm:3.2.7"
+ dependencies:
+ "@types/chai": "npm:^5.2.2"
+ "@vitest/expect": "npm:3.2.7"
+ "@vitest/mocker": "npm:3.2.7"
+ "@vitest/pretty-format": "npm:^3.2.7"
+ "@vitest/runner": "npm:3.2.7"
+ "@vitest/snapshot": "npm:3.2.7"
+ "@vitest/spy": "npm:3.2.7"
+ "@vitest/utils": "npm:3.2.7"
+ chai: "npm:^5.2.0"
+ debug: "npm:^4.4.1"
+ expect-type: "npm:^1.2.1"
+ magic-string: "npm:^0.30.17"
+ pathe: "npm:^2.0.3"
+ picomatch: "npm:^4.0.2"
+ std-env: "npm:^3.9.0"
+ tinybench: "npm:^2.9.0"
+ tinyexec: "npm:^0.3.2"
+ tinyglobby: "npm:^0.2.14"
+ tinypool: "npm:^1.1.1"
+ tinyrainbow: "npm:^2.0.0"
+ vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ vite-node: "npm:3.2.4"
+ why-is-node-running: "npm:^2.3.0"
+ peerDependencies:
+ "@edge-runtime/vm": "*"
+ "@types/debug": ^4.1.12
+ "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0
+ "@vitest/browser": 3.2.7
+ "@vitest/ui": 3.2.7
+ happy-dom: "*"
+ jsdom: "*"
+ peerDependenciesMeta:
+ "@edge-runtime/vm":
+ optional: true
+ "@types/debug":
+ optional: true
+ "@types/node":
+ optional: true
+ "@vitest/browser":
+ optional: true
+ "@vitest/ui":
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+ bin:
+ vitest: ./vitest.mjs
+ checksum: 10c0/4eb7a63a1d62b88c425bcbc609835055a5644a1994d6f47605fe396dddf732e3c0277c9ec89a028fe81557dc4051dd15fc15b9eba6a51d1466cd5c682ddc1261
+ languageName: node
+ linkType: hard
+
"vscode-uri@npm:^3.1.0":
version: 3.1.0
resolution: "vscode-uri@npm:3.1.0"
@@ -16113,6 +17552,13 @@ __metadata:
languageName: node
linkType: hard
+"vue-component-type-helpers@npm:^3.0.0":
+ version: 3.3.11
+ resolution: "vue-component-type-helpers@npm:3.3.11"
+ checksum: 10c0/2976679dd6489d469a00a1ccf62318c1d9e4f7da89399d82bca82a71d95ffb4a042b806f18662876f5053f4de2652103b59888f70a852c84c10658759b9e1863
+ languageName: node
+ linkType: hard
+
"vue-demi@npm:>=0.13.0, vue-demi@npm:>=0.14.8, vue-demi@npm:^0.14.6":
version: 0.14.10
resolution: "vue-demi@npm:0.14.10"
@@ -16226,6 +17672,13 @@ __metadata:
languageName: node
linkType: hard
+"whatwg-mimetype@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "whatwg-mimetype@npm:3.0.0"
+ checksum: 10c0/323895a1cda29a5fb0b9ca82831d2c316309fede0365047c4c323073e3239067a304a09a1f4b123b9532641ab604203f33a1403b5ca6a62ef405bcd7a204080f
+ languageName: node
+ linkType: hard
+
"whatwg-url@npm:^5.0.0":
version: 5.0.0
resolution: "whatwg-url@npm:5.0.0"
@@ -16370,6 +17823,18 @@ __metadata:
languageName: node
linkType: hard
+"why-is-node-running@npm:^2.3.0":
+ version: 2.3.0
+ resolution: "why-is-node-running@npm:2.3.0"
+ dependencies:
+ siginfo: "npm:^2.0.0"
+ stackback: "npm:0.0.2"
+ bin:
+ why-is-node-running: cli.js
+ checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054
+ languageName: node
+ linkType: hard
+
"wide-align@npm:^1.1.2":
version: 1.1.5
resolution: "wide-align@npm:1.1.5"
@@ -16736,6 +18201,21 @@ __metadata:
languageName: node
linkType: hard
+"ws@npm:^8.21.0":
+ version: 8.21.3
+ resolution: "ws@npm:8.21.3"
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ">=5.0.2"
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ checksum: 10c0/7b28dc2863ea0e2cece68d142a3eee90361021b73f750431e6d8076bb7dede5fdfdb75b3d29534b62f411261147b76b1bc80fb5cc63ab0aabd467280e85b22e0
+ languageName: node
+ linkType: hard
+
"xss@npm:^1.0.14":
version: 1.0.15
resolution: "xss@npm:1.0.15"