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 @@ @@ -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() { 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 @@ 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 @@ + + + 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 @@