diff --git a/docs/superpowers/plans/2026-07-22-web-extension-cloud-upload.md b/docs/superpowers/plans/2026-07-22-web-extension-cloud-upload.md new file mode 100644 index 0000000000..2ed71fcfaa --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-web-extension-cloud-upload.md @@ -0,0 +1,576 @@ +# Web Extension Cloud Upload Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add secure, configurable uploads of completed extension sessions to `https://api.rrweb.com` without changing recording startup or session identity behavior. + +**Architecture:** Keep IndexedDB operations in `storage.ts`, add pure/testable cloud settings and upload modules, and make the React settings and session-list views thin consumers. Credentials live only in `Browser.storage.local`; the upload transport serializes NDJSON and degrades from Brotli to gzip to an uncompressed request. + +**Tech Stack:** TypeScript, React 18, Chakra UI, WebExtension storage, Vitest, Happy DOM, Testing Library, Vite. + +--- + +### Task 1: Establish the extension unit-test harness + +**Files:** + +- Modify: `packages/web-extension/package.json` +- Create: `packages/web-extension/vitest.config.ts` +- Create: `packages/web-extension/test/setup.ts` + +- [ ] **Step 1: Add test scripts and direct test dependencies** + +Add these scripts to `packages/web-extension/package.json`: + +```json +"test:unit": "vitest run --config vitest.config.ts", +"test:unit:watch": "vitest --config vitest.config.ts" +``` + +Add these development dependencies and update `yarn.lock` with: + +```json +"@testing-library/react": "^14.3.1", +"@testing-library/user-event": "^14.6.1", +"vitest": "^1.4.0" +``` + +```bash +PUPPETEER_SKIP_DOWNLOAD=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + yarn install --ignore-scripts +``` + +- [ ] **Step 2: Configure Vitest for extension modules and React tests** + +Create `packages/web-extension/vitest.config.ts`: + +```ts +/// +import { fileURLToPath, URL } from 'node:url'; +import { defineProject, mergeConfig } from 'vitest/config'; +import configShared from '../../vitest.config'; + +export default mergeConfig( + configShared, + defineProject({ + resolve: { + alias: { + '~': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + environment: 'happy-dom', + globals: true, + setupFiles: ['./test/setup.ts'], + }, + }), +); +``` + +Create `packages/web-extension/test/setup.ts`: + +```ts +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => cleanup()); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }), +}); +``` + +- [ ] **Step 3: Verify the empty harness** + +Run: `yarn workspace @rrweb/web-extension test:unit --passWithNoTests` + +Expected: exit 0 with no test files found. + +- [ ] **Step 4: Commit the harness** + +```bash +git add packages/web-extension/package.json packages/web-extension/vitest.config.ts packages/web-extension/test/setup.ts yarn.lock +git commit -m "test(web-extension): add unit test harness" +``` + +### Task 2: Define and persist secure cloud settings + +**Files:** + +- Modify: `packages/web-extension/src/types.ts` +- Create: `packages/web-extension/src/utils/cloud-settings.ts` +- Create: `packages/web-extension/test/cloud-settings.test.ts` + +- [ ] **Step 1: Write failing settings tests** + +Create `packages/web-extension/test/cloud-settings.test.ts` with tests that describe the intended API: + +```ts +import { + CLOUD_SETTINGS_STORAGE_KEY, + DEFAULT_CLOUD_SETTINGS, + loadCloudSettings, + normalizeApiBaseUrl, + saveCloudSettings, +} from '../src/utils/cloud-settings'; + +describe('cloud settings', () => { + it('defaults to api.rrweb.com and an empty token', async () => { + const storage = { get: vi.fn().mockResolvedValue({}), set: vi.fn() }; + await expect(loadCloudSettings(storage)).resolves.toEqual( + DEFAULT_CLOUD_SETTINGS, + ); + }); + + it('normalizes trailing slashes before saving locally', async () => { + const storage = { get: vi.fn(), set: vi.fn().mockResolvedValue(undefined) }; + await saveCloudSettings(storage, { + apiBaseUrl: 'https://example.test/api///', + authToken: ' secret ', + }); + expect(storage.set).toHaveBeenCalledWith({ + [CLOUD_SETTINGS_STORAGE_KEY]: { + apiBaseUrl: 'https://example.test/api', + authToken: 'secret', + }, + }); + }); + + it.each(['ftp://example.test', 'not a url'])('rejects %s', (value) => { + expect(() => normalizeApiBaseUrl(value)).toThrow('HTTP or HTTPS'); + }); +}); +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: `yarn workspace @rrweb/web-extension test:unit test/cloud-settings.test.ts` + +Expected: FAIL because `src/utils/cloud-settings.ts` does not exist. + +- [ ] **Step 3: Add the cloud settings types and pure storage adapter** + +Add to `packages/web-extension/src/types.ts`: + +```ts +export type CloudSettings = { + apiBaseUrl: string; + authToken: string; +}; +``` + +Create `packages/web-extension/src/utils/cloud-settings.ts` with: + +```ts +import type { CloudSettings } from '~/types'; + +export const CLOUD_SETTINGS_STORAGE_KEY = 'rrweb-cloud-settings'; +export const DEFAULT_CLOUD_SETTINGS: CloudSettings = { + apiBaseUrl: 'https://api.rrweb.com', + authToken: '', +}; + +type LocalStorageArea = { + get(key: string): Promise>; + set(values: Record): Promise; +}; + +export function normalizeApiBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + throw new Error('API base URL must be a valid HTTP or HTTPS URL.'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('API base URL must use HTTP or HTTPS.'); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error( + 'API base URL cannot include credentials, a query, or a hash.', + ); + } + return url.href.replace(/\/+$/, ''); +} + +export function normalizeCloudSettings( + value?: Partial, +): CloudSettings { + return { + apiBaseUrl: normalizeApiBaseUrl( + value?.apiBaseUrl || DEFAULT_CLOUD_SETTINGS.apiBaseUrl, + ), + authToken: value?.authToken?.trim() || '', + }; +} + +export async function loadCloudSettings( + storage: LocalStorageArea, +): Promise { + const stored = await storage.get(CLOUD_SETTINGS_STORAGE_KEY); + return normalizeCloudSettings( + stored[CLOUD_SETTINGS_STORAGE_KEY] as Partial | undefined, + ); +} + +export async function saveCloudSettings( + storage: LocalStorageArea, + settings: CloudSettings, +): Promise { + await storage.set({ + [CLOUD_SETTINGS_STORAGE_KEY]: normalizeCloudSettings(settings), + }); +} +``` + +- [ ] **Step 4: Run the tests and verify GREEN** + +Run: `yarn workspace @rrweb/web-extension test:unit test/cloud-settings.test.ts` + +Expected: 4 tests pass. + +- [ ] **Step 5: Commit settings primitives** + +```bash +git add packages/web-extension/src/types.ts packages/web-extension/src/utils/cloud-settings.ts packages/web-extension/test/cloud-settings.test.ts +git commit -m "feat(web-extension): add local cloud settings" +``` + +### Task 3: Implement the upload transport with compression fallbacks + +**Files:** + +- Create: `packages/web-extension/src/utils/cloud-upload.ts` +- Create: `packages/web-extension/test/cloud-upload.test.ts` + +- [ ] **Step 1: Write failing URL, authentication, and fallback tests** + +Create `packages/web-extension/test/cloud-upload.test.ts`. Use real `Session` and event-shaped values, injected `getSession`, `getEvents`, `fetchFn`, and `compress` dependencies. Cover these assertions in separate tests: + +```ts +expect(buildUploadUrl('https://api.rrweb.com/', 'a/b')).toBe( + 'https://api.rrweb.com/recordings/a%2Fb/ingest', +); +expect(fetchFn).toHaveBeenCalledWith( + 'https://api.rrweb.com/recordings/session-1/ingest', + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: 'Bearer token-value', + 'Content-Type': 'application/x-ndjson', + 'Content-Encoding': 'br', + }, + }), +); +``` + +Add focused tests proving: + +1. a missing token returns a failure without calling storage or fetch; +2. Brotli success sets `Content-Encoding: br`; +3. Brotli rejection followed by gzip success sets `Content-Encoding: gzip`; +4. two compression rejections send the raw NDJSON string with no + `Content-Encoding` header; +5. an HTTP 401 becomes a per-session error; and +6. a failed first session does not prevent a second session from uploading. + +- [ ] **Step 2: Run the tests and verify RED** + +Run: `yarn workspace @rrweb/web-extension test:unit test/cloud-upload.test.ts` + +Expected: FAIL because `src/utils/cloud-upload.ts` does not exist. + +- [ ] **Step 3: Implement the transport boundary** + +Create `packages/web-extension/src/utils/cloud-upload.ts` with these public interfaces: + +```ts +import type { eventWithTime } from '@rrweb/types'; +import type { CloudSettings, Session } from '~/types'; +import { getEvents, getSession } from './storage'; +import { normalizeApiBaseUrl } from './cloud-settings'; + +export type SessionUploadResult = { + id: string; + name: string; + ok: boolean; + error?: string; +}; + +type CompressionFormat = 'brotli' | 'gzip'; +type Compressor = ( + payload: string, + format: CompressionFormat, +) => Promise; + +type UploadDependencies = { + getSession(id: string): Promise; + getEvents(id: string): Promise; + fetchFn: typeof fetch; + compress: Compressor; +}; + +export function buildUploadUrl(baseUrl: string, sessionId: string): string; +export async function uploadSessions( + ids: string[], + settings: CloudSettings, + dependencies?: Partial, +): Promise; +``` + +The default compressor must use `CompressionStream` through a narrow constructor cast, return an `ArrayBuffer`, and never log its input. `uploadSessions` must validate and trim configuration before loading a session, serialize events with `events.map(JSON.stringify).join('\n')`, merge injected dependencies over `{ getSession, getEvents, fetchFn: fetch, compress: compressWithCompressionStream }`, and process IDs sequentially so every result is retained. + +Use this fallback loop rather than duplicating request construction: + +```ts +async function prepareBody(payload: string, compress: Compressor) { + for (const [format, encoding] of [ + ['brotli', 'br'], + ['gzip', 'gzip'], + ] as const) { + try { + return { body: await compress(payload, format), encoding }; + } catch { + // Try the next supported representation. + } + } + return { body: payload }; +} +``` + +- [ ] **Step 4: Run the transport tests and verify GREEN** + +Run: `yarn workspace @rrweb/web-extension test:unit test/cloud-upload.test.ts` + +Expected: all transport tests pass with no console output. + +- [ ] **Step 5: Commit the transport** + +```bash +git add packages/web-extension/src/utils/cloud-upload.ts packages/web-extension/test/cloud-upload.test.ts +git commit -m "feat(web-extension): upload sessions to rrweb API" +``` + +### Task 4: Add and test the local settings screen + +**Files:** + +- Create: `packages/web-extension/src/options/Settings.tsx` +- Modify: `packages/web-extension/src/options/App.tsx` +- Create: `packages/web-extension/test/Settings.test.tsx` + +- [ ] **Step 1: Write a failing settings-screen test** + +Mock `webextension-polyfill` before importing the component. Render +`SettingsView` inside `ChakraProvider`, wait for the default API URL to appear, +enter a token, replace the URL with `https://uploads.example.test/`, click Save, +and assert: + +```ts +expect(localStorageArea.set).toHaveBeenCalledWith({ + 'rrweb-cloud-settings': { + apiBaseUrl: 'https://uploads.example.test', + authToken: 'entered-token', + }, +}); +expect(syncStorageArea.set).not.toHaveBeenCalled(); +``` + +Add a second test entering `ftp://uploads.example.test` and assert that the +validation error is shown and neither storage area writes. + +- [ ] **Step 2: Run the component test and verify RED** + +Run: `yarn workspace @rrweb/web-extension test:unit test/Settings.test.tsx` + +Expected: FAIL because `Settings.tsx` does not exist. + +- [ ] **Step 3: Implement the settings view** + +Build `SettingsView` with Chakra `FormControl`, `FormLabel`, `Input`, `Button`, +and toast components. It must: + +- call `loadCloudSettings(Browser.storage.local)` on mount; +- render named inputs `apiBaseUrl` and `authToken`; +- use `type="url"` for the base URL and `type="password"` for the token; +- call `saveCloudSettings(Browser.storage.local, settings)` on Save; +- show validation failures without writing storage; and +- describe the endpoint as `/recordings//ingest`. + +Update `options/App.tsx` to import `SettingsView` and replace the empty route: + +```tsx +} /> +``` + +- [ ] **Step 4: Run the settings tests and verify GREEN** + +Run: `yarn workspace @rrweb/web-extension test:unit test/Settings.test.tsx` + +Expected: both component tests pass. + +- [ ] **Step 5: Commit the settings UI** + +```bash +git add packages/web-extension/src/options/App.tsx packages/web-extension/src/options/Settings.tsx packages/web-extension/test/Settings.test.tsx +git commit -m "feat(web-extension): configure cloud uploads locally" +``` + +### Task 5: Add and test session-list uploads + +**Files:** + +- Modify: `packages/web-extension/src/pages/SessionList.tsx` +- Create: `packages/web-extension/test/SessionList.test.tsx` + +- [ ] **Step 1: Write a failing upload-action test** + +Mock `~/utils/storage`, `~/utils/cloud-settings`, and `~/utils/cloud-upload`. +Return one saved session from `getAllSessions`, render `SessionList` inside +`ChakraProvider` and `MemoryRouter`, select the session row, and click Upload. +Assert: + +```ts +expect(loadCloudSettings).toHaveBeenCalledWith(Browser.storage.local); +expect(uploadSessions).toHaveBeenCalledWith( + ['session-1'], + expect.objectContaining({ apiBaseUrl: 'https://api.rrweb.com' }), +); +``` + +Add separate tests that make `uploadSessions` return all-success and +partial-failure arrays, then assert the corresponding `Upload complete` and +`Upload completed with errors` toast content. + +- [ ] **Step 2: Run the component test and verify RED** + +Run: `yarn workspace @rrweb/web-extension test:unit test/SessionList.test.tsx` + +Expected: FAIL because the Upload action is absent. + +- [ ] **Step 3: Wire the session-list UI to the upload boundary** + +In `SessionList.tsx`: + +- import `Browser`, `loadCloudSettings`, and `uploadSessions`; +- add `isUploading` state; +- implement `handleUpload` with a `try/finally` loading guard; +- preserve per-session failure names and messages in the error toast; +- clear neither row selection nor saved sessions after upload; and +- add a blue Upload button beside Download with `isLoading={isUploading}` and + `loadingText="Uploading"`. + +The handler must load settings from `Browser.storage.local` and pass only the +selected session IDs and settings to `uploadSessions`. + +- [ ] **Step 4: Run the session-list tests and verify GREEN** + +Run: `yarn workspace @rrweb/web-extension test:unit test/SessionList.test.tsx` + +Expected: upload invocation and feedback tests pass. + +- [ ] **Step 5: Commit the session UI** + +```bash +git add packages/web-extension/src/pages/SessionList.tsx packages/web-extension/test/SessionList.test.tsx +git commit -m "feat(web-extension): add session upload action" +``` + +### Task 6: Document and verify the complete feature + +**Files:** + +- Modify: `packages/web-extension/README.md` + +- [ ] **Step 1: Document configuration and behavior** + +Add a Cloud uploads section explaining: + +- recordings remain local until the user selects and uploads them; +- the default base URL is `https://api.rrweb.com`; +- the bearer token is stored in extension-local storage and is not synced; +- custom HTTP/HTTPS base URLs are supported for proxies and local development; +- the endpoint path is `/recordings//ingest`; and +- automatic recording and session-ID bridging are not part of this feature. + +- [ ] **Step 2: Run formatting and inspect intentional changes** + +Run: + +```bash +yarn prettier --write \ + packages/web-extension/src/types.ts \ + packages/web-extension/src/utils/cloud-settings.ts \ + packages/web-extension/src/utils/cloud-upload.ts \ + packages/web-extension/src/options/App.tsx \ + packages/web-extension/src/options/Settings.tsx \ + packages/web-extension/src/pages/SessionList.tsx \ + packages/web-extension/test \ + packages/web-extension/vitest.config.ts \ + packages/web-extension/README.md +git diff --check +``` + +Expected: formatter exits 0 and `git diff --check` produces no output. + +- [ ] **Step 3: Run the complete extension test suite** + +Run: `yarn workspace @rrweb/web-extension test:unit` + +Expected: every extension unit and component test passes. + +- [ ] **Step 4: Run dependency-aware type checking** + +Run: `yarn turbo run check-types --filter @rrweb/web-extension` + +Expected: all dependency builds and the extension type-check pass. Do not use +the direct workspace `check-types` command in a fresh checkout because it does +not build referenced workspace packages first. + +- [ ] **Step 5: Build both browser targets** + +Run: + +```bash +yarn workspace @rrweb/web-extension build:chrome +yarn workspace @rrweb/web-extension build:firefox +``` + +Expected: both Vite builds exit 0 and produce extension bundles. + +- [ ] **Step 6: Confirm scope and credential hygiene** + +Run: + +```bash +git diff --name-only HEAD~5..HEAD +rg -n "rrwebcloud|console\.(log|debug).*payload|storage\.sync.*auth|authToken:\s*['\"][^'\"]+" packages/web-extension/src packages/web-extension/test +``` + +Expected: no auto-start, session-ID bridge, hardcoded token, payload logging, +sync credential storage, or `rrwebcloud` hostname appears. Inspect any benign +test fixture match before proceeding. + +- [ ] **Step 7: Commit documentation and any formatting-only changes** + +```bash +git add packages/web-extension/README.md packages/web-extension +git commit -m "docs(web-extension): document cloud session uploads" +``` + +- [ ] **Step 8: Review final branch state** + +Run: `git status --short --branch && git log --oneline --decorate -8` + +Expected: only known setup-generated files outside the feature remain unstaged; +all feature changes are committed on `codex/web-extension-cloud-upload`. diff --git a/docs/superpowers/specs/2026-07-22-web-extension-cloud-upload-design.md b/docs/superpowers/specs/2026-07-22-web-extension-cloud-upload-design.md new file mode 100644 index 0000000000..f5e9c5b82d --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-web-extension-cloud-upload-design.md @@ -0,0 +1,115 @@ +# Web Extension Cloud Upload Design + +## Goal + +Add completed-session uploads to the rrweb browser extension without changing +recording startup or session identity behavior. The extension will upload to +`https://api.rrweb.com` by default, while allowing users to configure another +HTTP or HTTPS API base URL. + +## Scope + +The feature includes: + +- an Upload action for selected saved sessions; +- a settings screen for an API base URL and bearer token; +- local-only credential storage; +- NDJSON serialization with Brotli, gzip, and raw fallbacks; +- per-session success and error feedback; and +- automated tests for configuration, request construction, fallbacks, and + partial failures. + +The feature deliberately excludes automatic recording, page-to-extension +session ID bridges, and any change to how recordings receive IDs. Those changes +belong in a separate pull request. + +## Architecture + +`src/utils/storage.ts` remains responsible only for IndexedDB session and event +storage. A new `src/utils/cloud-upload.ts` module owns configuration +normalization, NDJSON serialization, compression selection, URL construction, +and the HTTP request. This boundary keeps network behavior independently +testable and prevents storage code from accumulating transport concerns. + +`src/options/Settings.tsx` reads and writes cloud configuration through +`Browser.storage.local`. The API token is never placed in sync storage. The API +base URL defaults to `https://api.rrweb.com`, is normalized by removing trailing +slashes, and must use HTTP or HTTPS. The upload endpoint is constructed as +`/recordings//ingest`. + +`src/pages/SessionList.tsx` retrieves selected session IDs, delegates them to +the upload module, and presents aggregate success or per-session failure +messages. It does not access credentials or construct network requests. + +## Configuration and Credentials + +The cloud settings shape is: + +```ts +type CloudSettings = { + apiBaseUrl: string; + authToken: string; +}; +``` + +Defaults are applied when settings are missing or incomplete. The token input +uses a password field. Saving an empty token is allowed, but upload attempts +fail before reading session payloads or issuing a request. No token or event +payload is written to logs. + +HTTP is accepted to support local development; production users receive the +HTTPS default. Unsupported URL protocols and malformed URLs are rejected with +a settings validation error. + +## Upload Data Flow + +For each selected session, the upload module: + +1. loads session metadata and recorded events; +2. serializes each event as one JSON line; +3. attempts Brotli compression when the runtime supports it; +4. falls back to gzip when Brotli is unavailable or fails; +5. falls back to the raw NDJSON string when compression is unavailable; +6. sends one POST request with `Authorization: Bearer ` and + `Content-Type: application/x-ndjson`; +7. includes `Content-Encoding` only for compressed bodies; and +8. records success or a concise error for that session before continuing. + +Session IDs are URL-encoded. Non-success HTTP responses include status and +status text in the result without exposing response bodies that might contain +sensitive information. + +## Error Handling + +Missing credentials and invalid API URLs fail before network activity. Missing +session metadata or events fail only that session. Compression failures degrade +to the next supported encoding rather than aborting an upload. A failed request +does not prevent remaining selected sessions from being attempted. + +The UI distinguishes complete success, partial failure, and total failure. +Errors remain visible through Chakra toasts and name the affected saved session. + +## Testing + +The extension package will gain a focused Vitest configuration and unit tests. +Pure transport helpers will cover: + +- the `https://api.rrweb.com` default; +- trailing-slash normalization and encoded session IDs; +- rejection of malformed or unsupported URLs; +- missing-token short-circuiting; +- Brotli, gzip, and raw-body request headers; +- bearer authorization without credential logging; +- non-success HTTP responses; and +- continuation after individual session failures. + +Verification will run the extension unit tests, TypeScript checking, and both +Chrome and Firefox production builds. + +## Git Strategy + +Implementation lives in the isolated worktree +`/Users/justin/.config/superpowers/worktrees/rrweb/web-extension-cloud-upload` +on branch `codex/web-extension-cloud-upload`. Only the curated upload/settings +changes will be ported; unrelated modifications from the source workspace will +not be copied. diff --git a/packages/web-extension/README.md b/packages/web-extension/README.md index a32a7911f3..7f1464179c 100644 --- a/packages/web-extension/README.md +++ b/packages/web-extension/README.md @@ -33,6 +33,29 @@ yarn dev:chrome yarn dev:firefox ``` +## Cloud uploads + +Completed recordings stay in the extension's local session storage until you +select them and choose **Upload**. Uploading does not start recording +automatically, does not remove the local recording, and this feature does not +add a page-to-session-ID bridge. + +Configure uploads from the extension's **Settings** page. The default API base +URL is `https://api.rrweb.com`; you can configure another base URL for a proxy +or local development. Remote endpoints must use HTTPS because each upload sends +the bearer token and recording data. Plain HTTP is only appropriate for trusted +local development. The authentication bearer token is stored only in +extension-local storage (`Browser.storage.local`) and is never synchronized. + +For each selected session, the extension sends a `POST` request to +`/recordings//ingest`. Its body is NDJSON event data, with +Brotli compression when available, gzip as a fallback, and an uncompressed +request as a last resort. + +The configured endpoint must permit the extension origin to make `POST` +requests via CORS, including the `Authorization`, `Content-Type`, and +`Content-Encoding` request headers. + ## Sponsors [Become a sponsor](https://opencollective.com/rrweb#sponsor) and get your logo on our README on Github with a link to your site. diff --git a/packages/web-extension/package.json b/packages/web-extension/package.json index d15348e2bd..591ea743bc 100644 --- a/packages/web-extension/package.json +++ b/packages/web-extension/package.json @@ -13,12 +13,16 @@ "build:firefox": "cross-env TARGET_BROWSER=firefox vite build", "pack:chrome": "cross-env TARGET_BROWSER=chrome ZIP=true vite build", "pack:firefox": "cross-env TARGET_BROWSER=firefox ZIP=true vite build", + "test:unit": "vitest run --config vitest.config.ts", + "test:unit:watch": "vitest --config vitest.config.ts", "check-types": "tsc -noEmit", "build": "npm run pack:chrome && npm run pack:firefox", "prepublish": "yarn build" }, "devDependencies": { "@rrweb/types": "^2.1.1", + "@testing-library/react": "^14.3.1", + "@testing-library/user-event": "^14.6.1", "@types/chrome": "^0.0.287", "@types/react-dom": "^18.0.6", "@types/semver": "^7.5.8", @@ -29,6 +33,7 @@ "vite": "^6.0.1", "vite-plugin-web-extension": "^4.1.3", "vite-plugin-zip-pack": "^1.2.2", + "vitest": "^1.4.0", "webextension-polyfill": "^0.10.0" }, "dependencies": { diff --git a/packages/web-extension/src/options/App.tsx b/packages/web-extension/src/options/App.tsx index 5d93e6153d..1ecc7d0877 100644 --- a/packages/web-extension/src/options/App.tsx +++ b/packages/web-extension/src/options/App.tsx @@ -2,6 +2,7 @@ import { Route, Routes } from 'react-router-dom'; import SidebarWithHeader from '~/components/SidebarWithHeader'; import { FiList, FiSettings } from 'react-icons/fi'; import { Box } from '@chakra-ui/react'; +import { SettingsView } from './Settings'; export default function App() { return ( @@ -23,7 +24,7 @@ export default function App() { > - } /> + } /> diff --git a/packages/web-extension/src/options/Settings.tsx b/packages/web-extension/src/options/Settings.tsx new file mode 100644 index 0000000000..c6d139bcf5 --- /dev/null +++ b/packages/web-extension/src/options/Settings.tsx @@ -0,0 +1,256 @@ +import { + Alert, + AlertIcon, + Box, + Button, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + Heading, + Input, + Spinner, + Stack, + Text, + useToast, +} from '@chakra-ui/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import Browser from 'webextension-polyfill'; +import type { CloudSettings } from '~/types'; +import { + DEFAULT_CLOUD_SETTINGS, + loadCloudSettings, + normalizeCloudSettings, + saveCloudSettings, +} from '~/utils/cloud-settings'; + +const INVALID_URL_MESSAGE = 'Please enter a valid HTTP or HTTPS URL.'; +const LOAD_ERROR_MESSAGE = + 'Could not load cloud upload settings. Please try again.'; +const SAVE_ERROR_MESSAGE = + 'Could not save cloud upload settings. Please try again.'; + +type LoadState = 'loading' | 'error' | 'ready'; + +export function SettingsView() { + const toast = useToast(); + const [settings, setSettings] = useState( + DEFAULT_CLOUD_SETTINGS, + ); + const [loadState, setLoadState] = useState('loading'); + const [isSaving, setIsSaving] = useState(false); + const [loadError, setLoadError] = useState(); + const [saveError, setSaveError] = useState(); + const isMounted = useRef(true); + const loadRequestId = useRef(0); + + const loadSettings = useCallback(async () => { + const requestId = ++loadRequestId.current; + if (isMounted.current) { + setLoadState('loading'); + setLoadError(undefined); + } + + try { + const loadedSettings = await loadCloudSettings(Browser.storage.local); + if (!isMounted.current || requestId !== loadRequestId.current) { + return; + } + + setSettings(loadedSettings); + setLoadError(undefined); + setLoadState('ready'); + } catch { + if (!isMounted.current || requestId !== loadRequestId.current) { + return; + } + + setLoadError(LOAD_ERROR_MESSAGE); + setLoadState('error'); + } + }, []); + + useEffect(() => { + isMounted.current = true; + void loadSettings(); + + return () => { + isMounted.current = false; + }; + }, [loadSettings]); + + function useDefaultSettings() { + // Ignore an in-flight retry so it cannot replace the user's recovery form. + ++loadRequestId.current; + setSettings({ ...DEFAULT_CLOUD_SETTINGS }); + setLoadError(undefined); + setSaveError(undefined); + setLoadState('ready'); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (loadState !== 'ready') { + return; + } + + setSaveError(undefined); + + let normalizedSettings: CloudSettings; + try { + normalizedSettings = normalizeCloudSettings(settings); + } catch { + setSaveError(INVALID_URL_MESSAGE); + return; + } + + setIsSaving(true); + try { + await saveCloudSettings(Browser.storage.local, normalizedSettings); + if (!isMounted.current) { + return; + } + + setSettings(normalizedSettings); + toast({ + title: 'Cloud upload settings saved.', + status: 'success', + duration: 3000, + isClosable: true, + }); + } catch { + if (isMounted.current) { + setSaveError(SAVE_ERROR_MESSAGE); + } + } finally { + if (isMounted.current) { + setIsSaving(false); + } + } + } + + return ( + + + + + Cloud uploads + + + Configure where this extension uploads completed recordings. + + + + {loadError && ( + + + + {loadError} + + Use the defaults to discard the invalid values in this form. + Nothing is saved until you choose Save settings. + + + + + + )} + + + + + Cloud API base URL + + setSettings((current) => ({ + ...current, + apiBaseUrl: event.target.value, + })) + } + isDisabled={loadState !== 'ready' || isSaving} + /> + {saveError} + + + + + Use HTTPS for remote endpoints. HTTP is only appropriate for + trusted local development because uploads include your bearer + token and recording data. + + + + Authentication token + + setSettings((current) => ({ + ...current, + authToken: event.target.value, + })) + } + isDisabled={loadState !== 'ready' || isSaving} + /> + + This token stays on this device and is never synced. + + + + + The upload endpoint is <base URL>/recordings/<session + ID>/ingest. Your configured endpoint must allow + extension-origin CORS POST requests and the Authorization, + Content-Type, and Content-Encoding headers. + + + {saveError && saveError !== INVALID_URL_MESSAGE && ( + + + {saveError} + + )} + + + + + + {loadState === 'loading' && ( + + + Loading cloud upload settings… + + )} + + + ); +} diff --git a/packages/web-extension/src/pages/SessionList.tsx b/packages/web-extension/src/pages/SessionList.tsx index 03c9c552ed..bc5cf84485 100644 --- a/packages/web-extension/src/pages/SessionList.tsx +++ b/packages/web-extension/src/pages/SessionList.tsx @@ -31,15 +31,19 @@ import { flexRender, getCoreRowModel, type SortingState, + type RowSelectionState, getSortedRowModel, type PaginationState, } from '@tanstack/react-table'; import { VscTriangleDown, VscTriangleUp } from 'react-icons/vsc'; import { FiEdit3 as EditIcon } from 'react-icons/fi'; import { useNavigate } from 'react-router-dom'; +import Browser from 'webextension-polyfill'; import type { eventWithTime } from 'rrweb'; -import { type Session, EventName } from '~/types'; +import { type CloudSettings, type Session, EventName } from '~/types'; import Channel from '~/utils/channel'; +import { loadCloudSettings } from '~/utils/cloud-settings'; +import { type SessionUploadResult, uploadSessions } from '~/utils/cloud-upload'; import { deleteSessions, getAllSessions, @@ -68,7 +72,8 @@ export function SessionList() { desc: true, }, ]); - const [rowSelection, setRowSelection] = useState({}); + const [rowSelection, setRowSelection] = useState({}); + const [isUploading, setIsUploading] = useState(false); const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, @@ -96,6 +101,13 @@ export function SessionList() { }), [pageIndex, pageSize], ); + const selectedSessionIds = useMemo( + () => + sessions + .filter((session) => rowSelection[session.id]) + .map((session) => session.id), + [rowSelection, sessions], + ); const columns = useMemo( () => [ @@ -103,6 +115,7 @@ export function SessionList() { id: 'select', header: ({ table }) => ( ( row.id, manualPagination: true, pageCount: fetchData(fetchDataOptions).pageCount, }); @@ -250,6 +265,85 @@ export function SessionList() { reader.readAsText(file); }; + const handleUpload = async (ids: string[]) => { + if (ids.length === 0) return; + + setIsUploading(true); + try { + let settings: CloudSettings; + + try { + settings = await loadCloudSettings(Browser.storage.local); + } catch (error) { + toast({ + title: 'Could not load cloud upload settings.', + description: + error instanceof Error && error.message + ? error.message + : 'Please try again.', + status: 'error', + duration: 8000, + isClosable: true, + }); + return; + } + + let results: SessionUploadResult[]; + + try { + results = await uploadSessions(ids, settings); + } catch (error) { + toast({ + title: 'Could not complete the upload.', + description: + error instanceof Error && error.message + ? error.message + : 'Please try again.', + status: 'error', + duration: 8000, + isClosable: true, + }); + return; + } + + const failures = results.filter((result) => !result.ok); + const successes = results.length - failures.length; + const failureDescription = failures + .map((result) => `${result.name}: ${result.error ?? 'Upload failed'}`) + .join('\n'); + + if (failures.length === 0) { + toast({ + title: 'Upload complete', + description: `Uploaded ${successes} selected session${ + successes === 1 ? '' : 's' + }.`, + status: 'success', + duration: 5000, + isClosable: true, + }); + } else if (successes > 0) { + toast({ + title: 'Upload completed with errors', + description: `Uploaded ${successes} of ${results.length} selected sessions.\n${failureDescription}`, + status: 'warning', + duration: 8000, + isClosable: true, + }); + } else { + toast({ + title: 'Upload failed', + description: `No selected sessions were uploaded.\n${failureDescription}`, + status: 'error', + duration: 8000, + isClosable: true, + }); + } + } finally { + setIsUploading(false); + } + }; + return ( <> @@ -414,18 +508,15 @@ export function SessionList() { ))} - {Object.keys(rowSelection).length > 0 && ( + {selectedSessionIds.length > 0 && ( + )} diff --git a/packages/web-extension/src/types.ts b/packages/web-extension/src/types.ts index f3720d9c27..559ff91af7 100644 --- a/packages/web-extension/src/types.ts +++ b/packages/web-extension/src/types.ts @@ -12,6 +12,11 @@ export type Settings = { // }; +export type CloudSettings = { + apiBaseUrl: string; + authToken: string; +}; + export enum LocalDataKey { recorderStatus = 'recorder_status', } diff --git a/packages/web-extension/src/utils/cloud-settings.ts b/packages/web-extension/src/utils/cloud-settings.ts new file mode 100644 index 0000000000..d65f6e7b36 --- /dev/null +++ b/packages/web-extension/src/utils/cloud-settings.ts @@ -0,0 +1,77 @@ +import type { CloudSettings } from '~/types'; + +export const CLOUD_SETTINGS_STORAGE_KEY = 'rrweb-cloud-settings'; + +export const DEFAULT_CLOUD_SETTINGS: CloudSettings = { + apiBaseUrl: 'https://api.rrweb.com', + authToken: '', +}; + +type LocalStorage = { + get(key: string): Promise>; + set(values: Record): Promise; +}; + +const INVALID_URL_MESSAGE = 'URL must be valid HTTP/HTTPS'; + +export function normalizeApiBaseUrl(value: string): string { + let url: URL; + const trimmedValue = value.trim(); + + try { + url = new URL(trimmedValue); + } catch { + throw new Error(INVALID_URL_MESSAGE); + } + + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash || + trimmedValue.includes('?') || + trimmedValue.includes('#') + ) { + throw new Error(INVALID_URL_MESSAGE); + } + + return `${url.origin}${url.pathname.replace(/\/+$/, '')}`; +} + +export function normalizeCloudSettings( + value: Partial = {}, +): CloudSettings { + return { + apiBaseUrl: + typeof value.apiBaseUrl === 'string' && value.apiBaseUrl.trim() + ? normalizeApiBaseUrl(value.apiBaseUrl) + : DEFAULT_CLOUD_SETTINGS.apiBaseUrl, + authToken: + typeof value.authToken === 'string' + ? value.authToken.trim() + : DEFAULT_CLOUD_SETTINGS.authToken, + }; +} + +export async function loadCloudSettings( + storage: LocalStorage, +): Promise { + const values = await storage.get(CLOUD_SETTINGS_STORAGE_KEY); + const storedValue = values[CLOUD_SETTINGS_STORAGE_KEY]; + + return normalizeCloudSettings( + storedValue && typeof storedValue === 'object' + ? (storedValue as Partial) + : undefined, + ); +} + +export async function saveCloudSettings( + storage: LocalStorage, + settings: CloudSettings, +): Promise { + await storage.set({ + [CLOUD_SETTINGS_STORAGE_KEY]: normalizeCloudSettings(settings), + }); +} diff --git a/packages/web-extension/src/utils/cloud-upload.ts b/packages/web-extension/src/utils/cloud-upload.ts new file mode 100644 index 0000000000..2d624acfe0 --- /dev/null +++ b/packages/web-extension/src/utils/cloud-upload.ts @@ -0,0 +1,256 @@ +import type { eventWithTime } from '@rrweb/types'; +import type { CloudSettings, Session } from '~/types'; +import { normalizeApiBaseUrl, normalizeCloudSettings } from './cloud-settings'; +import { getEvents, getSession } from './storage'; + +export type SessionUploadResult = { + id: string; + name: string; + ok: boolean; + error?: string; +}; + +export type CompressionFormat = 'brotli' | 'gzip'; + +type ContentEncoding = 'br' | 'gzip'; + +export const DEFAULT_UPLOAD_TIMEOUT_MS = 30_000; + +export type UploadDependencies = { + getSession: (id: string) => Promise; + getEvents: (id: string) => Promise; + fetchFn: typeof fetch; + compress: ( + payload: string, + format: CompressionFormat, + ) => Promise; + compressionStreamCtor: CompressionStreamConstructor; + uploadTimeoutMs?: number; +}; + +export type CompressionStreamConstructor = new (format: CompressionFormat) => { + writable: WritableStream; + readable: ReadableStream; +}; + +async function compressWithCompressionStream( + payload: string, + format: CompressionFormat, + compressionStreamCtor: CompressionStreamConstructor, +): Promise { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(payload)); + controller.close(); + }, + }); + const stream = source.pipeThrough(new compressionStreamCtor(format)); + + return new Response(stream).arrayBuffer(); +} + +const defaultDependencies: Omit< + UploadDependencies, + 'compress' | 'compressionStreamCtor' +> = { + getSession, + getEvents, + fetchFn: fetch, +}; + +export function buildUploadUrl(baseUrl: string, sessionId: string): string { + return `${normalizeApiBaseUrl(baseUrl)}/recordings/${encodeURIComponent( + sessionId, + )}/ingest`; +} + +function failure(id: string, name: string, error: string): SessionUploadResult { + return { id, name, ok: false, error }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message + ? error.message + : 'Upload failed'; +} + +class UploadTimeoutError extends Error { + constructor() { + super('Upload timed out'); + } +} + +function getUploadTimeoutMs(dependencies: Partial): number { + const { uploadTimeoutMs } = dependencies; + + return typeof uploadTimeoutMs === 'number' && + Number.isFinite(uploadTimeoutMs) && + uploadTimeoutMs >= 0 + ? uploadTimeoutMs + : DEFAULT_UPLOAD_TIMEOUT_MS; +} + +async function fetchWithTimeout( + fetchFn: typeof fetch, + input: RequestInfo | URL, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timeoutError = new UploadTimeoutError(); + let timedOut = false; + let timer: ReturnType | undefined; + + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true; + reject(timeoutError); + controller.abort(); + }, timeoutMs); + }); + + try { + const response = await Promise.race([ + fetchFn(input, { ...init, signal: controller.signal }), + timeout, + ]); + + return response; + } catch (error) { + throw timedOut ? timeoutError : error; + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +async function compressPayload( + payload: string, + compress: UploadDependencies['compress'], +): Promise<{ + body: ArrayBuffer | string; + contentEncoding?: ContentEncoding; +}> { + try { + return { body: await compress(payload, 'brotli'), contentEncoding: 'br' }; + } catch { + try { + return { body: await compress(payload, 'gzip'), contentEncoding: 'gzip' }; + } catch { + return { body: payload }; + } + } +} + +/** + * Configured API endpoints must allow extension-origin POST requests and the + * Authorization, Content-Type, and Content-Encoding request headers via CORS. + */ +export async function uploadSessions( + ids: string[], + settings: CloudSettings, + dependencies: Partial = {}, +): Promise { + let normalizedSettings: CloudSettings; + + try { + normalizedSettings = normalizeCloudSettings(settings); + } catch (error) { + return ids.map((id) => failure(id, id, errorMessage(error))); + } + + if (!normalizedSettings.authToken) { + return ids.map((id) => failure(id, id, 'Missing authentication token')); + } + + const { getSession, getEvents, fetchFn } = { + ...defaultDependencies, + ...dependencies, + }; + const uploadTimeoutMs = getUploadTimeoutMs(dependencies); + const compress = + dependencies.compress ?? + ((payload: string, format: CompressionFormat) => + compressWithCompressionStream( + payload, + format, + dependencies.compressionStreamCtor ?? + (CompressionStream as unknown as CompressionStreamConstructor), + )); + const results: SessionUploadResult[] = []; + + for (const id of ids) { + let session: Session | undefined; + + try { + session = await getSession(id); + if (!session) { + results.push(failure(id, id, 'Session not found')); + continue; + } + + let events: unknown; + + try { + events = await getEvents(id); + } catch { + results.push( + failure(id, session.name || id, 'Session events could not be loaded'), + ); + continue; + } + + if (!Array.isArray(events)) { + results.push( + failure(id, session.name || id, 'Session events are invalid'), + ); + continue; + } + + let payload = ''; + for (const event of events as eventWithTime[]) { + if (payload) { + payload += '\n'; + } + payload += JSON.stringify(event); + } + const { body, contentEncoding } = await compressPayload( + payload, + compress, + ); + const headers: Record = { + Authorization: `Bearer ${normalizedSettings.authToken}`, + 'Content-Type': 'application/x-ndjson', + }; + + if (contentEncoding) { + headers['Content-Encoding'] = contentEncoding; + } + + const response = await fetchWithTimeout( + fetchFn, + buildUploadUrl(normalizedSettings.apiBaseUrl, id), + { method: 'POST', headers, body }, + uploadTimeoutMs, + ); + + if (!response.ok) { + results.push( + failure( + id, + session.name || id, + `Upload failed: ${response.status} ${response.statusText}`, + ), + ); + continue; + } + + results.push({ id, name: session.name || id, ok: true }); + } catch (error) { + results.push(failure(id, session?.name || id, errorMessage(error))); + } + } + + return results; +} diff --git a/packages/web-extension/test/SessionList.test.tsx b/packages/web-extension/test/SessionList.test.tsx new file mode 100644 index 0000000000..42dd0b76b2 --- /dev/null +++ b/packages/web-extension/test/SessionList.test.tsx @@ -0,0 +1,400 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '../src/types'; +import { SessionList } from '../src/pages/SessionList'; + +const browser = vi.hoisted(() => ({ + storage: { + local: {}, + }, +})); + +const storage = vi.hoisted(() => ({ + getAllSessions: vi.fn(), + deleteSessions: vi.fn(), + downloadSessions: vi.fn(), + addSession: vi.fn(), + updateSession: vi.fn(), +})); + +const cloudSettings = vi.hoisted(() => ({ + loadCloudSettings: vi.fn(), +})); + +const cloudUpload = vi.hoisted(() => ({ + uploadSessions: vi.fn(), +})); + +vi.mock('webextension-polyfill', () => ({ default: browser })); +vi.mock('../src/utils/storage', () => storage); +vi.mock('../src/utils/cloud-settings', () => cloudSettings); +vi.mock('../src/utils/cloud-upload', () => cloudUpload); +vi.mock('../src/utils/channel', () => ({ + default: class { + on = vi.fn(); + emit = vi.fn(); + }, +})); + +const sessions: Session[] = [ + { + id: 'session-1', + name: 'Checkout flow', + tags: [], + createTimestamp: 2, + modifyTimestamp: 2, + recorderVersion: '1.0.0', + }, + { + id: 'session-2', + name: 'Sign in flow', + tags: [], + createTimestamp: 1, + modifyTimestamp: 1, + recorderVersion: '1.0.0', + }, +]; + +const paginatedSessions: Session[] = Array.from({ length: 11 }, (_, index) => ({ + id: `session-${index + 1}`, + name: `Session ${index + 1}`, + tags: [], + createTimestamp: 11 - index, + modifyTimestamp: 11 - index, + recorderVersion: '1.0.0', +})); + +const duplicateNameSessions: Session[] = sessions.map((session) => ({ + ...session, + name: 'Recording', +})); + +function selectionLabel(name: string, id: string) { + return `Select ${name} (${id})`; +} + +function renderSessionList() { + return render( + + + + + , + ); +} + +async function selectFirstSession() { + await screen.findByText('Checkout flow'); + const checkbox = screen.getByRole('checkbox', { + name: selectionLabel('Checkout flow', 'session-1'), + }) as HTMLInputElement; + await userEvent.click(checkbox); + expect(checkbox.checked).toBe(true); + await screen.findByRole('button', { name: 'Upload' }); +} + +describe('SessionList cloud uploads', () => { + beforeEach(() => { + storage.getAllSessions.mockResolvedValue(sessions); + storage.deleteSessions.mockReset(); + storage.deleteSessions.mockResolvedValue(undefined); + storage.downloadSessions.mockReset(); + storage.addSession.mockReset(); + storage.updateSession.mockReset(); + cloudSettings.loadCloudSettings.mockResolvedValue({ + apiBaseUrl: 'https://api.rrweb.com', + authToken: 'token', + }); + cloudUpload.uploadSessions.mockResolvedValue([ + { id: 'session-1', name: 'Checkout flow', ok: true }, + ]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uploads only the selected saved session with locally loaded settings', async () => { + renderSessionList(); + await selectFirstSession(); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + await waitFor(() => { + expect(cloudSettings.loadCloudSettings).toHaveBeenCalledWith( + browser.storage.local, + ); + expect(cloudUpload.uploadSessions).toHaveBeenCalledWith(['session-1'], { + apiBaseUrl: 'https://api.rrweb.com', + authToken: 'token', + }); + }); + expect(await screen.findByText('Upload complete')).toBeTruthy(); + expect(screen.getByText('Uploaded 1 selected session.')).toBeTruthy(); + expect(screen.getByText('Checkout flow')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Upload' })).toBeTruthy(); + expect( + ( + screen.getByRole('checkbox', { + name: selectionLabel('Checkout flow', 'session-1'), + }) as HTMLInputElement + ).checked, + ).toBe(true); + }); + + it('reports partial failures by session name without clearing the selection', async () => { + cloudUpload.uploadSessions.mockResolvedValue([ + { id: 'session-1', name: 'Checkout flow', ok: true }, + { + id: 'session-2', + name: 'Sign in flow', + ok: false, + error: 'Upload failed: 500 Server Error', + }, + ]); + renderSessionList(); + await screen.findByText('Checkout flow'); + await userEvent.click( + screen.getByRole('checkbox', { + name: selectionLabel('Checkout flow', 'session-1'), + }), + ); + await userEvent.click( + screen.getByRole('checkbox', { + name: selectionLabel('Sign in flow', 'session-2'), + }), + ); + await screen.findByRole('button', { name: 'Upload' }); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect( + await screen.findByText('Upload completed with errors'), + ).toBeTruthy(); + expect(screen.getByText(/Uploaded 1 of 2 selected sessions/)).toBeTruthy(); + expect( + screen.getByText(/Sign in flow: Upload failed: 500 Server Error/), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Upload' })).toBeTruthy(); + expect( + ( + screen.getByRole('checkbox', { + name: selectionLabel('Sign in flow', 'session-2'), + }) as HTMLInputElement + ).checked, + ).toBe(true); + }); + + it('shows a useful error when settings cannot be loaded', async () => { + cloudSettings.loadCloudSettings.mockRejectedValue( + new Error('storage unavailable'), + ); + renderSessionList(); + await selectFirstSession(); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect( + await screen.findByText('Could not load cloud upload settings.'), + ).toBeTruthy(); + expect(screen.getByText('storage unavailable')).toBeTruthy(); + expect(cloudUpload.uploadSessions).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Upload' })).toBeTruthy(); + }); + + it('shows every failed session when no selected upload succeeds', async () => { + cloudUpload.uploadSessions.mockResolvedValue([ + { + id: 'session-1', + name: 'Checkout flow', + ok: false, + error: 'Missing authentication token', + }, + ]); + renderSessionList(); + await selectFirstSession(); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect(await screen.findByText('Upload failed')).toBeTruthy(); + expect( + screen.getByText(/Checkout flow: Missing authentication token/), + ).toBeTruthy(); + }); + + it('shows an upload error and preserves selection when the transport rejects', async () => { + cloudUpload.uploadSessions.mockRejectedValue(new Error('network failed')); + renderSessionList(); + await selectFirstSession(); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect( + await screen.findByText('Could not complete the upload.'), + ).toBeTruthy(); + expect(screen.getByText('network failed')).toBeTruthy(); + expect( + ( + screen.getByRole('checkbox', { + name: selectionLabel('Checkout flow', 'session-1'), + }) as HTMLInputElement + ).checked, + ).toBe(true); + }); + + it('does not substitute a first-page selection after page navigation', async () => { + storage.getAllSessions.mockResolvedValue(paginatedSessions); + renderSessionList(); + await screen.findByText('Session 1'); + await userEvent.click( + screen.getByRole('checkbox', { + name: selectionLabel('Session 1', 'session-1'), + }), + ); + + await userEvent.click( + screen.getByRole('button', { name: 'Goto Next Page' }), + ); + await screen.findByText('Session 11'); + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + await waitFor(() => { + expect(cloudUpload.uploadSessions).toHaveBeenCalledWith( + ['session-1'], + expect.anything(), + ); + }); + expect( + ( + screen.getByRole('checkbox', { + name: selectionLabel('Session 11', 'session-11'), + }) as HTMLInputElement + ).checked, + ).toBe(false); + }); + + it('uploads selected sessions across pages by their stable session IDs', async () => { + storage.getAllSessions.mockResolvedValue(paginatedSessions); + cloudUpload.uploadSessions.mockResolvedValue([ + { id: 'session-1', name: 'Session 1', ok: true }, + { id: 'session-11', name: 'Session 11', ok: true }, + ]); + renderSessionList(); + await screen.findByText('Session 1'); + await userEvent.click( + screen.getByRole('checkbox', { + name: selectionLabel('Session 1', 'session-1'), + }), + ); + await userEvent.click( + screen.getByRole('button', { name: 'Goto Next Page' }), + ); + await screen.findByText('Session 11'); + await userEvent.click( + screen.getByRole('checkbox', { + name: selectionLabel('Session 11', 'session-11'), + }), + ); + await userEvent.click( + screen.getByRole('button', { name: 'Goto Previous Page' }), + ); + await screen.findByText('Session 1'); + expect( + ( + screen.getByRole('checkbox', { + name: selectionLabel('Session 1', 'session-1'), + }) as HTMLInputElement + ).checked, + ).toBe(true); + await userEvent.click( + screen.getByRole('button', { name: 'Goto Next Page' }), + ); + await screen.findByText('Session 11'); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + await waitFor(() => { + expect(cloudUpload.uploadSessions).toHaveBeenCalledWith( + ['session-1', 'session-11'], + expect.anything(), + ); + }); + }); + + it('makes duplicate session names uniquely selectable by stable session ID', async () => { + storage.getAllSessions.mockResolvedValue(duplicateNameSessions); + renderSessionList(); + + await screen.findAllByText('Recording'); + + expect( + screen.getByRole('checkbox', { + name: selectionLabel('Recording', 'session-1'), + }), + ).toBeTruthy(); + expect( + screen.getByRole('checkbox', { + name: selectionLabel('Recording', 'session-2'), + }), + ).toBeTruthy(); + }); + + it('deletes cross-page selections by stable session IDs', async () => { + storage.getAllSessions.mockResolvedValue(paginatedSessions); + renderSessionList(); + await screen.findByText('Session 1'); + await userEvent.click( + screen.getByRole('checkbox', { + name: selectionLabel('Session 1', 'session-1'), + }), + ); + await userEvent.click( + screen.getByRole('button', { name: 'Goto Next Page' }), + ); + await screen.findByText('Session 11'); + await userEvent.click( + screen.getByRole('checkbox', { + name: selectionLabel('Session 11', 'session-11'), + }), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => { + expect(storage.deleteSessions).toHaveBeenCalledWith([ + 'session-1', + 'session-11', + ]); + }); + }); + + it('downloads cross-page selections by stable session IDs', async () => { + storage.getAllSessions.mockResolvedValue(paginatedSessions); + renderSessionList(); + await screen.findByText('Session 1'); + await userEvent.click( + screen.getByRole('checkbox', { + name: selectionLabel('Session 1', 'session-1'), + }), + ); + await userEvent.click( + screen.getByRole('button', { name: 'Goto Next Page' }), + ); + await screen.findByText('Session 11'); + await userEvent.click( + screen.getByRole('checkbox', { + name: selectionLabel('Session 11', 'session-11'), + }), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Download' })); + + expect(storage.downloadSessions).toHaveBeenCalledWith([ + 'session-1', + 'session-11', + ]); + }); +}); diff --git a/packages/web-extension/test/Settings.test.tsx b/packages/web-extension/test/Settings.test.tsx new file mode 100644 index 0000000000..4a2b70fc30 --- /dev/null +++ b/packages/web-extension/test/Settings.test.tsx @@ -0,0 +1,310 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_CLOUD_SETTINGS } from '../src/utils/cloud-settings'; +import { SettingsView } from '../src/options/Settings'; + +const browser = vi.hoisted(() => ({ + storage: { + local: { + get: vi.fn(), + set: vi.fn(), + }, + sync: { + get: vi.fn(), + set: vi.fn(), + }, + }, +})); + +vi.mock('webextension-polyfill', () => ({ default: browser })); + +function renderSettings() { + return render( + + + , + ); +} + +describe('SettingsView', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(() => { + browser.storage.local.get.mockReset(); + browser.storage.local.set.mockReset(); + browser.storage.local.get.mockResolvedValue({}); + browser.storage.local.set.mockResolvedValue(undefined); + browser.storage.sync.get.mockReset(); + browser.storage.sync.set.mockReset(); + }); + + it('loads the default URL from local storage without reading sync storage', async () => { + renderSettings(); + + expect( + ((await screen.findByLabelText('Cloud API base URL')) as HTMLInputElement) + .value, + ).toBe(DEFAULT_CLOUD_SETTINGS.apiBaseUrl); + expect(browser.storage.local.get).toHaveBeenCalledWith( + 'rrweb-cloud-settings', + ); + expect(browser.storage.sync.get).not.toHaveBeenCalled(); + }); + + it('warns that remote upload endpoints should use HTTPS', async () => { + renderSettings(); + + const warning = await screen.findByText( + /Use HTTPS for remote endpoints\. HTTP is only appropriate for trusted local development because uploads include your bearer token and recording data\./, + ); + + expect(warning.closest('[role="alert"]')).not.toBeNull(); + }); + + it('populates the form with the stored cloud settings', async () => { + browser.storage.local.get.mockResolvedValue({ + 'rrweb-cloud-settings': { + apiBaseUrl: 'https://stored.example.test', + authToken: 'stored-token', + }, + }); + + renderSettings(); + + expect( + ((await screen.findByLabelText('Cloud API base URL')) as HTMLInputElement) + .value, + ).toBe('https://stored.example.test'); + expect( + (screen.getByLabelText('Authentication token') as HTMLInputElement).value, + ).toBe('stored-token'); + }); + + it('saves normalized cloud settings in local storage', async () => { + const user = userEvent.setup(); + renderSettings(); + + const apiBaseUrl = await screen.findByLabelText('Cloud API base URL'); + await user.clear(apiBaseUrl); + await user.type(apiBaseUrl, 'https://uploads.example.test/'); + await user.type( + screen.getByLabelText('Authentication token'), + 'entered-token', + ); + await user.click(screen.getByRole('button', { name: 'Save settings' })); + + await waitFor(() => { + expect(browser.storage.local.set).toHaveBeenCalledWith({ + 'rrweb-cloud-settings': { + apiBaseUrl: 'https://uploads.example.test', + authToken: 'entered-token', + }, + }); + }); + expect(browser.storage.sync.set).not.toHaveBeenCalled(); + expect( + await screen.findByText('Cloud upload settings saved.'), + ).toBeTruthy(); + }); + + it('shows a validation error for an FTP URL without writing storage', async () => { + const user = userEvent.setup(); + renderSettings(); + + const apiBaseUrl = await screen.findByLabelText('Cloud API base URL'); + await user.clear(apiBaseUrl); + await user.type(apiBaseUrl, 'ftp://uploads.example.test'); + await user.click(screen.getByRole('button', { name: 'Save settings' })); + + expect( + await screen.findByText('Please enter a valid HTTP or HTTPS URL.'), + ).toBeTruthy(); + expect(browser.storage.local.set).not.toHaveBeenCalled(); + expect(browser.storage.sync.set).not.toHaveBeenCalled(); + }); + + it('shows a validation error for a malformed URL without writing storage', async () => { + const user = userEvent.setup(); + renderSettings(); + + const apiBaseUrl = await screen.findByLabelText('Cloud API base URL'); + await user.clear(apiBaseUrl); + await user.type(apiBaseUrl, 'not a URL'); + await user.click(screen.getByRole('button', { name: 'Save settings' })); + + expect( + await screen.findByText('Please enter a valid HTTP or HTTPS URL.'), + ).toBeTruthy(); + expect(browser.storage.local.set).not.toHaveBeenCalled(); + expect(browser.storage.sync.set).not.toHaveBeenCalled(); + }); + + it('uses a password input for the authentication token', async () => { + renderSettings(); + + expect( + (await screen.findByLabelText('Authentication token')).getAttribute( + 'type', + ), + ).toBe('password'); + }); + + it('keeps save disabled after a load failure until a retry succeeds', async () => { + browser.storage.local.get.mockRejectedValueOnce( + new Error('storage unavailable'), + ); + browser.storage.local.get.mockResolvedValueOnce({ + 'rrweb-cloud-settings': { + apiBaseUrl: 'https://recovered.example.test', + authToken: 'recovered-token', + }, + }); + + renderSettings(); + + expect( + await screen.findByText( + 'Could not load cloud upload settings. Please try again.', + ), + ).toBeTruthy(); + expect( + ( + screen.getByRole('button', { + name: 'Save settings', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(browser.storage.local.set).not.toHaveBeenCalled(); + + await userEvent.click( + screen.getByRole('button', { name: 'Retry loading settings' }), + ); + + await waitFor(() => { + expect( + (screen.getByLabelText('Cloud API base URL') as HTMLInputElement).value, + ).toBe('https://recovered.example.test'); + }); + expect( + screen.queryByText( + 'Could not load cloud upload settings. Please try again.', + ), + ).toBeNull(); + expect( + ( + screen.getByRole('button', { + name: 'Save settings', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + }); + + it('lets users recover from invalid stored settings without overwriting them', async () => { + const user = userEvent.setup(); + browser.storage.local.get.mockResolvedValue({ + 'rrweb-cloud-settings': { + apiBaseUrl: 'ftp://invalid.example.test', + authToken: 'stored-secret-token', + }, + }); + + renderSettings(); + + expect( + await screen.findByText( + 'Could not load cloud upload settings. Please try again.', + ), + ).toBeTruthy(); + expect( + ( + screen.getByRole('button', { + name: 'Save settings', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + + await user.click( + screen.getByRole('button', { name: 'Retry loading settings' }), + ); + + await waitFor(() => { + expect(browser.storage.local.get).toHaveBeenCalledTimes(2); + }); + expect( + screen.getByText( + 'Could not load cloud upload settings. Please try again.', + ), + ).toBeTruthy(); + + await user.click( + screen.getByRole('button', { name: 'Use default settings' }), + ); + + expect( + (screen.getByLabelText('Cloud API base URL') as HTMLInputElement).value, + ).toBe(DEFAULT_CLOUD_SETTINGS.apiBaseUrl); + expect( + (screen.getByLabelText('Authentication token') as HTMLInputElement).value, + ).toBe(''); + expect(browser.storage.local.set).not.toHaveBeenCalled(); + expect( + screen.queryByText( + 'Could not load cloud upload settings. Please try again.', + ), + ).toBeNull(); + expect( + ( + screen.getByRole('button', { + name: 'Save settings', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + + const apiBaseUrl = screen.getByLabelText('Cloud API base URL'); + await user.clear(apiBaseUrl); + await user.type(apiBaseUrl, 'https://recovered.example.test'); + await user.click(screen.getByRole('button', { name: 'Save settings' })); + + await waitFor(() => { + expect(browser.storage.local.set).toHaveBeenCalledWith({ + 'rrweb-cloud-settings': { + apiBaseUrl: 'https://recovered.example.test', + authToken: '', + }, + }); + }); + }); + + it('shows save failures without logging configured credentials', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + browser.storage.local.get.mockResolvedValue({ + 'rrweb-cloud-settings': { + apiBaseUrl: 'https://configured.example.test', + authToken: 'configured-token', + }, + }); + browser.storage.local.set.mockRejectedValue( + new Error('storage unavailable'), + ); + + renderSettings(); + + await screen.findByDisplayValue('https://configured.example.test'); + await userEvent.click( + screen.getByRole('button', { name: 'Save settings' }), + ); + + expect( + await screen.findByText( + 'Could not save cloud upload settings. Please try again.', + ), + ).toBeTruthy(); + expect(consoleError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web-extension/test/cloud-settings.test.ts b/packages/web-extension/test/cloud-settings.test.ts new file mode 100644 index 0000000000..525b3b58a7 --- /dev/null +++ b/packages/web-extension/test/cloud-settings.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CLOUD_SETTINGS_STORAGE_KEY, + DEFAULT_CLOUD_SETTINGS, + loadCloudSettings, + normalizeApiBaseUrl, + normalizeCloudSettings, + saveCloudSettings, +} from '../src/utils/cloud-settings'; + +describe('cloud settings', () => { + it('returns defaults when storage has no cloud settings', async () => { + const storage = { + get: vi.fn().mockResolvedValue({}), + set: vi.fn(), + }; + + await expect(loadCloudSettings(storage)).resolves.toEqual( + DEFAULT_CLOUD_SETTINGS, + ); + expect(storage.get).toHaveBeenCalledWith(CLOUD_SETTINGS_STORAGE_KEY); + }); + + it('saves normalized settings using the local cloud settings key', async () => { + const storage = { + get: vi.fn(), + set: vi.fn().mockResolvedValue(undefined), + }; + + await saveCloudSettings(storage, { + apiBaseUrl: ' https://cloud.example.com/// ', + authToken: ' token ', + }); + + expect(storage.set).toHaveBeenCalledWith({ + [CLOUD_SETTINGS_STORAGE_KEY]: { + apiBaseUrl: 'https://cloud.example.com', + authToken: 'token', + }, + }); + }); + + it('rejects FTP API base URLs', () => { + expect(() => normalizeApiBaseUrl('ftp://cloud.example.com')).toThrow( + 'URL must be valid HTTP/HTTPS', + ); + }); + + it('rejects malformed API base URLs', () => { + expect(() => normalizeApiBaseUrl('not a URL')).toThrow( + 'URL must be valid HTTP/HTTPS', + ); + }); + + it.each([ + 'https://user:password@cloud.example.com', + 'https://cloud.example.com?token=secret', + 'https://cloud.example.com#fragment', + 'https://cloud.example.com?', + 'https://cloud.example.com#', + ])('rejects disallowed API base URL components: %s', (value) => { + expect(() => normalizeApiBaseUrl(value)).toThrow( + 'URL must be valid HTTP/HTTPS', + ); + }); + + it('applies defaults when loading a partial stored value', async () => { + const storage = { + get: vi.fn().mockResolvedValue({ + [CLOUD_SETTINGS_STORAGE_KEY]: { authToken: ' token ' }, + }), + set: vi.fn(), + }; + + await expect(loadCloudSettings(storage)).resolves.toEqual({ + apiBaseUrl: DEFAULT_CLOUD_SETTINGS.apiBaseUrl, + authToken: 'token', + }); + }); + + it('normalizes partial settings without mutating defaults', () => { + expect(normalizeCloudSettings()).toEqual(DEFAULT_CLOUD_SETTINGS); + }); + + it('uses the default API URL when a configured API URL is blank', () => { + expect( + normalizeCloudSettings({ apiBaseUrl: ' ', authToken: 'token' }), + ).toEqual({ + apiBaseUrl: DEFAULT_CLOUD_SETTINGS.apiBaseUrl, + authToken: 'token', + }); + }); +}); diff --git a/packages/web-extension/test/cloud-upload.test.ts b/packages/web-extension/test/cloud-upload.test.ts new file mode 100644 index 0000000000..a2da708c0d --- /dev/null +++ b/packages/web-extension/test/cloud-upload.test.ts @@ -0,0 +1,355 @@ +import type { eventWithTime } from '@rrweb/types'; +import type { CloudSettings, Session } from '~/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_UPLOAD_TIMEOUT_MS, + buildUploadUrl, + type UploadDependencies, + uploadSessions, +} from '../src/utils/cloud-upload'; + +const settings: CloudSettings = { + apiBaseUrl: ' https://cloud.example.com/api/// ', + authToken: ' token ', +}; + +const session = (id: string, name = `Session ${id}`): Session => ({ + id, + name, + tags: [], + createTimestamp: 0, + modifyTimestamp: 0, + recorderVersion: '2.0.0', +}); + +const event = (timestamp: number): eventWithTime => + ({ type: 2, timestamp, data: {} } as eventWithTime); + +const response = (status = 200, statusText = 'OK') => + ({ ok: status >= 200 && status < 300, status, statusText } as Response); + +function dependencies( + overrides: Partial = {}, +): UploadDependencies { + return { + getSession: vi.fn(async (id: string) => session(id)), + getEvents: vi.fn(async () => [event(1), event(2)]), + fetchFn: vi.fn(async () => response()), + compress: vi.fn(async () => new ArrayBuffer(1)), + compressionStreamCtor: + CompressionStream as unknown as UploadDependencies['compressionStreamCtor'], + ...overrides, + }; +} + +describe('cloud upload', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('uses a thirty-second upload timeout by default', () => { + expect(DEFAULT_UPLOAD_TIMEOUT_MS).toBe(30_000); + }); + + it('builds a normalized endpoint with encoded session IDs', () => { + expect(buildUploadUrl('https://cloud.example.com/', 'a/b')).toBe( + 'https://cloud.example.com/recordings/a%2Fb/ingest', + ); + expect(buildUploadUrl(' https://cloud.example.com/api/// ', 'a/b')).toBe( + 'https://cloud.example.com/api/recordings/a%2Fb/ingest', + ); + }); + + it('short-circuits all IDs before storage or fetch when the token is missing', async () => { + const deps = dependencies(); + + await expect( + uploadSessions(['one', 'two'], { ...settings, authToken: ' ' }, deps), + ).resolves.toEqual([ + { + id: 'one', + name: 'one', + ok: false, + error: 'Missing authentication token', + }, + { + id: 'two', + name: 'two', + ok: false, + error: 'Missing authentication token', + }, + ]); + expect(deps.getSession).not.toHaveBeenCalled(); + expect(deps.getEvents).not.toHaveBeenCalled(); + expect(deps.fetchFn).not.toHaveBeenCalled(); + }); + + it('uploads Brotli-compressed NDJSON with the authenticated endpoint', async () => { + const compressed = new Uint8Array([1, 2, 3]).buffer; + const deps = dependencies({ + compress: vi.fn(async () => compressed), + }); + + await expect(uploadSessions(['a/b'], settings, deps)).resolves.toEqual([ + { id: 'a/b', name: 'Session a/b', ok: true }, + ]); + expect(deps.compress).toHaveBeenCalledWith( + `${JSON.stringify(event(1))}\n${JSON.stringify(event(2))}`, + 'brotli', + ); + expect(deps.fetchFn).toHaveBeenCalledWith( + 'https://cloud.example.com/api/recordings/a%2Fb/ingest', + expect.objectContaining({ + method: 'POST', + body: compressed, + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/x-ndjson', + 'Content-Encoding': 'br', + }, + }), + ); + }); + + it('uses the default CompressionStream compressor with the Brotli format', async () => { + const compressionStreamCtor = vi.fn(function () { + return new TransformStream(); + }); + const fetchFn = vi.fn(async () => response()); + const deps = { + getSession: vi.fn(async (id: string) => session(id)), + getEvents: vi.fn(async () => [event(1)]), + fetchFn, + compressionStreamCtor, + }; + + await uploadSessions(['one'], settings, deps); + + expect(compressionStreamCtor).toHaveBeenCalledWith('brotli'); + expect(fetchFn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'Content-Encoding': 'br' }), + }), + ); + }); + + it('falls back from Brotli to gzip', async () => { + const gzip = new Uint8Array([4, 5]).buffer; + const deps = dependencies({ + compress: vi + .fn() + .mockRejectedValueOnce(new Error('Brotli unsupported')) + .mockResolvedValueOnce(gzip), + }); + + await uploadSessions(['one'], settings, deps); + + expect(deps.compress).toHaveBeenNthCalledWith( + 1, + `${JSON.stringify(event(1))}\n${JSON.stringify(event(2))}`, + 'brotli', + ); + expect(deps.compress).toHaveBeenNthCalledWith( + 2, + `${JSON.stringify(event(1))}\n${JSON.stringify(event(2))}`, + 'gzip', + ); + expect(deps.fetchFn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: gzip, + headers: expect.objectContaining({ 'Content-Encoding': 'gzip' }), + }), + ); + }); + + it('uploads raw NDJSON without a content encoding when compression is unavailable', async () => { + const deps = dependencies({ + compress: vi.fn().mockRejectedValue(new Error('unsupported')), + }); + const ndjson = `${JSON.stringify(event(1))}\n${JSON.stringify(event(2))}`; + + await uploadSessions(['one'], settings, deps); + + expect(deps.fetchFn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: ndjson, + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/x-ndjson', + }, + }), + ); + }); + + it('returns a concise HTTP failure without reading the response body', async () => { + const deps = dependencies({ + fetchFn: vi.fn(async () => response(401, 'Unauthorized')), + }); + + await expect(uploadSessions(['one'], settings, deps)).resolves.toEqual([ + { + id: 'one', + name: 'Session one', + ok: false, + error: 'Upload failed: 401 Unauthorized', + }, + ]); + }); + + it('continues after one session fails and uploads the next', async () => { + const deps = dependencies({ + getSession: vi.fn(async (id: string) => + id === 'broken' ? undefined : session(id), + ), + }); + + await expect( + uploadSessions(['broken', 'valid'], settings, deps), + ).resolves.toEqual([ + { id: 'broken', name: 'broken', ok: false, error: 'Session not found' }, + { id: 'valid', name: 'Session valid', ok: true }, + ]); + expect(deps.fetchFn).toHaveBeenCalledTimes(1); + }); + + it('reports missing sessions and invalid event data independently', async () => { + const deps = dependencies({ + getSession: vi.fn(async (id: string) => + id === 'missing' ? undefined : session(id), + ), + getEvents: vi.fn(async (id: string) => (id === 'invalid' ? {} : [])), + }); + + await expect( + uploadSessions(['missing', 'invalid'], settings, deps), + ).resolves.toEqual([ + { id: 'missing', name: 'missing', ok: false, error: 'Session not found' }, + { + id: 'invalid', + name: 'Session invalid', + ok: false, + error: 'Session events are invalid', + }, + ]); + expect(deps.fetchFn).not.toHaveBeenCalled(); + }); + + it('reports unavailable events with a stable message and continues uploading', async () => { + const deps = dependencies({ + getEvents: vi.fn(async (id: string) => { + if (id === 'missing-events') { + throw new TypeError( + "Cannot read properties of undefined (reading 'events')", + ); + } + + return [event(1)]; + }), + }); + + await expect( + uploadSessions(['missing-events', 'valid'], settings, deps), + ).resolves.toEqual([ + { + id: 'missing-events', + name: 'Session missing-events', + ok: false, + error: 'Session events could not be loaded', + }, + { id: 'valid', name: 'Session valid', ok: true }, + ]); + expect(deps.fetchFn).toHaveBeenCalledTimes(1); + }); + + it('converts fetch rejections into a per-session failure', async () => { + const deps = dependencies({ + fetchFn: vi.fn(async () => { + throw new Error('Network unavailable'); + }), + }); + + await expect(uploadSessions(['one'], settings, deps)).resolves.toEqual([ + { + id: 'one', + name: 'Session one', + ok: false, + error: 'Network unavailable', + }, + ]); + }); + + it('reports a stable failure when a request hangs past its timeout', async () => { + vi.useFakeTimers(); + const deps = dependencies({ + fetchFn: vi.fn(() => new Promise(() => {})), + uploadTimeoutMs: 10, + }); + const upload = uploadSessions(['one'], settings, deps); + + await vi.advanceTimersByTimeAsync(10); + + await expect(upload).resolves.toEqual([ + { + id: 'one', + name: 'Session one', + ok: false, + error: 'Upload timed out', + }, + ]); + }); + + it('aborts the request signal when an upload times out', async () => { + vi.useFakeTimers(); + let requestSignal: AbortSignal | undefined; + const deps = dependencies({ + fetchFn: vi.fn((_url, init) => { + requestSignal = init?.signal ?? undefined; + return new Promise(() => {}); + }), + uploadTimeoutMs: 10, + }); + const upload = uploadSessions(['one'], settings, deps); + + await vi.advanceTimersByTimeAsync(10); + + expect(requestSignal?.aborted).toBe(true); + await expect(upload).resolves.toHaveLength(1); + }); + + it('continues the batch after timing out a hung request', async () => { + vi.useFakeTimers(); + const fetchFn = vi + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockImplementationOnce(async () => response()); + const deps = dependencies({ fetchFn, uploadTimeoutMs: 10 }); + const upload = uploadSessions(['stalled', 'next'], settings, deps); + + await vi.advanceTimersByTimeAsync(10); + + await expect(upload).resolves.toEqual([ + { + id: 'stalled', + name: 'Session stalled', + ok: false, + error: 'Upload timed out', + }, + { id: 'next', name: 'Session next', ok: true }, + ]); + }); + + it('does not log while uploading', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await uploadSessions(['one'], settings, dependencies()); + + expect(log).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web-extension/test/setup.ts b/packages/web-extension/test/setup.ts new file mode 100644 index 0000000000..a0e7695d38 --- /dev/null +++ b/packages/web-extension/test/setup.ts @@ -0,0 +1,18 @@ +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(cleanup); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string): MediaQueryList => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); diff --git a/packages/web-extension/tsconfig.json b/packages/web-extension/tsconfig.json index 759f972d89..c8060f364b 100644 --- a/packages/web-extension/tsconfig.json +++ b/packages/web-extension/tsconfig.json @@ -12,7 +12,13 @@ }, "jsx": "react-jsx" }, - "exclude": ["dist", "node_modules", "vite.config.ts"], + "exclude": [ + "dist", + "node_modules", + "vite.config.ts", + "vitest.config.ts", + "test" + ], "references": [ { "path": "../rrweb" diff --git a/packages/web-extension/vitest.config.ts b/packages/web-extension/vitest.config.ts new file mode 100644 index 0000000000..7cc23b1aa5 --- /dev/null +++ b/packages/web-extension/vitest.config.ts @@ -0,0 +1,20 @@ +/// +import { resolve } from 'node:path'; +import { defineProject, mergeConfig } from 'vitest/config'; +import configShared from '../../vitest.config'; + +export default mergeConfig( + configShared, + defineProject({ + resolve: { + alias: { + '~': resolve(__dirname, './src'), + }, + }, + test: { + environment: 'happy-dom', + globals: true, + setupFiles: ['./test/setup.ts'], + }, + }), +); diff --git a/yarn.lock b/yarn.lock index 42046dfb50..3cf776f1b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18,6 +18,15 @@ "@babel/highlight" "^7.24.7" picocolors "^1.0.0" +"@babel/code-frame@^7.10.4": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.7.tgz#d23bbea508c3883ba8251fb4164982c36ea577ed" @@ -136,6 +145,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + "@babel/helper-validator-option@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz#24c3bb77c7a425d1742eec8fb433b5a1b38e62f6" @@ -2609,6 +2623,34 @@ resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.17.3.tgz#d7a9830abb29cd369b52b2a7159dc0360af646fd" integrity sha512-mPBodDGVL+fl6d90wUREepHa/7lhsghg2A3vFpakEhrhtbIlgNAZiMr7ccTgak5qbHqF14Fwy+W1yFWQt+WmYQ== +"@testing-library/dom@^9.0.0": + version "9.3.4" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" + integrity sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.1.3" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + pretty-format "^27.0.2" + +"@testing-library/react@^14.3.1": + version "14.3.1" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.3.1.tgz#29513fc3770d6fb75245c4e1245c470e4ffdd830" + integrity sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^9.0.0" + "@types/react-dom" "^18.0.0" + +"@testing-library/user-event@^14.6.1": + version "14.6.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" + integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -2649,6 +2691,11 @@ resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA== +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14", "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -2932,6 +2979,11 @@ dependencies: "@types/node" "*" +"@types/react-dom@^18.0.0": + version "18.3.7" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" + integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== + "@types/react-dom@^18.0.6": version "18.3.0" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" @@ -3483,6 +3535,13 @@ aria-hidden@^1.2.3: dependencies: tslib "^2.0.0" +aria-query@5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" + integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== + dependencies: + deep-equal "^2.0.5" + aria-query@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" @@ -3490,6 +3549,14 @@ aria-query@^5.3.0: dependencies: dequal "^2.0.3" +array-buffer-byte-length@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== + dependencies: + call-bound "^1.0.3" + is-array-buffer "^3.0.5" + array-differ@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-4.0.0.tgz#aa3c891c653523290c880022f45b06a42051b026" @@ -3554,6 +3621,13 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + axobject-query@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.0.0.tgz#04a4c90dce33cc5d606c76d6216e3b250ff70dab" @@ -3831,6 +3905,24 @@ cacheable-request@^10.2.8: normalize-url "^8.0.0" responselike "^3.0.0" +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.8, call-bind@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.9.tgz#39a644700c80bc7d0ca9102fc6d1d43b2fd7eee7" + integrity sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + get-intrinsic "^1.3.0" + set-function-length "^1.2.2" + call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -3842,6 +3934,14 @@ call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -4498,6 +4598,30 @@ deep-eql@^4.1.3: dependencies: type-detect "^4.0.0" +deep-equal@^2.0.5: + version "2.2.3" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1" + integrity sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA== + dependencies: + array-buffer-byte-length "^1.0.0" + call-bind "^1.0.5" + es-get-iterator "^1.1.3" + get-intrinsic "^1.2.2" + is-arguments "^1.1.1" + is-array-buffer "^3.0.2" + is-date-object "^1.0.5" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.2" + isarray "^2.0.5" + object-is "^1.1.5" + object-keys "^1.1.1" + object.assign "^4.1.4" + regexp.prototype.flags "^1.5.1" + side-channel "^1.0.4" + which-boxed-primitive "^1.0.2" + which-collection "^1.0.1" + which-typed-array "^1.1.13" + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -4525,7 +4649,7 @@ defer-to-connect@^2.0.1: resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== -define-data-property@^1.1.4: +define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== @@ -4539,6 +4663,15 @@ define-lazy-prop@^2.0.0: resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== +define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + degenerator@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-5.0.1.tgz#9403bf297c6dad9a1ece409b37db27954f91f2f5" @@ -4637,6 +4770,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" @@ -4693,6 +4831,15 @@ dtrace-provider@~0.8: dependencies: nan "^2.14.0" +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -4767,11 +4914,38 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== +es-get-iterator@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" + integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== + dependencies: + call-bind "^1.0.2" + get-intrinsic "^1.1.3" + has-symbols "^1.0.3" + is-arguments "^1.1.1" + is-map "^2.0.2" + is-set "^2.0.2" + is-string "^1.0.7" + isarray "^2.0.5" + stop-iteration-iterator "^1.0.0" + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + es6-error@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" @@ -5351,6 +5525,13 @@ focus-lock@^1.3.5: dependencies: tslib "^2.0.3" +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + form-data-encoder@^2.1.2: version "2.1.4" resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" @@ -5464,6 +5645,11 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +functions-have-names@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" + integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== + fx-runner@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/fx-runner/-/fx-runner-1.4.0.tgz#7a3f0374cc78c6c689ef75937b7b0cd75428c509" @@ -5502,6 +5688,22 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.2, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-nonce@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" @@ -5512,6 +5714,14 @@ get-package-type@^0.1.0: resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-stdin@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398" @@ -5644,6 +5854,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + got@^12.1.0: version "12.6.1" resolved "https://registry.yarnpkg.com/got/-/got-12.6.1.tgz#8869560d1383353204b5a9435f782df9c091f549" @@ -5705,6 +5920,11 @@ harmony-reflect@^1.4.6: resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.2.tgz#31ecbd32e648a34d030d86adb67d4d47547fe710" integrity sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g== +has-bigints@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" + integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -5720,7 +5940,7 @@ has-own-prop@^2.0.0: resolved "https://registry.yarnpkg.com/has-own-prop/-/has-own-prop-2.0.0.tgz#f0f95d58f65804f5d218db32563bb85b8e0417af" integrity sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ== -has-property-descriptors@^1.0.2: +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== @@ -5737,6 +5957,18 @@ has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + has-yarn@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-3.0.0.tgz#c3c21e559730d1d3b57e28af1f30d06fac38147d" @@ -5749,6 +5981,13 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" +hasown@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -6006,6 +6245,15 @@ inquirer@^9.0.0: strip-ansi "^6.0.1" wrap-ansi "^6.2.0" +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -6033,11 +6281,35 @@ is-absolute@^0.1.7: dependencies: is-relative "^0.1.0" +is-arguments@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b" + integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-array-buffer@^3.0.2, is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -6045,11 +6317,24 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-buffer@~1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== +is-callable@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" + integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== + is-ci@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" @@ -6064,6 +6349,14 @@ is-core-module@^2.1.0, is-core-module@^2.13.0: dependencies: hasown "^2.0.0" +is-date-object@^1.0.5: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + is-docker@^2.0.0, is-docker@^2.1.1: version "2.2.1" resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" @@ -6104,11 +6397,24 @@ is-interactive@^1.0.0: resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== +is-map@^2.0.2, is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + is-npm@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-6.0.0.tgz#b59e75e8915543ca5d881ecff864077cba095261" integrity sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ== +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -6141,11 +6447,33 @@ is-reference@^3.0.0, is-reference@^3.0.1: dependencies: "@types/estree" "*" +is-regex@^1.1.4, is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + is-relative@^0.1.0: version "0.1.3" resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-0.1.3.tgz#905fee8ae86f45b3ec614bc3c15c869df0876e82" integrity sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA== +is-set@^2.0.2, is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -6156,6 +6484,14 @@ is-stream@^3.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== +is-string@^1.0.7, is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-subdir@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/is-subdir/-/is-subdir-1.2.0.tgz#b791cd28fab5202e91a08280d51d9d7254fd20d4" @@ -6163,6 +6499,15 @@ is-subdir@^1.1.1: dependencies: better-path-resolve "1.0.0" +is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -6173,6 +6518,19 @@ is-unicode-supported@^0.1.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-windows@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" @@ -6190,6 +6548,11 @@ is-yarn-global@^0.4.0: resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.4.1.tgz#b312d902b313f81e4eaf98b6361ba2b45cd694bb" integrity sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ== +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -7099,6 +7462,11 @@ lru-cache@^7.14.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + magic-string@^0.30.10, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@^0.30.8: version "0.30.10" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.10.tgz#123d9c41a0cb5640c892b041d4cfb3bd0aa4b39e" @@ -7179,6 +7547,11 @@ marky@^1.2.2: resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0" integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + md5@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" @@ -7598,6 +7971,36 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.4: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + on-finished@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" @@ -7980,6 +8383,11 @@ pngjs@^6.0.0: resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821" integrity sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg== +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + postcss-load-config@^3.1.4: version "3.1.4" resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855" @@ -8044,7 +8452,7 @@ prettier@^2.7.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== -pretty-format@^27.0.0, pretty-format@^27.5.1: +pretty-format@^27.0.0, pretty-format@^27.0.2, pretty-format@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== @@ -8449,6 +8857,18 @@ regenerator-runtime@^0.14.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== +regexp.prototype.flags@^1.5.1: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + registry-auth-token@^5.0.1: version "5.0.2" resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-5.0.2.tgz#8b026cc507c8552ebbe06724136267e63302f756" @@ -8682,6 +9102,15 @@ safe-json-stringify@~1: resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz#356e44bc98f1f93ce45df14bcd7c01cda86e0afd" integrity sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg== +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -8791,7 +9220,7 @@ set-cookie-parser@^2.6.0: resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz#131921e50f62ff1a66a461d7d62d7b21d5d15a51" integrity sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ== -set-function-length@^1.2.1: +set-function-length@^1.2.1, set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -8803,6 +9232,16 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -8840,6 +9279,35 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" @@ -8850,6 +9318,17 @@ side-channel@^1.0.4: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + siginfo@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" @@ -9045,6 +9524,14 @@ std-env@^3.5.0: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== +stop-iteration-iterator@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + streamx@^2.15.0: version "2.18.0" resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.18.0.tgz#5bc1a51eb412a667ebfdcd4e6cf6a6fc65721ac7" @@ -10128,6 +10615,40 @@ when@3.7.7: resolved "https://registry.yarnpkg.com/when/-/when-3.7.7.tgz#aba03fc3bb736d6c88b091d013d8a8e590d84718" integrity sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw== +which-boxed-primitive@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-collection@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.13: + version "1.1.22" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.22.tgz#8f3cc78aefb40b437346dd40a1dbfa5d1da43fe9" + integrity sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.9" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/which/-/which-1.2.4.tgz#1557f96080604e5b11b3599eb9f45b50a9efd722"