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"