From 91a4132718ea808c66c3452da7646111adbe7cfe Mon Sep 17 00:00:00 2001
From: Luke Policinski
Date: Wed, 16 Sep 2026 13:32:57 -0400
Subject: [PATCH 01/17] chore: add vitest with @nuxt/test-utils
Specs live in tests/ and run inside the real Nuxt app; yarn test runs them
and CI runs them on pull requests.
---
.github/workflows/tests.yml | 45 +
package.json | 9 +
tests/setup.ts | 10 +
tests/smoke.spec.ts | 25 +
vitest.config.ts | 21 +
yarn.lock | 1538 ++++++++++++++++++++++++++++++++++-
6 files changed, 1619 insertions(+), 29 deletions(-)
create mode 100644 .github/workflows/tests.yml
create mode 100644 tests/setup.ts
create mode 100644 tests/smoke.spec.ts
create mode 100644 vitest.config.ts
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 000000000..07e4a2d38
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,45 @@
+name: tests
+
+on:
+ pull_request:
+ push:
+ branches:
+ - "main"
+ - "beta"
+ workflow_dispatch:
+
+concurrency:
+ group: tests-${{ github.head_ref || github.ref_name }}
+ cancel-in-progress: true
+
+jobs:
+ vitest:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Check out the repo
+ uses: actions/checkout@v5
+
+ # setup-node runs twice: its yarn cache probe needs Corepack first (see i18n.yml).
+ - name: Set up Node
+ uses: actions/setup-node@v5
+ with:
+ node-version-file: .nvmrc
+ package-manager-cache: false
+
+ - name: Enable Corepack
+ run: |
+ corepack enable
+ corepack prepare --activate
+
+ - name: Restore the yarn cache
+ uses: actions/setup-node@v5
+ with:
+ node-version-file: .nvmrc
+ cache: yarn
+
+ - name: Install dependencies
+ run: yarn install --immutable
+
+ - name: Run tests
+ run: yarn test
diff --git a/package.json b/package.json
index e685f595e..8ad48023b 100644
--- a/package.json
+++ b/package.json
@@ -11,12 +11,15 @@
"postinstall": "nuxt prepare",
"codegen": "bash -c '. ./.env && zeus https://$NUXT_PUBLIC_API_DOMAIN/v1/graphql ./generated --ts --td --header=x-hasura-admin-secret:$HASURA_GRAPHQL_ADMIN_SECRET && ./scripts/patch-zeus-codegen.sh'",
"check-translations": "node scripts/check-translations.js",
+ "test": "vitest run",
+ "test:watch": "vitest",
"wrangler": "wrangler --config cloudflare-workers/backblaze-proxy/wrangler.toml"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250508.0",
"@nuxt/devtools": "^2.4.0",
"@nuxt/image": "^1.10.0",
+ "@nuxt/test-utils": "^3.23.0",
"@nuxtjs/apollo": "5.0.0-alpha.16",
"@nuxtjs/i18n": "^9.5.4",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -25,8 +28,10 @@
"@types/simple-peer": "^9.11.8",
"@types/three": "^0.184.1",
"@vite-pwa/nuxt": "^1.0.0",
+ "@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.21",
"glob": "^11.0.2",
+ "happy-dom": "^20.0.11",
"nuxt": "^3.17.2",
"postcss": "^8.5.3",
"postcss-nesting": "^13.0.1",
@@ -34,6 +39,7 @@
"shadcn-nuxt": "^2.1.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
+ "vitest": "^3.2.7",
"vue": "^3.5.34",
"vue-router": "^4.5.1",
"workbox-core": "^7.3.0",
@@ -92,5 +98,8 @@
"vue-chartjs": "^5.3.2",
"zod": "^4.3.6"
},
+ "resolutions": {
+ "vitest-environment-nuxt/@nuxt/test-utils": "^3.23.0"
+ },
"packageManager": "yarn@4.11.0"
}
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/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"
From 7339bfc9257adb464db6ee5ece349684a795c5d7 Mon Sep 17 00:00:00 2001
From: Luke Policinski
Date: Wed, 16 Sep 2026 13:32:57 -0400
Subject: [PATCH 02/17] bug: render team links as real links
resolves NuxtLink from the component registry,
which this app never populates, so it rendered an unknown element.
Fixes 5stackgg/5stack-panel#608
---
components/MatchTableRow.vue | 9 +-
components/events/EventStandings.vue | 5 +-
components/events/EventTeamsPanel.vue | 3 +-
pages/matches/[id]/index.vue | 5 +-
tests/components/MatchTableRow.spec.ts | 77 +++++++++++++++
.../components/events/EventStandings.spec.ts | 97 +++++++++++++++++++
.../components/events/EventTeamsPanel.spec.ts | 32 ++++++
tests/guards/dynamicNuxtLink.spec.ts | 72 ++++++++++++++
8 files changed, 291 insertions(+), 9 deletions(-)
create mode 100644 tests/components/MatchTableRow.spec.ts
create mode 100644 tests/components/events/EventStandings.spec.ts
create mode 100644 tests/components/events/EventTeamsPanel.spec.ts
create mode 100644 tests/guards/dynamicNuxtLink.spec.ts
diff --git a/components/MatchTableRow.vue b/components/MatchTableRow.vue
index e70a831de..7024f5514 100644
--- a/components/MatchTableRow.vue
+++ b/components/MatchTableRow.vue
@@ -10,6 +10,7 @@ import {
UsersIcon,
X,
} from "lucide-vue-next";
+import { NuxtLink } from "#components";
import TimeAgo from "~/components/TimeAgo.vue";
import { e_match_status_enum } from "~/generated/zeus";
import mapLabel from "~/utilities/mapLabel";
@@ -270,7 +271,7 @@ import MatchOverviewDrawer from "~/components/match/MatchOverviewDrawer.vue";
:title="match.lineup_1.name"
>
(() => {
size="sm"
/>
+import { NuxtLink } from "#components";
import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
import Empty from "~/components/ui/empty/Empty.vue";
import PlayerDisplay from "~/components/PlayerDisplay.vue";
@@ -53,7 +54,7 @@ function initials(team: { name: string; short_name?: string | null }): string {
class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{
+ 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/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/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([]);
+ });
+});
From 17090c898104dc0c12f16d2b7d14e18adfb8acf5 Mon Sep 17 00:00:00 2001
From: Luke Policinski
Date: Wed, 16 Sep 2026 13:32:58 -0400
Subject: [PATCH 03/17] bug: feature upcoming events and query each events
section separately
Fixes 5stackgg/5stack-panel#607
---
components/events/EventHero.vue | 33 ++-
components/events/EventSquare.vue | 13 +-
pages/events/index.vue | 254 ++++++++++++--------
tests/components/events/EventHero.spec.ts | 68 ++++++
tests/components/events/EventSquare.spec.ts | 57 +++++
tests/helpers/fakeHasura.ts | 124 ++++++++++
tests/pages/events/index.spec.ts | 195 +++++++++++++++
tests/utilities/eventDisplay.spec.ts | 63 +++++
utilities/eventDisplay.ts | 15 ++
9 files changed, 710 insertions(+), 112 deletions(-)
create mode 100644 tests/components/events/EventHero.spec.ts
create mode 100644 tests/components/events/EventSquare.spec.ts
create mode 100644 tests/helpers/fakeHasura.ts
create mode 100644 tests/pages/events/index.spec.ts
create mode 100644 tests/utilities/eventDisplay.spec.ts
diff --git a/components/events/EventHero.vue b/components/events/EventHero.vue
index 5af21131c..f77b62f61 100644
--- a/components/events/EventHero.vue
+++ b/components/events/EventHero.vue
@@ -1,5 +1,5 @@
@@ -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.show_more") }}
+
+
-
{{ $t("pages.events.upcoming") }}
-
-
+
+
+
+ {{ $t("pages.events.show_more") }}
+
+
-
-
-
- {{ $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/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/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/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/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/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" {
From faa7f1f16c3c04ccd11f9dc12861a028b0f2df74 Mon Sep 17 00:00:00 2001
From: Luke Policinski
Date: Wed, 16 Sep 2026 13:32:58 -0400
Subject: [PATCH 04/17] bug: let granters award themselves and show manual team
awards
Fixes 5stackgg/5stack-panel#609
Fixes 5stackgg/5stack-panel#610
---
components/TeamsTable.vue | 81 ++++--
components/award/AwardCase.vue | 24 +-
components/award/AwardComposer.vue | 1 +
components/award/AwardModal.vue | 24 +-
pages/teams/[id].vue | 6 +-
pages/teams/index.vue | 21 +-
tests/components/TeamsTable.spec.ts | 206 ++++++++++++++
tests/components/award/AwardCase.spec.ts | 71 +++++
tests/components/award/AwardComposer.spec.ts | 115 ++++++++
tests/components/award/AwardModal.spec.ts | 69 +++++
tests/pages/teams/teamAwards.spec.ts | 267 +++++++++++++++++++
11 files changed, 838 insertions(+), 47 deletions(-)
create mode 100644 tests/components/TeamsTable.spec.ts
create mode 100644 tests/components/award/AwardCase.spec.ts
create mode 100644 tests/components/award/AwardComposer.spec.ts
create mode 100644 tests/components/award/AwardModal.spec.ts
create mode 100644 tests/pages/teams/teamAwards.spec.ts
diff --git a/components/TeamsTable.vue b/components/TeamsTable.vue
index 2c780ed7e..7a97dc428 100644
--- a/components/TeamsTable.vue
+++ b/components/TeamsTable.vue
@@ -139,7 +139,7 @@ import { resolveRosterImageUrl } from "~/utilities/rosterImage";
type="button"
class="rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--tac-amber))]"
:aria-label="awardAriaLabel(grant)"
- @click.stop.prevent="goToTournament(grant.tournament_id)"
+ @click.stop.prevent="openAward(grant)"
>
- {{
- grant.tournament_award?.custom_name ||
- grant.tournament?.name ||
- "Tournament"
- }}
+ {{ awardName(grant) }}
{{ placementLabel(grant.placement) }}
- ·
-
- {{ formatAwardDate(grant.tournament.start) }}
+ ·
+
+ {{ formatAwardDate(awardDate(grant)) }}
- {{ $t("ui.click_to_view_tournament") }}
+ {{
+ linksToTournament(grant)
+ ? $t("ui.click_to_view_tournament")
+ : $t("ui.click_to_view_award")
+ }}
@@ -224,6 +222,7 @@ interface AwardEntry {
id: string;
placement?: number | null;
placement_tier?: string | null;
+ source?: string | null;
tournament_id?: string | null;
created_at?: string | null;
award?: {
@@ -289,12 +288,8 @@ export default {
recentAwards(team: { id: string }): AwardEntry[] {
return [...this.teamAwards(team)]
.sort((a, b) => {
- const da = a.tournament?.start
- ? new Date(a.tournament.start).getTime()
- : 0;
- const db = b.tournament?.start
- ? new Date(b.tournament.start).getTime()
- : 0;
+ const da = new Date(this.awardDate(a) || 0).getTime();
+ const db = new Date(this.awardDate(b) || 0).getTime();
return db - da;
})
.slice(0, 5);
@@ -302,14 +297,17 @@ export default {
extraAwards(team: { id: string }): number {
return Math.max(0, this.teamAwards(team).length - 5);
},
- placementLabel(placement: number): string {
+ placementLabel(placement?: number | null): string {
+ if (placement === null || placement === undefined) {
+ return this.$t("awards.granted");
+ }
if (placement === 0) return "MVP";
if (placement === 1) return "1st Place";
if (placement === 2) return "2nd Place";
if (placement === 3) return "3rd Place";
return `#${placement}`;
},
- placementColor(placement: number): string {
+ placementColor(placement?: number | null): string {
if (placement === 0) return "hsl(195 85% 60%)";
if (placement === 1) return "hsl(45 95% 60%)";
if (placement === 2) return "hsl(0 0% 78%)";
@@ -328,16 +326,45 @@ export default {
})
.toUpperCase();
},
- awardAriaLabel(grant: AwardEntry): string {
- const name =
+ isManual(grant: AwardEntry): boolean {
+ return grant.source === "manual";
+ },
+ linksToTournament(grant: AwardEntry): boolean {
+ return !!grant.tournament_id && !this.isManual(grant);
+ },
+ awardName(grant: AwardEntry): string {
+ if (this.isManual(grant)) {
+ return (
+ grant.tournament_award?.custom_name ||
+ grant.award?.name ||
+ grant.tournament?.name ||
+ ""
+ );
+ }
+ return (
grant.tournament_award?.custom_name ||
grant.tournament?.name ||
- "tournament";
- return `${this.placementLabel(grant.placement)} — ${name}`;
+ grant.award?.name ||
+ ""
+ );
+ },
+ awardDate(grant: AwardEntry): string | null {
+ if (this.isManual(grant)) {
+ return grant.created_at || null;
+ }
+ return grant.tournament?.start || grant.created_at || null;
},
- goToTournament(tournamentId?: string | null) {
- if (!tournamentId) return;
- this.$router.push(`/tournaments/${tournamentId}`);
+ awardAriaLabel(grant: AwardEntry): string {
+ return `${this.placementLabel(grant.placement)} — ${this.awardName(grant)}`;
+ },
+ openAward(grant: AwardEntry) {
+ if (this.linksToTournament(grant)) {
+ this.$router.push(`/tournaments/${grant.tournament_id}`);
+ return;
+ }
+ if (grant.award?.id) {
+ this.$router.push(`/awards/${grant.award.id}`);
+ }
},
topStarters(team: { roster?: RosterEntry[] }): RosterEntry[] {
const roster = team.roster ?? [];
diff --git a/components/award/AwardCase.vue b/components/award/AwardCase.vue
index d0f9cfcd5..670311e0c 100644
--- a/components/award/AwardCase.vue
+++ b/components/award/AwardCase.vue
@@ -9,6 +9,7 @@ interface AwardGrant {
id: string;
placement?: number | null;
placement_tier?: string | null;
+ source?: string | null;
tournament_id?: string | null;
team_id?: string | null;
note?: string | null;
@@ -67,6 +68,14 @@ function grantTier(grant: AwardGrant): AwardTier {
}
function grantName(grant: AwardGrant): string {
+ if (grant.source === "manual") {
+ return (
+ grant.tournament_award?.custom_name ||
+ grant.award?.name ||
+ grant.tournament?.name ||
+ ""
+ );
+ }
return (
grant.tournament_award?.custom_name ||
grant.tournament?.name ||
@@ -75,6 +84,13 @@ function grantName(grant: AwardGrant): string {
);
}
+function grantDate(grant: AwardGrant): string | null {
+ if (grant.source === "manual") {
+ return grant.created_at || null;
+ }
+ return grant.tournament?.start || grant.created_at || null;
+}
+
// Placement medals lead, ordered mvp -> bronze; standalone awards trail them
// newest-first so a long tournament history never buries a fresh grant.
const TIER_ORDER: Record = {
@@ -91,8 +107,8 @@ const sorted = computed(() => {
const ta = TIER_ORDER[grantTier(a)];
const tb = TIER_ORDER[grantTier(b)];
if (ta !== tb) return ta - tb;
- const da = new Date(a.tournament?.start || a.created_at || 0).getTime();
- const db = new Date(b.tournament?.start || b.created_at || 0).getTime();
+ const da = new Date(grantDate(a) || 0).getTime();
+ const db = new Date(grantDate(b) || 0).getTime();
return db - da;
});
});
@@ -291,11 +307,11 @@ const tierRack = computed(() => {
{{ grantName(grant) }}
- {{ formatAwardDate(grant.tournament?.start || grant.created_at) }}
+ {{ formatAwardDate(grantDate(grant)) }}
diff --git a/components/award/AwardComposer.vue b/components/award/AwardComposer.vue
index 35cfbf87d..ae636c06c 100644
--- a/components/award/AwardComposer.vue
+++ b/components/award/AwardComposer.vue
@@ -681,6 +681,7 @@ async function submit() {
(recipient.player = p)"
/>
diff --git a/components/award/AwardModal.vue b/components/award/AwardModal.vue
index c410cbae5..47e811972 100644
--- a/components/award/AwardModal.vue
+++ b/components/award/AwardModal.vue
@@ -19,6 +19,7 @@ interface AwardGrant {
id: string;
placement?: number | null;
placement_tier?: string | null;
+ source?: string | null;
tournament_id?: string | null;
team_id?: string | null;
note?: string | null;
@@ -91,8 +92,12 @@ const placementLabelKey = computed(() => {
const tierColor = computed(() => TIER_COLORS[tier.value]);
+const manual = computed(() => props.award.source === "manual");
+
const formattedDate = computed(() => {
- const iso = props.award.tournament?.start || props.award.created_at;
+ const iso = manual.value
+ ? props.award.created_at
+ : props.award.tournament?.start || props.award.created_at;
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
@@ -110,13 +115,22 @@ const tournamentType = computed(
() => props.award.tournament?.stages?.[0]?.type || null,
);
-const title = computed(
- () =>
+const title = computed(() => {
+ if (manual.value) {
+ return (
+ props.award.tournament_award?.custom_name ||
+ props.award.award?.name ||
+ tournamentName.value ||
+ ""
+ );
+ }
+ return (
props.award.tournament_award?.custom_name ||
tournamentName.value ||
props.award.award?.name ||
- "",
-);
+ ""
+ );
+});
const awardTeam = computed(() => {
const team = props.award.team || props.award.tournament_team?.team || null;
diff --git a/pages/teams/[id].vue b/pages/teams/[id].vue
index 20b4a0554..ed14dbcab 100644
--- a/pages/teams/[id].vue
+++ b/pages/teams/[id].vue
@@ -595,10 +595,8 @@ export default {
player_steam_id: {
_is_null: true,
},
- tournament_team: {
- team_id: {
- _eq: $("teamId", "uuid!"),
- },
+ team_id: {
+ _eq: $("teamId", "uuid!"),
},
},
},
diff --git a/pages/teams/index.vue b/pages/teams/index.vue
index d48beb3a2..978484f52 100644
--- a/pages/teams/index.vue
+++ b/pages/teams/index.vue
@@ -229,14 +229,21 @@ export default {
awardsByTeamId(): Record {
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/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/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]);
+ });
+});
From 480254ed043c8bc3daf7392dffd9bf3058e42f79 Mon Sep 17 00:00:00 2001
From: Luke Policinski
Date: Wed, 16 Sep 2026 13:32:58 -0400
Subject: [PATCH 05/17] bug: offer app install on android phones
Fixes 5stackgg/5stack-panel#515
---
components/InstallPWA.vue | 76 ++--------
components/InstallPWADrawer.vue | 94 +++++++++++++
composables/usePwaInstall.ts | 64 ++++++++-
layouts/components/ProfileMenu.vue | 33 ++++-
layouts/components/TopNav.vue | 1 +
tests/components/InstallPWA.spec.ts | 115 ++++++++++++++++
tests/composables/usePwaInstall.spec.ts | 130 ++++++++++++++++++
tests/helpers/pwaDevice.ts | 79 +++++++++++
tests/layouts/components/ProfileMenu.spec.ts | 95 +++++++++++++
tests/layouts/components/TopNav.spec.ts | 114 +++++++++++++++
.../settings/notification-preferences.spec.ts | 102 ++++++++++++++
11 files changed, 832 insertions(+), 71 deletions(-)
create mode 100644 components/InstallPWADrawer.vue
create mode 100644 tests/components/InstallPWA.spec.ts
create mode 100644 tests/composables/usePwaInstall.spec.ts
create mode 100644 tests/helpers/pwaDevice.ts
create mode 100644 tests/layouts/components/ProfileMenu.spec.ts
create mode 100644 tests/layouts/components/TopNav.spec.ts
create mode 100644 tests/pages/settings/notification-preferences.spec.ts
diff --git a/components/InstallPWA.vue b/components/InstallPWA.vue
index a922473cb..f62b24f7e 100644
--- a/components/InstallPWA.vue
+++ b/components/InstallPWA.vue
@@ -1,9 +1,9 @@
@@ -48,7 +36,7 @@ async function installPWA() {
:class="{ 'mx-4': isMobile || state === 'expanded' }"
>
-
+
{{
$t("pwa.install.button")
@@ -60,7 +48,7 @@ async function installPWA() {
-
+
@@ -68,54 +56,14 @@ async function installPWA() {
-
+
{{ $t("pwa.install.button") }}
-
-
-
-
- {{ $t("pwa.install.title") }}
-
-
- {{ $t("common.cancel") }}
-
-
-
-
-
- {{ $t("pwa.install.description") }}
-
-
-
-
-
-
-
-
- 1)
- {{ $t("pwa.install.step1") }}
-
-
-
-
-
- 2)
- {{ $t("pwa.install.step2") }}
-
-
-
-
-
+
diff --git a/components/InstallPWADrawer.vue b/components/InstallPWADrawer.vue
new file mode 100644
index 000000000..f71bc1bd3
--- /dev/null
+++ b/components/InstallPWADrawer.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+ {{ $t("pwa.install.title") }}
+
+
+ {{ $t("common.cancel") }}
+
+
+
+
+
+ {{
+ platform === "in_app"
+ ? $t("pwa.install.in_app.description")
+ : $t("pwa.install.description")
+ }}
+
+
+
+
+
+
+
+
+ {{ index + 1 }})
+ {{ $t(step.label) }}
+
+
+
+
+
+
+ {{ $t("pwa.install.android.already_installed") }}
+
+
+
+
diff --git a/composables/usePwaInstall.ts b/composables/usePwaInstall.ts
index 29a9ea867..27889cfe0 100644
--- a/composables/usePwaInstall.ts
+++ b/composables/usePwaInstall.ts
@@ -4,6 +4,8 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
// notifications are now gated on it everywhere -- the settings toggle, the
// enable prompt, and the install button itself all have to agree.
+export type ManualInstallPlatform = "ios" | "android" | "in_app";
+
export function isIosBrowser(): boolean {
return (
typeof navigator !== "undefined" &&
@@ -11,6 +13,17 @@ export function isIosBrowser(): boolean {
);
}
+export function isAndroidBrowser(): boolean {
+ return (
+ typeof navigator !== "undefined" && /Android/.test(navigator.userAgent)
+ );
+}
+
+// "; wv)" marks Android's WebView (in-app browsers), where nothing can install.
+export function isAndroidWebView(): boolean {
+ return isAndroidBrowser() && /; wv\)/.test(navigator.userAgent);
+}
+
// $pwa.isPWAInstalled only checks the display-mode media query, which iOS
// Safari never sets -- navigator.standalone is the only honest answer there.
export function isStandalone(): boolean {
@@ -32,30 +45,67 @@ export function usePwaInstall() {
// `appinstalled` is the only signal this side ever gets, and it matters --
// the tab and the installed app share one service worker registration, so a
// subscription made here is the same one the app will use.
- const installedHere = ref(false);
+ const installedHere = useState("pwa-installed-here", () => false);
+
+ const showInstructions = ref(false);
const installed = computed(
() =>
installedHere.value || Boolean($pwa?.isPWAInstalled) || isStandalone(),
);
- const canInstall = computed(() => {
+ const hasNativePrompt = computed(
+ () => !installed.value && $pwa?.showInstallPrompt === true,
+ );
+
+ const manualInstall = computed(() => {
if (installed.value) {
- return false;
+ return null;
}
// iOS has no beforeinstallprompt at all -- Add to Home Screen is manual,
// so the instructions are always worth offering.
if (isIosBrowser()) {
- return true;
+ return "ios";
}
// beforeinstallprompt fires once, shortly after load, usually before a
// component that wanted to listen for it has mounted. The vite-pwa plugin
// captures it at app init and exposes it reactively.
- return $pwa?.showInstallPrompt === true;
+ if (hasNativePrompt.value) {
+ return null;
+ }
+
+ // Android falls back to browser-menu steps; desktop deliberately gets none.
+ if (isAndroidWebView()) {
+ return "in_app";
+ }
+
+ if (isAndroidBrowser()) {
+ return "android";
+ }
+
+ return null;
});
+ const canInstall = computed(
+ () => hasNativePrompt.value || manualInstall.value !== null,
+ );
+
+ async function install() {
+ if (manualInstall.value) {
+ showInstructions.value = true;
+ return;
+ }
+
+ const choice = await $pwa?.install();
+
+ // Don't wait for appinstalled: it lands after Android builds the app.
+ if (choice?.outcome === "accepted") {
+ installedHere.value = true;
+ }
+ }
+
function onAppInstalled() {
installedHere.value = true;
}
@@ -71,6 +121,8 @@ export function usePwaInstall() {
return {
installed,
canInstall,
- install: () => $pwa?.install(),
+ manualInstall,
+ showInstructions,
+ install,
};
}
diff --git a/layouts/components/ProfileMenu.vue b/layouts/components/ProfileMenu.vue
index 5412c768a..28b19d3fc 100644
--- a/layouts/components/ProfileMenu.vue
+++ b/layouts/components/ProfileMenu.vue
@@ -1,6 +1,12 @@
@@ -405,33 +399,16 @@ function formatTournamentStatus(status?: string | null): string {
-
-
+
-
-
- {{ formatTournamentStatus(entry.tournament.status) }}
-
-
- {{ formatEventDate(entry.tournament.start) }}
-
-
-
- {{ entry.tournament.name }}
-
-
+ :tournament="entry.tournament"
+ :status-variant="
+ tournamentStatusVariant(entry.tournament.status)
+ "
+ :priority="index === 0"
+ />
@@ -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: [
{},
{
From f1fa5169d741a80720b0469e335fa9f1678b92d6 Mon Sep 17 00:00:00 2001
From: Luke Policinski
Date: Thu, 17 Sep 2026 18:37:21 -0400
Subject: [PATCH 13/17] bug: make a far-off check-in countdown readable
The readout is built for a fifteen-minute match check-in, where H:MM:SS
always reads as a clock. Reused for a tournament check-in window that can
open weeks out, it degenerated into "1390:29:38". Past a day it now shows
days and hours, ticks once a minute instead of once a second, and follows
the hour rather than the minute for the rollover. Under a day it is the
exact clock as before, and the ticker goes back to 1s on the crossing.
The heading had the same problem: "Check-in opens at 04:00 PM" for a window
two months out reads as today. It now carries the date when the window is
not today, and the year when it is not this year. required_window keeps the
clock -- "between X and Y on the day" is describing the window's shape.
Co-Authored-By: Claude Opus 5 (1M context)
---
components/match/CheckInDeadline.vue | 59 ++++++++++++++++++-
.../tournament/TournamentCheckInPanel.vue | 44 ++++++++++++--
2 files changed, 94 insertions(+), 9 deletions(-)
diff --git a/components/match/CheckInDeadline.vue b/components/match/CheckInDeadline.vue
index 3b1cbfc4a..ab068e10c 100644
--- a/components/match/CheckInDeadline.vue
+++ b/components/match/CheckInDeadline.vue
@@ -1,6 +1,7 @@
@@ -45,11 +39,7 @@ const fallbackGradient = computed(() => {
muted
playsinline
/>
-
+
{
+