diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 0c62487c..42ce05f9 100644 --- a/.github/workflows/node-test.yml +++ b/.github/workflows/node-test.yml @@ -51,6 +51,7 @@ jobs: CYPRESS_INSTALL_BINARY: 0 run: | npm ci + npx playwright install chromium npm run build --if-present - name: Test diff --git a/.gitignore b/.gitignore index 65583bb1..2677cd58 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ pids lib-cov # Coverage directory used by tools like istanbul +.vitest* +__screenshots__/ coverage # nyc test coverage diff --git a/REUSE.toml b/REUSE.toml index be4feaf5..9fc658e7 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -6,13 +6,7 @@ SPDX-PackageSupplier = "Nextcloud " SPDX-PackageDownloadLocation = "https://github.com/nextcloud-libraries/nextcloud-axios" [[annotations]] -path = ["package-lock.json", "package.json", "tsconfig.json"] +path = ["package-lock.json", "package.json", "tsconfig.json", "tests/tsconfig.json"] precedence = "aggregate" SPDX-FileCopyrightText = "2018-2024 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "GPL-3.0-or-later" - -[[annotations]] -path = ".eslintrc.json" -precedence = "aggregate" -SPDX-FileCopyrightText = "2023 Nextcloud GmbH and Nextcloud contributors" -SPDX-License-Identifier = "GPL-3.0-or-later" diff --git a/lib/client.ts b/lib/client.ts index c2b6280d..1a6715c0 100644 --- a/lib/client.ts +++ b/lib/client.ts @@ -16,18 +16,23 @@ export interface CancelableAxiosInstance extends AxiosInstance { isCancel: typeof Axios.isCancel } -const client = Axios.create({ - headers: { - requesttoken: getRequestToken() ?? '', - 'X-Requested-With': 'XMLHttpRequest', - }, -}) +/** + * Get an Axios instance with default Nextcloud headers and CSRF token handling. + */ +export function getCancelableClient(): CancelableAxiosInstance { + const client = Axios.create({ + headers: { + requesttoken: getRequestToken() ?? '', + 'X-Requested-With': 'XMLHttpRequest', + }, + }) -onRequestTokenUpdate((token: string) => { - client.defaults.headers.requesttoken = token -}) + onRequestTokenUpdate((token: string) => { + client.defaults.headers.requesttoken = token + }) -export const cancelableClient: CancelableAxiosInstance = Object.assign(client, { - CancelToken: Axios.CancelToken, - isCancel: Axios.isCancel, -}) + return Object.assign(client, { + CancelToken: Axios.CancelToken, + isCancel: Axios.isCancel, + }) +} diff --git a/lib/index.ts b/lib/index.ts index b6b3ad1f..aae1c221 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { cancelableClient } from './client.ts' +import { getCancelableClient } from './client.ts' import { onCsrfTokenError } from './interceptors/csrf-token.ts' import { onMaintenanceModeError } from './interceptors/maintenance-mode.ts' import { onNotLoggedInError } from './interceptors/not-logged-in.ts' +const cancelableClient = getCancelableClient() cancelableClient.interceptors.response.use((r) => r, onCsrfTokenError(cancelableClient)) cancelableClient.interceptors.response.use((r) => r, onMaintenanceModeError(cancelableClient)) cancelableClient.interceptors.response.use((r) => r, onNotLoggedInError) diff --git a/lib/interceptors/csrf-token.ts b/lib/interceptors/csrf-token.ts index 005b2954..3c76f8b9 100644 --- a/lib/interceptors/csrf-token.ts +++ b/lib/interceptors/csrf-token.ts @@ -9,7 +9,7 @@ import type { InterceptorErrorHandler } from './index.ts' import { generateUrl } from '@nextcloud/router' import { isAxiosError } from 'axios' -const RETRY_KEY = Symbol('csrf-retry') +const RETRY_KEY = '_nextcloudCsrfTokenReloaded' /** * Handle CSRF token errors in Axios requests. @@ -26,22 +26,21 @@ export function onCsrfTokenError(axios: CancelableAxiosInstance): InterceptorErr const responseURL = request?.responseURL if (config - && !config[RETRY_KEY] + && !(RETRY_KEY in config) && response?.status === 412 && response?.data?.message === 'CSRF check failed') { - console.warn(`Request to ${responseURL} failed because of a CSRF mismatch. Fetching a new token`) + console.warn(`Request to ${responseURL} failed because of a CSRF mismatch. Fetching a new token.`) const { data: { token } } = await axios.get(generateUrl('/csrftoken')) - console.debug(`New request token ${token} fetched`) axios.defaults.headers.requesttoken = token return axios({ ...config, + [RETRY_KEY]: true, headers: { ...config.headers, requesttoken: token, }, - [RETRY_KEY]: true, }) } diff --git a/lib/interceptors/maintenance-mode.ts b/lib/interceptors/maintenance-mode.ts index 511fa67b..e53ecbcb 100644 --- a/lib/interceptors/maintenance-mode.ts +++ b/lib/interceptors/maintenance-mode.ts @@ -8,7 +8,7 @@ import type { InterceptorErrorHandler } from './index.ts' import { isAxiosError } from 'axios' -export const RETRY_DELAY_KEY = Symbol('retryDelay') +const RETRY_DELAY_KEY = '_nextcloudMaintenanceModeRetryDelay' /** * Handles Nextcloud maintenance mode errors in Axios requests. @@ -25,9 +25,7 @@ export function onMaintenanceModeError(axios: CancelableAxiosInstance): Intercep const responseURL = request?.responseURL const status = response?.status const headers = response?.headers - let retryDelay = typeof config?.[RETRY_DELAY_KEY] === 'number' - ? config?.[RETRY_DELAY_KEY] - : 1 + let retryDelay = config?.[RETRY_DELAY_KEY] ?? 1 /** * Retry requests if they failed due to maintenance mode diff --git a/lib/interceptors/not-logged-in.ts b/lib/interceptors/not-logged-in.ts index 3bdd3d3b..93fdba59 100644 --- a/lib/interceptors/not-logged-in.ts +++ b/lib/interceptors/not-logged-in.ts @@ -21,10 +21,13 @@ export async function onNotLoggedInError(error: unknown) { if (status === 401 && response?.data?.message === 'Current user is not logged in' && config?.reloadExpiredSession - && window?.location) { + && globalThis.location?.reload) { console.error(`Request to ${responseURL} failed because the user session expired. Reloading the page …`) - - window.location.reload() + if (globalThis.OC?.reload) { + globalThis.OC.reload() + } else { + globalThis.location.reload() + } } } diff --git a/lib/axios.d.ts b/lib/internal.d.ts similarity index 71% rename from lib/axios.d.ts rename to lib/internal.d.ts index 83191bf9..a6162da9 100644 --- a/lib/axios.d.ts +++ b/lib/internal.d.ts @@ -9,8 +9,16 @@ declare module 'axios' { // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any -- needed as we extend the interface only. interface AxiosRequestConfig { - [key: symbol]: unknown + _nextcloudCsrfTokenReloaded?: true + _nextcloudMaintenanceModeRetryDelay?: number } } +declare global { + var OC: { + /** NC 32 and before */ + reload?: () => void + } | undefined +} + export {} diff --git a/package-lock.json b/package-lock.json index 54a24fef..8809aa90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,9 +19,10 @@ "@nextcloud/event-bus": "^3.3.3", "@nextcloud/vite-config": "^2.5.2", "@types/node": "^25.6.0", + "@vitest/browser-playwright": "^4.1.4", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", - "happy-dom": "^20.8.9", + "msw": "^2.13.3", "typescript": "^6.0.2", "vite": "^7.3.2", "vitest": "^4.1.4" @@ -90,6 +91,13 @@ "node": ">=18" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.86.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", @@ -793,6 +801,94 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -984,6 +1080,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nextcloud/auth": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-2.6.0.tgz", @@ -1136,6 +1250,31 @@ "vite": "^7.1.10" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -1460,6 +1599,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.29", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", @@ -2105,23 +2251,13 @@ "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", "license": "MIT" }, - "node_modules/@types/whatwg-mimetype": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", - "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "dev": true, "license": "MIT" }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", @@ -2382,6 +2518,53 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/browser": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.4.tgz", + "integrity": "sha512-TrNaY/yVOwxtrxNsDUC/wQ56xSwplpytTeRAqF/197xV/ZddxxulBsxR6TrhVMyniJmp9in8d5u0AcDaNRY30w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.4" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.4.tgz", + "integrity": "sha512-q3PchVhZINX23Pv+RERgAtDlp6wzVkID/smOPnZ5YGWpeWUe3jMNYppeVh15j4il3G7JIJty1d1Kicpm0HSMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.4", + "@vitest/mocker": "4.1.4", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.4" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", @@ -2831,6 +3014,32 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/are-docs-informative": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", @@ -3396,6 +3605,69 @@ "node": ">= 0.10" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3459,6 +3731,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-js-compat": { "version": "3.44.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz", @@ -3802,6 +4088,13 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -4559,6 +4852,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4651,35 +4954,14 @@ "dev": true, "license": "ISC" }, - "node_modules/happy-dom": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", - "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", "dev": true, "license": "MIT", - "dependencies": { - "@types/node": ">=20.0.0", - "@types/whatwg-mimetype": "^3.0.2", - "@types/ws": "^8.18.1", - "entities": "^7.0.1", - "whatwg-mimetype": "^3.0.0", - "ws": "^8.18.3" - }, "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/happy-dom/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, "node_modules/has-flag": { @@ -4779,6 +5061,13 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -4944,6 +5233,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -4993,6 +5292,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5463,6 +5769,16 @@ "node": "*" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5470,6 +5786,51 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.3.tgz", + "integrity": "sha512-/F49bxavkNGfreMlrKmTxZs6YorjfMbbDLd89Q3pWi+cXGtQQNXXaHt4MkXN7li91xnQJ24HWXqW9QDm5id33w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.7", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -5477,6 +5838,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5692,6 +6063,13 @@ "dev": true, "license": "MIT" }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5829,6 +6207,13 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5933,6 +6318,66 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6147,6 +6592,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6191,6 +6646,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rettime": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.7.tgz", + "integrity": "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==", + "dev": true, + "license": "MIT" + }, "node_modules/ripemd160": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", @@ -6549,6 +7011,34 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sort-object-keys": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-2.1.0.tgz", @@ -6711,6 +7201,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -6742,6 +7242,13 @@ "xtend": "^4.0.2" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6762,6 +7269,34 @@ "node": ">=0.6.19" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6814,6 +7349,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -6871,6 +7419,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", @@ -6918,6 +7486,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6951,6 +7542,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -7028,6 +7635,16 @@ "node": ">= 10.0.0" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -7876,16 +8493,6 @@ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7951,6 +8558,21 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -7993,6 +8615,16 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -8000,6 +8632,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8012,6 +8673,19 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 02be509e..fce011f2 100644 --- a/package.json +++ b/package.json @@ -58,9 +58,10 @@ "@nextcloud/event-bus": "^3.3.3", "@nextcloud/vite-config": "^2.5.2", "@types/node": "^25.6.0", + "@vitest/browser-playwright": "^4.1.4", "@vitest/coverage-v8": "^4.1.4", "eslint": "^10.2.0", - "happy-dom": "^20.8.9", + "msw": "^2.13.3", "typescript": "^6.0.2", "vite": "^7.3.2", "vitest": "^4.1.4" diff --git a/test/interceptors/csrf-token.spec.ts b/test/interceptors/csrf-token.spec.ts deleted file mode 100644 index cb9dda19..00000000 --- a/test/interceptors/csrf-token.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios' - -import { AxiosError } from 'axios' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { cancelableClient } from '../../lib/client.ts' -import { onCsrfTokenError } from '../../lib/interceptors/csrf-token.ts' - -describe('CSRF token', () => { - const axiosMock = vi.mockObject(cancelableClient) - const consoleWarn = vi.spyOn(window.console, 'warn') - const consoleDebug = vi.spyOn(window.console, 'debug') - - beforeEach(() => { - vi.resetAllMocks() - consoleWarn.mockImplementation(() => {}) - consoleDebug.mockImplementation(() => {}) - }) - - it('does retry', async () => { - axiosMock.get.mockImplementationOnce(async () => ({ - status: 200, - data: { - token: '123', - }, - } as AxiosResponse)) - - const interceptor = onCsrfTokenError(axiosMock) - await expect(interceptor(mockAxiosError({ message: 'CSRF check failed' }))).resolves.not.toThrowError() - expect(axiosMock.get).toHaveBeenCalled() - expect(axiosMock.defaults.headers.requesttoken).toBe('123') - expect(consoleDebug).toHaveBeenCalledWith('New request token 123 fetched') - }) - - it('does not retry if wrong message is returned', async () => { - const interceptor = onCsrfTokenError(axiosMock) - await expect(() => interceptor(mockAxiosError('wrong data'))).rejects.toThrowError() - expect(axiosMock.get).not.toHaveBeenCalled() - expect(consoleDebug).not.toHaveBeenCalled() - }) - - it('does not retry with unrelated error', async () => { - const interceptor = onCsrfTokenError(axiosMock) - await expect(() => interceptor(new AxiosError('Unauthorized', AxiosError.ERR_BAD_REQUEST))).rejects.toThrowError() - expect(axiosMock.get).not.toHaveBeenCalled() - expect(consoleDebug).not.toHaveBeenCalled() - }) - - it('does not retry multiple times', async () => { - axiosMock.get.mockImplementationOnce(async (url, config) => { - throw mockAxiosError({ message: 'CSRF check failed' }, config) - }) - - const interceptor = onCsrfTokenError(axiosMock) - await expect(interceptor(mockAxiosError({ message: 'CSRF check failed' }))).rejects.toThrowError() - expect(axiosMock.get).toHaveBeenCalledOnce() - expect(consoleDebug).not.toHaveBeenCalled() - }) -}) - -/** - * @param data - The data to be returned in the error response - * @param config - The Axios request configuration - */ -function mockAxiosError(data = {}, config = {}) { - return new AxiosError( - 'Unauthorized', - AxiosError.ERR_BAD_REQUEST, - config as InternalAxiosRequestConfig, - { - responseURL: '/some/url', - }, - { - config: config as InternalAxiosRequestConfig, - headers: {}, - data, - status: 412, - statusText: 'Precondition Failed', - }, - ) -} diff --git a/test/interceptors/maintenance-mode.spec.ts b/test/interceptors/maintenance-mode.spec.ts deleted file mode 100644 index 05e644cb..00000000 --- a/test/interceptors/maintenance-mode.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import type { InternalAxiosRequestConfig } from 'axios' - -import { AxiosError } from 'axios' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { cancelableClient } from '../../lib/client.ts' -import { onMaintenanceModeError, RETRY_DELAY_KEY } from '../../lib/interceptors/maintenance-mode.ts' - -describe('maintenance mode interceptor', () => { - const axiosMock = vi.mockObject(cancelableClient) - const consoleWarn = vi.spyOn(window.console, 'warn') - const consoleError = vi.spyOn(window.console, 'error') - - beforeEach(() => { - vi.resetAllMocks() - vi.useFakeTimers() - consoleWarn.mockImplementation(() => {}) - consoleError.mockImplementation(() => {}) - }) - - it('does retry', async () => { - axiosMock.mockImplementationOnce(async () => ({ - status: 200, - data: {}, - })) - const interceptor = onMaintenanceModeError(axiosMock) - - const expectationPromise = expect(interceptor(mockAxiosError('Service Unavailable', 503, { 'x-nextcloud-maintenance-mode': '1' }, { retryIfMaintenanceMode: true }))).resolves.not.toThrowError() - expect(axiosMock).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(2000) - await expectationPromise - expect(axiosMock).toHaveBeenCalledOnce() - expect(consoleWarn).toHaveBeenCalledOnce() - expect(consoleError).not.toHaveBeenCalledOnce() - }) - - it('does retry more than 16 times', async () => { - const interceptor = onMaintenanceModeError(axiosMock) - - const expectationPromise = expect(interceptor(mockAxiosError( - 'Service Unavailable', - 503, - { 'x-nextcloud-maintenance-mode': '1' }, - { retryIfMaintenanceMode: true, [RETRY_DELAY_KEY]: 32 }, - ))).rejects.toThrowError() - expect(axiosMock).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(32000) - await expectationPromise - expect(axiosMock).not.toHaveBeenCalledOnce() - expect(consoleError).toHaveBeenCalledOnce() - }) - - it('does not intercept a cancelation error', async () => { - const interceptor = onMaintenanceModeError(axiosMock) - - await expect(interceptor(new AxiosError('Canceled', AxiosError.ERR_CANCELED))).rejects.toThrowError() - await vi.advanceTimersByTimeAsync(2000) - expect(axiosMock).not.toHaveBeenCalled() - }) - - it('does not retry HTTP-404', async () => { - const interceptor = onMaintenanceModeError(axiosMock) - - await expect(interceptor(mockAxiosError('Not Found', 404))).rejects.toThrowError() - await vi.advanceTimersByTimeAsync(2000) - expect(axiosMock).not.toHaveBeenCalled() - }) - - it('does not retry without config option', async () => { - const interceptor = onMaintenanceModeError(axiosMock) - - await expect(interceptor(mockAxiosError('Service Unavailable', 503, { 'x-nextcloud-maintenance-mode': '1' }))).rejects.toThrowError() - await vi.advanceTimersByTimeAsync(2000) - expect(axiosMock).not.toHaveBeenCalled() - }) - - it('does not retry without header', async () => { - const interceptor = onMaintenanceModeError(axiosMock) - - await expect(interceptor(mockAxiosError('Service Unavailable', 503, {}, { retryIfMaintenanceMode: true }))).rejects.toThrowError() - await vi.advanceTimersByTimeAsync(2000) - expect(axiosMock).not.toHaveBeenCalled() - }) -}) - -/** - * @param statusText - The status text of the error - * @param status - The HTTP status code of the error - * @param headers - The headers of the error response - */ -function mockAxiosError(statusText: string, status: number, headers = {}, config = {}) { - return new AxiosError( - statusText, - AxiosError.ERR_BAD_REQUEST, - config as InternalAxiosRequestConfig, - { - responseURL: '/some/url', - }, - { - config: config as InternalAxiosRequestConfig, - headers, - data: {}, - status, - statusText, - }, - ) -} diff --git a/test/interceptors/not-logged-in.spec.ts b/test/interceptors/not-logged-in.spec.ts deleted file mode 100644 index 47a5cd16..00000000 --- a/test/interceptors/not-logged-in.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import type { InternalAxiosRequestConfig } from 'axios' - -import { AxiosError } from 'axios' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { onNotLoggedInError } from '../../lib/interceptors/not-logged-in.ts' - -describe('not logged in interceptor', () => { - const consoleMock = vi.spyOn(window.console, 'error') - - beforeEach(() => { - vi.resetAllMocks() - consoleMock.mockImplementationOnce(() => {}) - Object.defineProperty(window, 'location', { - configurable: true, - value: { reload: vi.fn() }, - }) - }) - - it('does reload when it should', async () => { - await expect(() => onNotLoggedInError(mockAxiosError({ reloadExpiredSession: true }, { message: 'Current user is not logged in' }))).rejects.toThrowError() - expect(window.location.reload).toHaveBeenCalled() - }) - - it('does not reload arbitrary 401s', async () => { - await expect(() => onNotLoggedInError(mockAxiosError({}))).rejects.toThrowError() - expect(window.location.reload).not.toHaveBeenCalled() - }) - - it('does not reload if not asked to', async () => { - await expect(() => onNotLoggedInError(mockAxiosError({}, { message: 'Current user is not logged in' }))).rejects.toThrowError() - expect(window.location.reload).not.toHaveBeenCalled() - }) - - it('does not reload on a cancellation error', async () => { - const cancelError = new AxiosError('canceled', AxiosError.ERR_CANCELED) - - await expect(() => onNotLoggedInError(cancelError)).rejects.toThrowError() - expect(window.location.reload).not.toHaveBeenCalled() - }) -}) - -/** - * This function mocks an Axios error response for testing purposes. - * It simulates a 401 Unauthorized error, which is commonly used to indicate that the user is not logged in. - * - * @param config - The Axios request configuration - * @param data - The data to be returned in the error response - */ -function mockAxiosError(config = {}, data = {}) { - return new AxiosError( - 'Unauthorized', - AxiosError.ERR_BAD_REQUEST, - config as InternalAxiosRequestConfig, - { - responseURL: '/some/url', - }, - { - config: config as InternalAxiosRequestConfig, - headers: {}, - data, - status: 401, - statusText: 'Unauthorized', - }, - ) -} diff --git a/test/client.spec.ts b/tests/client.spec.ts similarity index 70% rename from test/client.spec.ts rename to tests/client.spec.ts index bd19420a..5feae106 100644 --- a/test/client.spec.ts +++ b/tests/client.spec.ts @@ -3,24 +3,21 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { emit } from '@nextcloud/event-bus' +import { getRequestToken, setRequestToken } from '@nextcloud/auth' import { expect, it, vi } from 'vitest' -import { cancelableClient } from '../lib/client.ts' +import { getCancelableClient } from '../lib/client.ts' -vi.mock('@nextcloud/auth', async (original) => ({ - ...await original(), - getRequestToken: () => 'initial-token', -})) +vi.mock('@nextcloud/auth', { spy: true }) +vi.mocked(getRequestToken).mockReturnValue('initial-token') + +const cancelableClient = getCancelableClient() it('has initial token set in defaults', () => { expect(cancelableClient.defaults.headers.requesttoken).toBe('initial-token') }) it('has the latest request token', () => { - emit('csrf-token-update', { - token: 'ABC123', - }) - + setRequestToken('ABC123') expect(cancelableClient.defaults.headers.requesttoken).toBe('ABC123') }) diff --git a/tests/interceptors/csrf-token.spec.ts b/tests/interceptors/csrf-token.spec.ts new file mode 100644 index 00000000..686f9e16 --- /dev/null +++ b/tests/interceptors/csrf-token.spec.ts @@ -0,0 +1,95 @@ +/*! + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getCancelableClient } from '../../lib/client.ts' +import { onCsrfTokenError } from '../../lib/interceptors/csrf-token.ts' +import { mockRequests } from '../mockRequests.ts' + +const server = mockRequests() + +// interceptors +const httpTokenOk = http.get('/index.php/csrftoken', () => HttpResponse.json({ token: '123' })) +const httpBadRequest = http.get('/index.php/api', () => HttpResponse.json({ message: 'CSRF check failed' }, { status: 400, statusText: 'Bad Request' })) +const httpPrecoditionFailed = http.get('/index.php/api', () => HttpResponse.json({ message: 'Generic error' }, { status: 412, statusText: 'Precondition Failed' })) +const httpCsrfFailed = http.get('/index.php/api', () => HttpResponse.json({ message: 'CSRF check failed' }, { status: 412, statusText: 'Precondition Failed' })) +function getCsrfErrorHandler() { + let handled = false + return http.get('/index.php/api', () => { + if (handled) { + return HttpResponse.json({ success: true }) + } else { + handled = true + return HttpResponse.json({ message: 'CSRF check failed' }, { status: 412, statusText: 'Precondition Failed' }) + } + }) +} + +describe('CSRF token', () => { + const consoleWarn = vi.spyOn(window.console, 'warn') + const consoleDebug = vi.spyOn(window.console, 'debug') + + beforeEach(() => { + vi.resetAllMocks() + consoleWarn.mockImplementation(() => {}) + consoleDebug.mockImplementation(() => {}) + }) + + it('does retry', async () => { + server.resetHandlers(httpTokenOk, getCsrfErrorHandler()) + + const axios = getAxios() + await expect(axios.get('/index.php/api')) + .resolves.not.toThrow() + + expect(server.requests).toHaveLength(3) + expect(server.requests[0].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + expect(server.requests[1].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/csrftoken"') + expect(server.requests[2].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + expect(axios.defaults.headers.requesttoken).toBe('123') + }) + + it('does not retry if wrong message is returned', async () => { + server.resetHandlers(httpTokenOk, httpPrecoditionFailed) + + await expect(getAxios().get('/index.php/api')) + .rejects.toThrow() + + expect(server.requests).toHaveLength(1) + expect(server.requests[0].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + }) + + it('does not retry with unrelated error', async () => { + server.resetHandlers(httpTokenOk, httpBadRequest) + + await expect(getAxios().get('/index.php/api')) + .rejects.toThrow() + + expect(server.requests).toHaveLength(1) + expect(server.requests[0].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + }) + + it('does not retry multiple times', async () => { + server.resetHandlers(httpTokenOk, httpCsrfFailed) + + await expect(getAxios().get('/index.php/api')) + .rejects.toThrow() + + expect(server.requests).toHaveLength(3) + expect(server.requests[0].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') // ok, throws but we try again + expect(server.requests[1].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/csrftoken"') // we fetch a new token + expect(server.requests[2].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') // failed again no more retries + }) +}) + +/** + * Get a new axios instance with the csrf token interceptor attached. + */ +function getAxios() { + const axios = getCancelableClient() + axios.interceptors.response.use((r) => r, onCsrfTokenError(axios)) + return axios +} diff --git a/tests/interceptors/maintenance-mode.spec.ts b/tests/interceptors/maintenance-mode.spec.ts new file mode 100644 index 00000000..eaf6b56a --- /dev/null +++ b/tests/interceptors/maintenance-mode.spec.ts @@ -0,0 +1,166 @@ +/*! + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getCancelableClient } from '../../lib/client.ts' +import { onMaintenanceModeError } from '../../lib/interceptors/maintenance-mode.ts' +import { mockRequests } from '../mockRequests.ts' + +const server = mockRequests() + +// interceptors +const httpGateWayTimeout = http.get('/index.php/api', () => HttpResponse.json({ message: 'Gateway Timeout' }, { status: 504, statusText: 'Gateway Timeout' })) +const httpServiceUnavailable = http.get('/index.php/api', () => HttpResponse.json({ message: 'Service Unavailable' }, { status: 503, statusText: 'Service Unavailable' })) +function getHttpMaintenanceMode(retries = 2) { + return http.get('/index.php/api', () => { + if (--retries <= 0) { + return HttpResponse.json({ message: 'ok' }) + } + return HttpResponse.json({ message: 'Service Unavailable' }, { status: 503, statusText: 'Service Unavailable', headers: { 'x-nextcloud-maintenance-mode': '1' } }) + }) +} + +describe('maintenance mode interceptor', () => { + const consoleWarn = vi.spyOn(window.console, 'warn') + const consoleError = vi.spyOn(window.console, 'error') + + beforeEach(() => { + vi.resetAllMocks() + vi.useFakeTimers() + consoleWarn.mockImplementation(() => {}) + consoleError.mockImplementation(() => {}) + }) + + it('does retry', async () => { + server.resetHandlers(getHttpMaintenanceMode()) + + // wait for the first request to be made + const request = new Promise((resolve) => server.events.on('request:match', resolve)) + const response = getAxios().get('/index.php/api', { retryIfMaintenanceMode: true }) + await expect(request).resolves.not.toThrow() + vi.setTimerTickMode('manual') + + // now advance the timers 1/2 of timeout + await vi.advanceTimersByTimeAsync(1000) + expect(server.requests).toHaveLength(1) + + // now check that after the full timeout the request is retried + await vi.advanceTimersByTimeAsync(1000) + vi.setTimerTickMode('nextTimerAsync') + await expect(response).resolves.not.toThrow() + expect(server.requests).toHaveLength(2) + expect(server.requests[0].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + expect(server.requests[1].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + expect(consoleWarn).toHaveBeenCalledOnce() + expect(consoleError).not.toHaveBeenCalledOnce() + }) + + it('does retry up to 32s (5 times)', async () => { + server.resetHandlers(getHttpMaintenanceMode(6)) + + // wait for the first request to be made + const request = new Promise((resolve) => server.events.on('request:match', resolve)) + const response = getAxios().get('/index.php/api', { retryIfMaintenanceMode: true }) + await expect(request).resolves.not.toThrow() + + expect(server.requests).toHaveLength(1) + + vi.setTimerTickMode('nextTimerAsync') + for (let i = 1; i <= 5; i++) { + const r = new Promise((r) => server.events.on('request:match', r)) + await vi.advanceTimersByTimeAsync(1000 * (2 ** i)) + await expect(r).resolves.not.toThrow() + expect(server.requests).toHaveLength(i + 1) + expect(server.requests[i].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + } + + await expect(response).resolves.not.toThrow() + expect(consoleWarn).toHaveBeenCalledTimes(5) + expect(consoleError).not.toHaveBeenCalled() + }) + + it('gives up after 32s (on 6st retry)', async () => { + server.resetHandlers(getHttpMaintenanceMode(7)) + + // wait for the first request to be made + const request = new Promise((resolve) => server.events.on('request:match', resolve)) + const response = getAxios().get('/index.php/api', { retryIfMaintenanceMode: true }) + await expect(request).resolves.not.toThrow() + + expect(server.requests).toHaveLength(1) + + vi.setTimerTickMode('nextTimerAsync') + for (let i = 1; i <= 5; i++) { + const r = new Promise((r) => server.events.on('request:match', r)) + await vi.advanceTimersByTimeAsync(1000 * (2 ** i)) + await expect(r).resolves.not.toThrow() + expect(server.requests).toHaveLength(i + 1) + expect(server.requests[i].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + } + + await expect(response).rejects.toThrow() + expect(server.requests).toHaveLength(6) + expect(consoleWarn).toHaveBeenCalledTimes(5) + expect(consoleError).toHaveBeenCalledOnce() + }) + + it('does not retry without config option', async () => { + server.resetHandlers(getHttpMaintenanceMode()) + + // wait for the first request to be made + const request = new Promise((resolve) => server.events.on('request:match', resolve)) + const response = getAxios().get('/index.php/api') + await expect(request).resolves.not.toThrow() + + vi.setTimerTickMode('nextTimerAsync') + await vi.advanceTimersByTimeAsync(2500) + + await expect(response).rejects.toThrow() + expect(server.requests).toHaveLength(1) + expect(server.requests[0].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + }) + + it('does not retry without header', async () => { + server.resetHandlers(httpServiceUnavailable) + + // wait for the first request to be made + const request = new Promise((resolve) => server.events.on('request:match', resolve)) + const response = getAxios().get('/index.php/api', { retryIfMaintenanceMode: true }) + await expect(request).resolves.not.toThrow() + + vi.setTimerTickMode('nextTimerAsync') + await vi.advanceTimersByTimeAsync(2500) + + await expect(response).rejects.toThrow() + expect(server.requests).toHaveLength(1) + expect(server.requests[0].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + }) + + it('does not retry with wrong status code', async () => { + server.resetHandlers(httpGateWayTimeout) + + // wait for the first request to be made + const request = new Promise((resolve) => server.events.on('request:match', resolve)) + const response = getAxios().get('/index.php/api', { retryIfMaintenanceMode: true }) + await expect(request).resolves.not.toThrow() + + vi.setTimerTickMode('nextTimerAsync') + await vi.advanceTimersByTimeAsync(2500) + + await expect(response).rejects.toThrow() + expect(server.requests).toHaveLength(1) + expect(server.requests[0].url).toMatchInlineSnapshot('"http://localhost:63315/index.php/api"') + }) +}) + +/** + * Get an axios instance with the maintenance mode interceptor attached + */ +function getAxios() { + const axios = getCancelableClient() + axios.interceptors.response.use((r) => r, onMaintenanceModeError(axios)) + return axios +} diff --git a/tests/interceptors/not-logged-in.spec.ts b/tests/interceptors/not-logged-in.spec.ts new file mode 100644 index 00000000..23ae56fc --- /dev/null +++ b/tests/interceptors/not-logged-in.spec.ts @@ -0,0 +1,68 @@ +/*! + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getCancelableClient } from '../../lib/client.ts' +import { onNotLoggedInError } from '../../lib/interceptors/not-logged-in.ts' +import { mockRequests } from '../mockRequests.ts' + +import '../../lib/custom-config.ts' + +const server = mockRequests() + +// interceptors +const httpNotLoggedIn = http.get('/index.php/api', () => HttpResponse.json({ message: 'Current user is not logged in' }, { status: 401, statusText: 'Unauthorized' })) +const httpUnauthorized = http.get('/index.php/api', () => HttpResponse.json({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' })) + +describe('not logged in interceptor', () => { + const consoleMock = vi.spyOn(window.console, 'error') + const spyReload = vi.fn() + + beforeEach(() => { + vi.resetAllMocks() + globalThis.OC = { reload: spyReload } + consoleMock.mockImplementationOnce(() => {}) + }) + + it('does reload when it should', async () => { + server.resetHandlers(httpNotLoggedIn) + + await expect(getAxios().get('/index.php/api', { reloadExpiredSession: true })) + .rejects.toThrow() + + expect(server.requests).toHaveLength(1) + expect(spyReload).toHaveBeenCalledOnce() + }) + + it('does not reload arbitrary 401s', async () => { + server.resetHandlers(httpUnauthorized) + + await expect(getAxios().get('/index.php/api', { reloadExpiredSession: true })) + .rejects.toThrow() + + expect(server.requests).toHaveLength(1) + expect(spyReload).not.toHaveBeenCalledOnce() + }) + + it('does not reload if not asked to', async () => { + server.resetHandlers(httpNotLoggedIn) + + await expect(getAxios().get('/index.php/api')) + .rejects.toThrow() + + expect(server.requests).toHaveLength(1) + expect(spyReload).not.toHaveBeenCalledOnce() + }) +}) + +/** + * Get a new axios instance with the not-logged-in interceptor attached. + */ +function getAxios() { + const axios = getCancelableClient() + axios.interceptors.response.use((r) => r, onNotLoggedInError) + return axios +} diff --git a/tests/mockRequests.ts b/tests/mockRequests.ts new file mode 100644 index 00000000..0f0edcfe --- /dev/null +++ b/tests/mockRequests.ts @@ -0,0 +1,32 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { setupWorker } from 'msw/browser' +import { afterAll, beforeAll, beforeEach } from 'vitest' + +/** + * Create a mock service worker and expose the requests it receives for testing purposes. + */ +export function mockRequests() { + const requests: Request[] = [] + const server = setupWorker() + + beforeAll(async () => { + // @ts-expect-error -- mocking global variable for testing purposes + window._oc_webroot = '' + // Start webworker before all tests + await server.start({ onUnhandledRequest: 'error', quiet: true }) + }) + + beforeEach(() => { + requests.splice(0, requests.length) + server.events.removeAllListeners() + server.events.on('request:match', (req) => requests.push(req.request)) + }) + + afterAll(async () => server.stop()) + + return Object.assign(server, { requests }) +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 00000000..418ae21c --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "../lib", + "." + ], + "compilerOptions": { + "rootDir": "..", + } +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index a6c76d9d..383ca7c3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,28 +1,22 @@ -/** +/*! * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -import { defineConfig } from 'vitest/config' -import config from './vite.config' -export default defineConfig(async (env) => { - const viteConfig = (await config(env)) - delete viteConfig.define +import { playwright } from '@vitest/browser-playwright' +import { defineConfig } from 'vitest/config' - return { - ...viteConfig, - test: { - environment: 'happy-dom', - coverage: { - include: ['lib/**/*.ts', 'lib/*.ts'], - exclude: ['test/'], - }, - // Fix unresolvable .css extension for ssr - server: { - deps: { - inline: [/@nextcloud\/vue/], - }, - }, +export default defineConfig({ + test: { + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, + coverage: { + include: ['lib/**/*.ts', 'lib/*.ts'], + exclude: ['test/'], }, - } + }, })