├── polyfills ├── index.ts └── querySelectorPolyfill.ts ├── random ├── index.ts ├── randomUtil.ts ├── roomNameGenerator.spec.ts └── randomUtil.spec.ts ├── browser-detection ├── index.ts ├── constants.ts ├── BrowserDetection.ts └── BrowserDetection.spec.ts ├── .eslintrc.cjs ├── .eslintignore ├── index.ts ├── .gitignore ├── json.ts ├── transport ├── index.ts ├── constants.ts ├── types.ts ├── PostMessageTransportBackend.ts ├── postis.ts └── Transport.ts ├── .github ├── dependabot.yml └── workflows │ ├── autopublish.yml │ ├── scripts │ ├── create-test-check.js │ ├── generate-test-summary.sh │ └── parse-test-results.sh │ └── ci.yml ├── tsconfig.json ├── avatar ├── index.ts └── index.spec.ts ├── README.md ├── package.json ├── json.spec.ts ├── web-test-runner.config.mjs ├── CLAUDE.md ├── jitsi-local-storage ├── index.ts └── index.spec.ts └── LICENSE /polyfills/index.ts: -------------------------------------------------------------------------------- 1 | export * from './querySelectorPolyfill'; 2 | -------------------------------------------------------------------------------- /random/index.ts: -------------------------------------------------------------------------------- 1 | export * from './randomUtil'; 2 | export * from './roomNameGenerator'; 3 | -------------------------------------------------------------------------------- /browser-detection/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BrowserDetection } from './BrowserDetection'; 2 | import * as browsers from './constants'; 3 | export { browsers }; 4 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@jitsi/eslint-config' 4 | ], 5 | parser: '@typescript-eslint/parser', 6 | plugins: [ 7 | '@typescript-eslint' 8 | ] 9 | }; -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build artifacts 5 | dist/ 6 | 7 | # Documentation 8 | doc/ 9 | docs/ 10 | 11 | # Test files 12 | *.spec.ts 13 | *.spec.js 14 | *.test.ts 15 | *.test.js 16 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './avatar'; 2 | export * from './browser-detection'; 3 | export * from './jitsi-local-storage'; 4 | export * from './json'; 5 | export * from './polyfills'; 6 | export * from './random'; 7 | export * from './transport'; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .*.tmp 3 | .remote-sync.json 4 | .sync-config.cson 5 | .DS_Store 6 | .idea 7 | .jshintignore 8 | .jshintrc 9 | *.iml 10 | node_modules/ 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # Build output directory 15 | dist/ 16 | 17 | # Test coverage reports 18 | coverage/ 19 | .nyc_output/ 20 | -------------------------------------------------------------------------------- /json.ts: -------------------------------------------------------------------------------- 1 | import { parse as BourneParse } from '@hapi/bourne'; 2 | 3 | /** 4 | * Safely parse JSON payloads. 5 | * 6 | * @param {string} data - The payload to be parsed. 7 | * @returns The parsed object. 8 | */ 9 | export function safeJsonParse(data: string): any { 10 | return BourneParse(data); 11 | } 12 | -------------------------------------------------------------------------------- /transport/index.ts: -------------------------------------------------------------------------------- 1 | export { MessageType } from './constants'; 2 | export { default as PostMessageTransportBackend } from './PostMessageTransportBackend'; 3 | export { default as Transport } from './Transport'; 4 | export type { 5 | ITransportBackend, 6 | ITransportMessage, 7 | ITransportOptions, 8 | ResponseCallback, 9 | TransportListener 10 | } from './types'; 11 | -------------------------------------------------------------------------------- /transport/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Message types for transport communication. 3 | * 4 | * @enum {string} 5 | */ 6 | export enum MessageType { 7 | /** 8 | * The message type for events. 9 | * 10 | * @type {string} 11 | */ 12 | EVENT = 'event', 13 | 14 | /** 15 | * The message type for requests. 16 | * 17 | * @type {string} 18 | */ 19 | REQUEST = 'request', 20 | 21 | /** 22 | * The message type for responses. 23 | * 24 | * @type {string} 25 | */ 26 | RESPONSE = 'response' 27 | } 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Monitor GitHub Actions versions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | labels: 9 | - "dependencies" 10 | - "github-actions" 11 | commit-message: 12 | prefix: "chore(ci)" 13 | 14 | # Monitor npm dependencies 15 | - package-ecosystem: "npm" 16 | directory: "/" 17 | schedule: 18 | interval: "weekly" 19 | labels: 20 | - "dependencies" 21 | - "npm" 22 | commit-message: 23 | prefix: "build(deps)" 24 | # Group updates to reduce PR noise 25 | groups: 26 | development-dependencies: 27 | dependency-type: "development" 28 | update-types: 29 | - "minor" 30 | - "patch" 31 | -------------------------------------------------------------------------------- /browser-detection/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum Browser { 3 | CHROME = 'chrome', 4 | ELECTRON = 'electron', 5 | FIREFOX = 'firefox', 6 | REACT_NATIVE = 'react-native', 7 | SAFARI = 'safari', 8 | WEBKIT_BROWSER = 'webkit-browser' 9 | } 10 | 11 | /** 12 | * Maps the names of the browsers from ua-parser to the internal names defined in 13 | * ./browsers.js 14 | */ 15 | export const PARSER_TO_JITSI_NAME: { [key: string]: Browser; } = { 16 | 'Chrome': Browser.CHROME, 17 | 'Firefox': Browser.FIREFOX, 18 | 'Safari': Browser.SAFARI, 19 | 'Electron': Browser.ELECTRON 20 | }; 21 | 22 | export enum Engine { 23 | BLINK = 'blink', 24 | GECKO = 'gecko', 25 | WEBKIT = 'webkit' 26 | } 27 | 28 | export const ENGINES: { [key: string]: Engine; } = { 29 | 'Blink': Engine.BLINK, 30 | 'WebKit': Engine.WEBKIT, 31 | 'Gecko': Engine.GECKO 32 | }; 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, // Generate .d.ts files 4 | "emitDeclarationOnly": false, // Emit both .js and .d.ts files 5 | "outDir": "./dist", // Output directory - emit files to dist directory 6 | "moduleResolution": "bundler", // Use bundler resolution for extension-less imports 7 | "target": "ES2019", // Target ES2019 for modern, efficient output that works in all target browsers 8 | "module": "ES2020", // ES2020 modules for better tree-shaking and dynamic imports 9 | "strict": true, 10 | "types": ["chai", "node"] // Include Chai and Node.js type definitions for tests 11 | }, 12 | "include": [ 13 | "./**/*.ts" 14 | ], 15 | "exclude": [ 16 | "node_modules", 17 | "dist", 18 | "docs", 19 | "test", 20 | "**/*.spec.ts", 21 | "**/*.spec.js", 22 | "*.conf*.js" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /avatar/index.ts: -------------------------------------------------------------------------------- 1 | import md5 from 'js-md5'; 2 | 3 | /** 4 | * Returns the Gravatar URL of a given email id. 5 | * 6 | * @param {string} key - Email or id for which we need gravatar URL. 7 | * @param {string} [baseURL='https://www.gravatar.com/avatar/'] - Base Gravatar URL. 8 | * @returns {string} Gravatar URL. 9 | */ 10 | export function getGravatarURL(key: string, baseURL: string = 'https://www.gravatar.com/avatar/'): string { 11 | const urlSuffix = '?d=404&size=200'; 12 | 13 | // If the key is a valid email, we hash it. If it's not, we assume it's already a hashed format. 14 | const avatarKey: string = isValidEmail(key) ? md5.hex(key.trim().toLowerCase()) : key; 15 | 16 | return `${baseURL}${avatarKey}${urlSuffix}`; 17 | } 18 | 19 | /** 20 | * Returns if the email id is valid. 21 | * 22 | * @param {string} email - Email id to be checked. 23 | * @returns {boolean} True if the email is valid, false otherwise. 24 | */ 25 | function isValidEmail(email: string): boolean { 26 | return Boolean(email?.indexOf('@') > 0); 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # js-utils 2 | 3 | Collection of utilities for Jitsi JavaScript projects. 4 | 5 | ## Requirements 6 | 7 | This library is designed to be used with a bundler (webpack, rollup, vite, etc.). The package uses `moduleResolution: "bundler"` in its TypeScript configuration and publishes compiled JavaScript targeting ES2019 with ES2020 module syntax. 8 | 9 | **Important:** If you're consuming this library, ensure your build tool is configured to handle ES modules. Direct usage in Node.js without a bundler or transpilation may not work as expected. 10 | 11 | ## Development 12 | 13 | ### Building 14 | 15 | The project is written in TypeScript and compiles to the `dist/` directory: 16 | 17 | ```bash 18 | npm run build # Compile TypeScript to dist/ 19 | npm run clean # Remove the dist/ directory 20 | npm run lint # Run ESLint on source files 21 | ``` 22 | 23 | ### Project Structure 24 | 25 | - Source files are in TypeScript (`.ts`) in the root and module directories 26 | - Compiled output (`.js` and `.d.ts`) goes to `dist/` 27 | - The package exports multiple subpaths for individual utilities 28 | 29 | ### Publishing 30 | 31 | The package is automatically published to npm when changes are pushed to the master branch. The `prepack` script ensures the package is built before publishing. 32 | -------------------------------------------------------------------------------- /.github/workflows/autopublish.yml: -------------------------------------------------------------------------------- 1 | name: "AutoPublish" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | jobs: 9 | auto-publish: 10 | name: "AutoPublish on master" 11 | runs-on: ubuntu-latest 12 | if: "!startsWith(github.event.head_commit.message, 'chore: bump version to')" 13 | 14 | steps: 15 | - name: "Generate GitHub App token" 16 | id: app-token 17 | uses: "actions/create-github-app-token@v2" 18 | with: 19 | app-id: ${{ secrets.GH_APP_ID }} 20 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 21 | - name: "Checkout source code" 22 | uses: "actions/checkout@v5" 23 | with: 24 | token: ${{ steps.app-token.outputs.token }} 25 | - name: "Automated Release" 26 | uses: "phips28/gh-action-bump-version@master" 27 | with: 28 | commit-message: 'chore: bump version to {{version}}' 29 | env: 30 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 31 | - uses: actions/setup-node@v6 32 | with: 33 | node-version: '18' 34 | registry-url: 'https://registry.npmjs.org' 35 | - name: "Install dependencies" 36 | run: npm install 37 | - run: npm publish --access public 38 | env: 39 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/scripts/create-test-check.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a GitHub check run with test results. 3 | * 4 | * This script creates a GitHub check that displays test results on pull requests. 5 | * It's designed to be used with actions/github-script. 6 | * 7 | * @param {Object} params - Parameters object 8 | * @param {Object} params.github - GitHub API client from actions/github-script 9 | * @param {Object} params.context - GitHub Actions context 10 | * @param {string} params.passed - Number of passed tests 11 | * @param {string} params.failed - Number of failed tests 12 | * @param {string} params.total - Total number of tests 13 | */ 14 | module.exports = async ({ github, context, passed, failed, total }) => { 15 | const conclusion = failed === '0' && total !== '0' ? 'success' : (total === '0' ? 'neutral' : 'failure'); 16 | const summary = `${total} tests run, ${passed} passed, ${failed} failed`; 17 | 18 | await github.rest.checks.create({ 19 | owner: context.repo.owner, 20 | repo: context.repo.repo, 21 | name: 'Test Results', 22 | head_sha: context.payload.pull_request.head.sha, 23 | status: 'completed', 24 | conclusion: conclusion, 25 | output: { 26 | title: summary, 27 | summary: summary, 28 | text: `**Test Execution Summary**\n\n- Total: ${total}\n- Passed: ${passed} ✅\n- Failed: ${failed} ${failed === '0' ? '✅' : '❌'}` 29 | } 30 | }); 31 | }; -------------------------------------------------------------------------------- /transport/types.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from './constants'; 2 | 3 | /** 4 | * Transport backend interface. 5 | */ 6 | export interface ITransportBackend { 7 | /** 8 | * Disposes the transport backend and cleans up resources. 9 | * 10 | * @returns {void} 11 | */ 12 | dispose: () => void; 13 | 14 | /** 15 | * Sends a message through the transport. 16 | * 17 | * @param {any} message - The message to send. 18 | * @returns {void} 19 | */ 20 | send: (message: any) => void; 21 | 22 | /** 23 | * Sets the callback function to handle received messages. 24 | * 25 | * @param {Function} callback - The callback function. 26 | * @returns {void} 27 | */ 28 | setReceiveCallback: (callback: (message: any) => void) => void; 29 | } 30 | 31 | /** 32 | * Transport options. 33 | */ 34 | export interface ITransportOptions { 35 | backend?: ITransportBackend; 36 | } 37 | 38 | /** 39 | * Listener function type. 40 | */ 41 | export type TransportListener = (...args: any[]) => boolean | void; 42 | 43 | /** 44 | * Response callback type for handling request/response pattern. 45 | */ 46 | export type ResponseCallback = (result: any, error?: any) => void; 47 | 48 | /** 49 | * Message types for transport communication. 50 | */ 51 | export interface ITransportMessage { 52 | data?: any; 53 | error?: any; 54 | id?: number; 55 | result?: any; 56 | type: MessageType; 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/scripts/generate-test-summary.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Generate a test summary markdown for GitHub Actions step summary. 5 | # 6 | # This script creates a formatted markdown table showing test results and writes it 7 | # to GITHUB_STEP_SUMMARY. It also exits with the original test exit code. 8 | # 9 | # Usage: 10 | # ./scripts/generate-test-summary.sh 11 | # 12 | # Example: 13 | # ./scripts/generate-test-summary.sh 182 0 182 0 14 | 15 | if [ $# -lt 4 ]; then 16 | echo "Error: Missing required arguments" 17 | echo "Usage: $0 " 18 | exit 1 19 | fi 20 | 21 | PASSED="$1" 22 | FAILED="$2" 23 | TOTAL="$3" 24 | EXIT_CODE="$4" 25 | 26 | echo "### Test Results 📊" >> "$GITHUB_STEP_SUMMARY" 27 | echo "" >> "$GITHUB_STEP_SUMMARY" 28 | 29 | if [ "$FAILED" = "0" ] && [ "$TOTAL" != "0" ]; then 30 | echo "✅ **All tests passed!**" >> "$GITHUB_STEP_SUMMARY" 31 | elif [ "$TOTAL" = "0" ]; then 32 | echo "⚠️ **Could not parse test results**" >> "$GITHUB_STEP_SUMMARY" 33 | else 34 | echo "❌ **Some tests failed**" >> "$GITHUB_STEP_SUMMARY" 35 | fi 36 | 37 | echo "" >> "$GITHUB_STEP_SUMMARY" 38 | echo "| Metric | Count |" >> "$GITHUB_STEP_SUMMARY" 39 | echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" 40 | echo "| Total Tests | $TOTAL |" >> "$GITHUB_STEP_SUMMARY" 41 | echo "| Passed | $PASSED |" >> "$GITHUB_STEP_SUMMARY" 42 | echo "| Failed | $FAILED |" >> "$GITHUB_STEP_SUMMARY" 43 | 44 | exit "$EXIT_CODE" -------------------------------------------------------------------------------- /.github/workflows/scripts/parse-test-results.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Parse test results from web-test-runner output and export to GitHub Actions output. 5 | # 6 | # This script extracts test result counts (passed/failed/total) from the test output log 7 | # and writes them to GITHUB_OUTPUT for use in subsequent workflow steps. 8 | # 9 | # Usage: 10 | # ./.github/workflows/scripts/parse-test-results.sh 11 | # 12 | # Example: 13 | # npm run test:coverage 2>&1 | tee test-output.log 14 | # ./.github/workflows/scripts/parse-test-results.sh test-output.log 15 | 16 | if [ $# -lt 1 ]; then 17 | echo "Error: Missing required argument" 18 | echo "Usage: $0 " 19 | exit 1 20 | fi 21 | 22 | TEST_OUTPUT_LOG="$1" 23 | 24 | if [ ! -f "$TEST_OUTPUT_LOG" ]; then 25 | echo "Error: Test output log file not found: $TEST_OUTPUT_LOG" 26 | exit 1 27 | fi 28 | 29 | # Extract test results from web-test-runner output 30 | # Format: "Chrome: |████| 7/7 test files | 182 passed, 0 failed" 31 | if grep -q "passed" "$TEST_OUTPUT_LOG"; then 32 | PASSED=$(grep -oP '\d+ passed' "$TEST_OUTPUT_LOG" | tail -1 | grep -oP '\d+' || echo "0") 33 | FAILED=$(grep -oP '\d+ failed' "$TEST_OUTPUT_LOG" | tail -1 | grep -oP '\d+' || echo "0") 34 | TOTAL=$((PASSED + FAILED)) 35 | 36 | echo "passed=$PASSED" >> "$GITHUB_OUTPUT" 37 | echo "failed=$FAILED" >> "$GITHUB_OUTPUT" 38 | echo "total=$TOTAL" >> "$GITHUB_OUTPUT" 39 | 40 | echo "✅ Parsed test results: $PASSED passed, $FAILED failed (total: $TOTAL)" 41 | else 42 | echo "passed=0" >> "$GITHUB_OUTPUT" 43 | echo "failed=0" >> "$GITHUB_OUTPUT" 44 | echo "total=0" >> "$GITHUB_OUTPUT" 45 | 46 | echo "⚠️ Could not parse test results from $TEST_OUTPUT_LOG" 47 | fi 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jitsi/js-utils", 3 | "version": "6.2.0", 4 | "description": "Utilities for Jitsi JS projects", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/jitsi/js-utils" 8 | }, 9 | "keywords": [ 10 | "browser", 11 | "jitsi", 12 | "utils" 13 | ], 14 | "author": "", 15 | "readmeFilename": "README.md", 16 | "dependencies": { 17 | "@hapi/bourne": "3.0.0", 18 | "js-md5": "0.7.3", 19 | "ua-parser-js": "1.0.35" 20 | }, 21 | "devDependencies": { 22 | "@esm-bundle/chai": "4.3.4", 23 | "@jitsi/eslint-config": "6.0.4", 24 | "@rollup/plugin-commonjs": "^29.0.0", 25 | "@types/chai": "5.2.3", 26 | "@types/js-md5": "0.8.0", 27 | "@types/node": "24.9.2", 28 | "@types/ua-parser-js": "0.7.39", 29 | "@typescript-eslint/eslint-plugin": "8.46.2", 30 | "@typescript-eslint/parser": "8.46.2", 31 | "@web/dev-server-esbuild": "1.0.4", 32 | "@web/dev-server-rollup": "0.6.4", 33 | "@web/test-runner": "0.20.2", 34 | "eslint": "8.57.1", 35 | "eslint-plugin-import": "2.32.0", 36 | "eslint-plugin-typescript-sort-keys": "3.3.0", 37 | "events": "3.3.0", 38 | "mocha": "11.7.4", 39 | "typescript": "5.9.3" 40 | }, 41 | "scripts": { 42 | "lint": "eslint . --ext .ts", 43 | "build": "tsc --project tsconfig.json", 44 | "clean": "rm -rf dist", 45 | "prepack": "npm run clean && npm run build", 46 | "test": "web-test-runner", 47 | "test:watch": "web-test-runner --watch", 48 | "test:coverage": "web-test-runner --coverage", 49 | "test:debug": "web-test-runner --manual --open" 50 | }, 51 | "main": "./dist/index.js", 52 | "types": "./dist/index.d.ts", 53 | "exports": { 54 | ".": { 55 | "types": "./dist/index.d.ts", 56 | "default": "./dist/index.js" 57 | }, 58 | "./*": { 59 | "types": "./dist/*", 60 | "default": "./dist/*" 61 | } 62 | }, 63 | "files": [ 64 | "dist" 65 | ], 66 | "license": "Apache-2.0" 67 | } 68 | -------------------------------------------------------------------------------- /transport/PostMessageTransportBackend.ts: -------------------------------------------------------------------------------- 1 | import Postis, { PostisOptions } from './postis'; 2 | import type { ITransportBackend } from './types'; 3 | 4 | /** 5 | * Options for PostMessageITransportBackend. 6 | */ 7 | export interface IPostMessageTransportBackendOptions { 8 | postisOptions?: Partial; 9 | } 10 | 11 | /** 12 | * The default options for postis. 13 | * 14 | * @type {Object} 15 | */ 16 | const DEFAULT_POSTIS_OPTIONS: Partial = { 17 | window: window.opener || window.parent 18 | }; 19 | 20 | /** 21 | * The postis method used for all messages. 22 | * 23 | * @type {string} 24 | */ 25 | const POSTIS_METHOD_NAME = 'message' as const; 26 | 27 | /** 28 | * Implements message transport using the postMessage API. 29 | */ 30 | export default class PostMessageITransportBackend implements ITransportBackend { 31 | /** 32 | * The postis instance for communication. 33 | */ 34 | private postis: Postis; 35 | 36 | /** 37 | * Callback function for receiving messages. 38 | */ 39 | private _receiveCallback: (message: any) => void; 40 | 41 | /** 42 | * Creates new PostMessageITransportBackend instance. 43 | * 44 | * @param {Object} options - Optional parameters for configuration of the 45 | * transport. 46 | */ 47 | constructor({ postisOptions }: IPostMessageTransportBackendOptions = {}) { 48 | this.postis = new Postis({ 49 | ...DEFAULT_POSTIS_OPTIONS, 50 | ...postisOptions 51 | } as PostisOptions); 52 | 53 | this._receiveCallback = () => { 54 | // Do nothing until a callback is set by the consumer of 55 | // PostMessageITransportBackend via setReceiveCallback. 56 | }; 57 | 58 | this.postis.listen( 59 | POSTIS_METHOD_NAME, 60 | (message: any) => this._receiveCallback(message)); 61 | } 62 | 63 | /** 64 | * Disposes the allocated resources. 65 | * 66 | * @returns {void} 67 | */ 68 | dispose(): void { 69 | this.postis.destroy(); 70 | } 71 | 72 | /** 73 | * Sends the passed message. 74 | * 75 | * @param {Object} message - The message to be sent. 76 | * @returns {void} 77 | */ 78 | send(message: any): void { 79 | this.postis.send({ 80 | method: POSTIS_METHOD_NAME, 81 | params: message 82 | }); 83 | } 84 | 85 | /** 86 | * Sets the callback for receiving data. 87 | * 88 | * @param {Function} callback - The new callback. 89 | * @returns {void} 90 | */ 91 | setReceiveCallback(callback: (message: any) => void): void { 92 | this._receiveCallback = callback; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /random/randomUtil.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Alphanumeric characters. 3 | * @const 4 | */ 5 | const ALPHANUM = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; 6 | 7 | /** 8 | * Hexadecimal digit characters. 9 | * @const 10 | */ 11 | const HEX_DIGITS = '0123456789abcdef'; 12 | 13 | /** 14 | * Generate a string with random alphanumeric characters with a specific length. 15 | * 16 | * @param {number} length - The length of the string to return. 17 | * @returns {string} A string of random alphanumeric characters with the 18 | * specified length. 19 | */ 20 | export function randomAlphanumString(length: number): string { 21 | return _randomString(length, ALPHANUM); 22 | } 23 | 24 | /** 25 | * Get random element of array or string. 26 | * 27 | * @param {Array|string} arr - Source. 28 | * @returns {Array|string} Array element or string character. 29 | */ 30 | export function randomElement(arr: readonly T[]): T; 31 | export function randomElement(arr: string): string; 32 | export function randomElement(arr: readonly T[] | string): T | string { 33 | return arr[randomInt(0, arr.length - 1)]; 34 | } 35 | 36 | /** 37 | * Returns a random hex digit. 38 | * 39 | * @returns {Array|string} 40 | */ 41 | export function randomHexDigit(): string { 42 | return randomElement(HEX_DIGITS); 43 | } 44 | 45 | /** 46 | * Generates a string of random hexadecimal digits with a specific length. 47 | * 48 | * @param {number} length - The length of the string to return. 49 | * @returns {string} A string of random hexadecimal digits with the specified 50 | * length. 51 | */ 52 | export function randomHexString(length: number): string { 53 | return _randomString(length, HEX_DIGITS); 54 | } 55 | 56 | /** 57 | * Generates random int within the range [min, max]. 58 | * 59 | * @param {number} min - The minimum value for the generated number. 60 | * @param {number} max - The maximum value for the generated number. 61 | * @returns {number} Random int number. 62 | */ 63 | export function randomInt(min: number, max: number): number { 64 | return Math.floor(Math.random() * (max - min + 1)) + min; 65 | } 66 | 67 | /** 68 | * Generates a string of random characters with a specific length. 69 | * 70 | * @param {number} length - The length of the string to return. 71 | * @param {string} characters - The characters from which the returned string is 72 | * to be constructed. 73 | * @private 74 | * @returns {string} A string of random characters with the specified length. 75 | */ 76 | function _randomString(length: number, characters: string): string { 77 | let result = ''; 78 | 79 | for (let i = 0; i < length; ++i) { 80 | result += randomElement(characters); 81 | } 82 | 83 | return result; 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master, main] 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | 15 | - uses: actions/setup-node@v6 16 | with: 17 | node-version: 24 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run ESLint 24 | run: npm run lint 25 | 26 | test: 27 | name: Test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v5 31 | 32 | - uses: actions/setup-node@v6 33 | with: 34 | node-version: 24 35 | cache: 'npm' 36 | 37 | - name: Install dependencies 38 | run: npm ci 39 | 40 | - name: Run tests with coverage 41 | run: | 42 | npm run test:coverage 2>&1 | tee test-output.log 43 | echo "TEST_EXIT_CODE=${PIPESTATUS[0]}" >> $GITHUB_ENV 44 | 45 | - name: Parse test results 46 | if: always() 47 | id: test-results 48 | run: ./.github/workflows/scripts/parse-test-results.sh test-output.log 49 | 50 | - name: Generate test summary 51 | if: always() 52 | run: | 53 | PASSED="${{ steps.test-results.outputs.passed }}" 54 | FAILED="${{ steps.test-results.outputs.failed }}" 55 | TOTAL="${{ steps.test-results.outputs.total }}" 56 | 57 | ./.github/workflows/scripts/generate-test-summary.sh "$PASSED" "$FAILED" "$TOTAL" "$TEST_EXIT_CODE" 58 | 59 | - name: Create test results check 60 | if: always() && github.event_name == 'pull_request' 61 | uses: actions/github-script@v7 62 | with: 63 | script: | 64 | const createTestCheck = require('./.github/workflows/scripts/create-test-check.js'); 65 | await createTestCheck({ 66 | github, 67 | context, 68 | passed: '${{ steps.test-results.outputs.passed }}', 69 | failed: '${{ steps.test-results.outputs.failed }}', 70 | total: '${{ steps.test-results.outputs.total }}' 71 | }); 72 | 73 | - name: Upload coverage to Codecov 74 | uses: codecov/codecov-action@v4 75 | with: 76 | token: ${{ secrets.CODECOV_TOKEN }} 77 | directory: ./coverage 78 | fail_ci_if_error: false 79 | verbose: true 80 | 81 | - name: Upload coverage report as artifact 82 | if: always() 83 | uses: actions/upload-artifact@v5 84 | with: 85 | name: coverage-report 86 | path: coverage/ 87 | retention-days: 30 88 | 89 | build: 90 | name: Build 91 | runs-on: ubuntu-latest 92 | steps: 93 | - uses: actions/checkout@v5 94 | 95 | - uses: actions/setup-node@v6 96 | with: 97 | node-version: 24 98 | cache: 'npm' 99 | 100 | - name: Install dependencies 101 | run: npm ci 102 | 103 | - name: Build TypeScript 104 | run: npm run build 105 | -------------------------------------------------------------------------------- /random/roomNameGenerator.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for room name generator. 3 | * Tests random room name generation following pattern-based templates. 4 | */ 5 | import { expect } from '@esm-bundle/chai'; 6 | import { generateRoomWithoutSeparator } from './roomNameGenerator'; 7 | 8 | describe('roomNameGenerator', () => { 9 | describe('generateRoomWithoutSeparator', () => { 10 | it('should generate non-empty room name', () => { 11 | const roomName = generateRoomWithoutSeparator(); 12 | 13 | expect(roomName).to.be.a('string'); 14 | expect(roomName.length).to.be.greaterThan(0); 15 | }); 16 | 17 | it('should generate different room names on multiple calls', () => { 18 | const roomName1 = generateRoomWithoutSeparator(); 19 | const roomName2 = generateRoomWithoutSeparator(); 20 | const roomName3 = generateRoomWithoutSeparator(); 21 | 22 | // While theoretically possible all 3 could be equal, probability is extremely low. 23 | const uniqueNames = new Set([ roomName1, roomName2, roomName3 ]); 24 | 25 | expect(uniqueNames.size).to.be.at.least(2); 26 | }); 27 | 28 | it('should generate room names without spaces or separators', () => { 29 | const roomName = generateRoomWithoutSeparator(); 30 | 31 | expect(roomName).to.not.include(' '); 32 | expect(roomName).to.not.include('-'); 33 | expect(roomName).to.not.include('_'); 34 | expect(roomName).to.not.include('.'); 35 | }); 36 | 37 | it('should generate room names with reasonable length', () => { 38 | // Generate multiple samples to test length distribution. 39 | for (let i = 0; i < 10; i++) { 40 | const roomName = generateRoomWithoutSeparator(); 41 | 42 | // Room names should be between 10 and 100 characters typically. 43 | expect(roomName.length).to.be.at.least(10); 44 | expect(roomName.length).to.be.at.most(100); 45 | } 46 | }); 47 | 48 | it('should generate room names starting with capital letter', () => { 49 | // Generate multiple samples. 50 | for (let i = 0; i < 10; i++) { 51 | const roomName = generateRoomWithoutSeparator(); 52 | const firstChar = roomName.charAt(0); 53 | 54 | expect(firstChar).to.match(/[A-Z]/); 55 | } 56 | }); 57 | 58 | it('should generate room names with only alphabetic characters', () => { 59 | // Generate multiple samples. 60 | for (let i = 0; i < 10; i++) { 61 | const roomName = generateRoomWithoutSeparator(); 62 | 63 | expect(roomName).to.match(/^[A-Za-z]+$/); 64 | } 65 | }); 66 | 67 | it('should generate variety of room names', () => { 68 | const roomNames = new Set(); 69 | 70 | // Generate 20 room names. 71 | for (let i = 0; i < 20; i++) { 72 | roomNames.add(generateRoomWithoutSeparator()); 73 | } 74 | 75 | // Should have at least 15 unique names out of 20. 76 | expect(roomNames.size).to.be.at.least(15); 77 | }); 78 | 79 | it('should be deterministic with same internal randomness', () => { 80 | // This test verifies the function works consistently. 81 | const roomName = generateRoomWithoutSeparator(); 82 | 83 | expect(roomName).to.be.a('string'); 84 | expect(roomName.length).to.be.greaterThan(0); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /json.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for safe JSON parsing utilities. 3 | * Tests JSON parsing with protection against prototype pollution. 4 | */ 5 | import { expect } from '@esm-bundle/chai'; 6 | import { safeJsonParse } from './json'; 7 | 8 | describe('json utilities', () => { 9 | describe('safeJsonParse', () => { 10 | it('should parse valid JSON string', () => { 11 | const json = '{"key": "value"}'; 12 | const result = safeJsonParse(json); 13 | 14 | expect(result).to.deep.equal({ key: 'value' }); 15 | }); 16 | 17 | it('should parse JSON with nested objects', () => { 18 | const json = '{"outer": {"inner": "value"}}'; 19 | const result = safeJsonParse(json); 20 | 21 | expect(result).to.deep.equal({ 22 | outer: { 23 | inner: 'value' 24 | } 25 | }); 26 | }); 27 | 28 | it('should parse JSON arrays', () => { 29 | const json = '[1, 2, 3, 4, 5]'; 30 | const result = safeJsonParse(json); 31 | 32 | expect(result).to.deep.equal([ 1, 2, 3, 4, 5 ]); 33 | }); 34 | 35 | it('should parse JSON with various data types', () => { 36 | const json = '{"string": "text", "number": 42, "boolean": true, "null": null}'; 37 | const result = safeJsonParse(json); 38 | 39 | expect(result).to.deep.equal({ 40 | string: 'text', 41 | number: 42, 42 | boolean: true, 43 | null: null 44 | }); 45 | }); 46 | 47 | it('should parse empty object', () => { 48 | const json = '{}'; 49 | const result = safeJsonParse(json); 50 | 51 | expect(result).to.deep.equal({}); 52 | }); 53 | 54 | it('should parse empty array', () => { 55 | const json = '[]'; 56 | const result = safeJsonParse(json); 57 | 58 | expect(result).to.deep.equal([]); 59 | }); 60 | 61 | it('should throw error for invalid JSON', () => { 62 | const invalidJson = '{invalid}'; 63 | 64 | expect(() => safeJsonParse(invalidJson)).to.throw(); 65 | }); 66 | 67 | it('should protect against __proto__ pollution', () => { 68 | const maliciousJson = '{"__proto__": {"polluted": true}}'; 69 | 70 | // @hapi/bourne should throw when it detects __proto__ pollution. 71 | expect(() => safeJsonParse(maliciousJson)).to.throw(SyntaxError, 'Object contains forbidden prototype property'); 72 | expect(Object.prototype).to.not.have.property('polluted'); 73 | }); 74 | 75 | it('should protect against constructor pollution', () => { 76 | const maliciousJson = '{"constructor": {"prototype": {"polluted": true}}}'; 77 | const result = safeJsonParse(maliciousJson); 78 | 79 | // @hapi/bourne should prevent prototype pollution. 80 | expect(Object.prototype).to.not.have.property('polluted'); 81 | expect(result).to.be.an('object'); 82 | }); 83 | 84 | it('should parse JSON with special characters', () => { 85 | const json = '{"key": "value with \\"quotes\\" and \\nnewlines"}'; 86 | const result = safeJsonParse(json); 87 | 88 | expect(result.key).to.include('quotes'); 89 | expect(result.key).to.include('\n'); 90 | }); 91 | 92 | it('should parse JSON with unicode characters', () => { 93 | const json = '{"emoji": "😀", "chinese": "中文"}'; 94 | const result = safeJsonParse(json); 95 | 96 | expect(result).to.deep.equal({ 97 | emoji: '😀', 98 | chinese: '中文' 99 | }); 100 | }); 101 | 102 | it('should parse JSON with numbers in scientific notation', () => { 103 | const json = '{"small": 1e-10, "large": 1e10}'; 104 | const result = safeJsonParse(json); 105 | 106 | expect(result.small).to.equal(1e-10); 107 | expect(result.large).to.equal(1e10); 108 | }); 109 | 110 | it('should parse deeply nested JSON', () => { 111 | const json = '{"a": {"b": {"c": {"d": {"e": "deep"}}}}}'; 112 | const result = safeJsonParse(json); 113 | 114 | expect(result.a.b.c.d.e).to.equal('deep'); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { esbuildPlugin } from '@web/dev-server-esbuild'; 2 | import { fromRollup } from '@web/dev-server-rollup'; 3 | import rollupCommonjs from '@rollup/plugin-commonjs'; 4 | 5 | // Wrap Rollup CommonJS plugin for Web Dev Server 6 | const commonjs = fromRollup(rollupCommonjs); 7 | 8 | /** 9 | * Custom plugin to handle @hapi/bourne named exports in tests. 10 | * Web Test Runner uses Rollup's commonjs plugin which cannot reliably transform 11 | * named exports from CommonJS modules using `exports.functionName` pattern. 12 | * This plugin intercepts @hapi/bourne after CommonJS transformation and adds 13 | * named exports by reading the transformed module's default export. 14 | */ 15 | const bourneNamedExportsPlugin = { 16 | name: 'bourne-named-exports', 17 | transform(context) { 18 | // Match the already-transformed @hapi/bourne module 19 | if (context.path.includes('node_modules/@hapi/bourne/lib/index.js')) { 20 | // Parse the transformed code to find the default export variable name 21 | // The commonjs plugin typically creates something like: export default __moduleExports; 22 | const defaultExportMatch = context.body.match(/export\s+default\s+(\w+);/); 23 | 24 | if (defaultExportMatch) { 25 | const defaultVarName = defaultExportMatch[1]; 26 | // Add named exports that reference properties of the default export variable 27 | return { 28 | body: context.body + ` 29 | export const parse = ${defaultVarName}.parse; 30 | export const scan = ${defaultVarName}.scan; 31 | export const safeParse = ${defaultVarName}.safeParse; 32 | `, 33 | transformCache: false 34 | }; 35 | } 36 | } 37 | } 38 | }; 39 | 40 | /** 41 | * Custom plugin to handle ua-parser-js named exports in tests. 42 | * Similar to bourne plugin above - ua-parser-js is a UMD/CommonJS module 43 | * that needs its default export exposed as a named export (UAParser). 44 | */ 45 | const uaParserNamedExportsPlugin = { 46 | name: 'ua-parser-named-exports', 47 | transform(context) { 48 | // Match the already-transformed ua-parser-js module 49 | if (context.path.includes('node_modules/ua-parser-js/src/ua-parser.js')) { 50 | // Parse the transformed code to find the default export variable name 51 | const defaultExportMatch = context.body.match(/export\s+default\s+(\w+);/); 52 | 53 | if (defaultExportMatch) { 54 | const defaultVarName = defaultExportMatch[1]; 55 | // UAParser is typically the main export (could be UAParser or the default itself) 56 | // Add named export that references the default export 57 | return { 58 | body: context.body + ` 59 | export const UAParser = ${defaultVarName}.UAParser || ${defaultVarName}; 60 | `, 61 | transformCache: false 62 | }; 63 | } 64 | } 65 | } 66 | }; 67 | 68 | /** 69 | * Web Test Runner configuration for @jitsi/js-utils. 70 | * Uses Mocha + Chai for testing with real Chrome browser (headless by default). 71 | * 72 | * Test pattern: **\/*.spec.ts 73 | * Browser: Chrome Headless (system Chrome, same approach as lib-jitsi-meet Karma) 74 | * Coverage: Enabled with 80% threshold 75 | */ 76 | export default { 77 | // Test files pattern - only match spec files in the root directories 78 | files: [ 79 | 'avatar/**/*.spec.ts', 80 | 'browser-detection/**/*.spec.ts', 81 | 'jitsi-local-storage/**/*.spec.ts', 82 | 'polyfills/**/*.spec.ts', 83 | 'random/**/*.spec.ts', 84 | 'transport/**/*.spec.ts', 85 | '*.spec.ts' 86 | ], 87 | 88 | // Exclude patterns 89 | exclude: [ 90 | '**/node_modules/**', 91 | '**/dist/**', 92 | '**/*.d.ts', 93 | '**/coverage/**' 94 | ], 95 | 96 | // Node-style module resolution 97 | nodeResolve: true, 98 | 99 | // Plugins 100 | plugins: [ 101 | // Compile TypeScript on-the-fly using esbuild 102 | esbuildPlugin({ 103 | ts: true, 104 | target: 'auto', 105 | tsconfig: './tsconfig.json' 106 | }), 107 | // Convert CommonJS modules to ES modules for browser compatibility 108 | // Only transform specific CommonJS dependencies for performance 109 | commonjs({ 110 | include: [ 111 | '**/node_modules/@hapi/bourne/**', 112 | '**/node_modules/js-md5/**', 113 | '**/node_modules/ua-parser-js/**', 114 | '**/node_modules/events/**' 115 | ], 116 | defaultIsModuleExports: true, // Make module.exports the default export 117 | requireReturnsDefault: 'auto', // Smart handling of ES module imports 118 | esmExternals: false, // Don't skip CommonJS transformation for ESM externals 119 | strictRequires: false, // Required for v27+: Use legacy require handling 120 | transformMixedEsModules: true // Transform mixed module systems 121 | }), 122 | // Test-specific: Handle CommonJS module named exports 123 | // These plugins allow tests to use named imports from CommonJS modules 124 | bourneNamedExportsPlugin, 125 | uaParserNamedExportsPlugin 126 | ], 127 | 128 | // Uses default Chrome launcher (headless by default) 129 | // No 'browsers' config needed - works same as Karma ChromeHeadless 130 | 131 | // Coverage configuration 132 | coverage: true, 133 | coverageConfig: { 134 | report: true, 135 | reportDir: 'coverage', 136 | threshold: { 137 | statements: 70, 138 | branches: 60, 139 | functions: 70, 140 | lines: 70 141 | }, 142 | exclude: [ 143 | '**/*.spec.ts', 144 | '**/*.d.ts', 145 | '**/node_modules/**', 146 | 'index.ts' 147 | ] 148 | }, 149 | 150 | // Browser options 151 | browserStartTimeout: 30000, 152 | testsStartTimeout: 30000, 153 | testsFinishTimeout: 30000, 154 | 155 | // Logging - only show errors in browser console 156 | filterBrowserLogs: ({ type }) => type === 'error' 157 | }; 158 | -------------------------------------------------------------------------------- /avatar/index.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for avatar/Gravatar utilities. 3 | * Tests Gravatar URL generation with various inputs. 4 | */ 5 | import { expect } from '@esm-bundle/chai'; 6 | import { getGravatarURL } from './index'; 7 | 8 | describe('avatar utilities', () => { 9 | describe('getGravatarURL', () => { 10 | it('should generate Gravatar URL for valid email', () => { 11 | const email = 'test@example.com'; 12 | const url = getGravatarURL(email); 13 | 14 | expect(url).to.be.a('string'); 15 | expect(url).to.include('https://www.gravatar.com/avatar/'); 16 | expect(url).to.include('?d=404&size=200'); 17 | }); 18 | 19 | it('should hash email and generate URL', () => { 20 | const email = 'user@domain.com'; 21 | const url = getGravatarURL(email); 22 | 23 | // MD5 hash of 'user@domain.com' is 'cd2bfcffe5fee4a1149d101994d0987f'. 24 | expect(url).to.equal('https://www.gravatar.com/avatar/cd2bfcffe5fee4a1149d101994d0987f?d=404&size=200'); 25 | }); 26 | 27 | it('should trim and lowercase email before hashing', () => { 28 | const email1 = ' Test@Example.COM '; 29 | const email2 = 'test@example.com'; 30 | 31 | const url1 = getGravatarURL(email1); 32 | const url2 = getGravatarURL(email2); 33 | 34 | // Should generate the same URL after trimming and lowercasing. 35 | expect(url1).to.equal(url2); 36 | }); 37 | 38 | it('should handle pre-hashed key without hashing again', () => { 39 | const hashedKey = 'abc123def456'; 40 | const url = getGravatarURL(hashedKey); 41 | 42 | // Should use the key as-is since it doesn't contain '@'. 43 | expect(url).to.equal('https://www.gravatar.com/avatar/abc123def456?d=404&size=200'); 44 | }); 45 | 46 | it('should use default base URL if not provided', () => { 47 | const email = 'test@example.com'; 48 | const url = getGravatarURL(email); 49 | 50 | expect(url).to.include('https://www.gravatar.com/avatar/'); 51 | }); 52 | 53 | it('should use custom base URL if provided', () => { 54 | const email = 'test@example.com'; 55 | const customBaseURL = 'https://custom.gravatar.com/'; 56 | const url = getGravatarURL(email, customBaseURL); 57 | 58 | expect(url).to.include('https://custom.gravatar.com/'); 59 | expect(url).to.include('?d=404&size=200'); 60 | }); 61 | 62 | it('should include default image and size parameters', () => { 63 | const email = 'test@example.com'; 64 | const url = getGravatarURL(email); 65 | 66 | expect(url).to.include('d=404'); 67 | expect(url).to.include('size=200'); 68 | }); 69 | 70 | it('should handle empty string as pre-hashed key', () => { 71 | const emptyKey = ''; 72 | const url = getGravatarURL(emptyKey); 73 | 74 | // Empty string doesn't contain '@', so treated as pre-hashed key. 75 | expect(url).to.equal('https://www.gravatar.com/avatar/?d=404&size=200'); 76 | }); 77 | 78 | it('should handle email with multiple @ symbols', () => { 79 | const email = 'user@@domain.com'; 80 | const url = getGravatarURL(email); 81 | 82 | // Contains '@' at position > 0, so treated as email and hashed. 83 | expect(url).to.be.a('string'); 84 | expect(url).to.include('https://www.gravatar.com/avatar/'); 85 | }); 86 | 87 | it('should handle email with @ at the beginning', () => { 88 | const invalidEmail = '@domain.com'; 89 | const url = getGravatarURL(invalidEmail); 90 | 91 | // '@' is at position 0, so not treated as valid email. 92 | expect(url).to.equal('https://www.gravatar.com/avatar/@domain.com?d=404&size=200'); 93 | }); 94 | 95 | it('should handle various valid email formats', () => { 96 | const emails = [ 97 | 'simple@example.com', 98 | 'user.name@example.com', 99 | 'user+tag@example.co.uk', 100 | 'user_name@example-domain.com' 101 | ]; 102 | 103 | emails.forEach(email => { 104 | const url = getGravatarURL(email); 105 | 106 | expect(url).to.be.a('string'); 107 | expect(url).to.include('https://www.gravatar.com/avatar/'); 108 | expect(url).to.match(/^https:\/\/www\.gravatar\.com\/avatar\/[a-f0-9]{32}\?d=404&size=200$/); 109 | }); 110 | }); 111 | 112 | it('should generate consistent URLs for same email', () => { 113 | const email = 'consistent@example.com'; 114 | const url1 = getGravatarURL(email); 115 | const url2 = getGravatarURL(email); 116 | 117 | expect(url1).to.equal(url2); 118 | }); 119 | 120 | it('should generate different URLs for different emails', () => { 121 | const email1 = 'user1@example.com'; 122 | const email2 = 'user2@example.com'; 123 | 124 | const url1 = getGravatarURL(email1); 125 | const url2 = getGravatarURL(email2); 126 | 127 | expect(url1).to.not.equal(url2); 128 | }); 129 | 130 | it('should handle custom base URL without trailing slash', () => { 131 | const email = 'test@example.com'; 132 | const customBaseURL = 'https://custom.gravatar.com/avatar'; 133 | const url = getGravatarURL(email, customBaseURL); 134 | 135 | // Should concatenate directly (implementation doesn't add slash). 136 | expect(url).to.include('https://custom.gravatar.com/avatar'); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is `@jitsi/js-utils`, a collection of utility libraries for Jitsi JavaScript projects. The package is published to npm and provides shared functionality across the Jitsi ecosystem. This is a TypeScript project that exports ES6 modules. 8 | 9 | ## Development Commands 10 | 11 | ### Build 12 | - `npm run build` - Compile TypeScript files to dist/ directory using tsc 13 | - `npm run clean` - Remove the dist/ directory 14 | - `npm run prepack` - Runs clean and build (automatically runs before publishing) 15 | 16 | ### Testing 17 | - `npm test` or `npm run test` - Run all tests using @web/test-runner 18 | - `npm run test:watch` - Run tests in watch mode 19 | - `npm run test:coverage` - Run tests with coverage reporting 20 | - `npm run test:debug` - Run tests in manual debug mode with browser UI 21 | 22 | ### Linting 23 | - `npm run lint` - Run ESLint using @jitsi/eslint-config 24 | - Always run linting before committing changes 25 | 26 | ## Code Architecture 27 | 28 | ### Module Structure 29 | The codebase is organized as a collection of independent utility modules, each in its own directory: 30 | 31 | - **avatar/** - Avatar generation utilities 32 | - Gravatar URL generation with email hashing 33 | 34 | - **browser-detection/** - Browser and environment detection using ua-parser-js 35 | - Uses TypeScript with `BrowserDetection` class 36 | - Maps various browser names to Jitsi-specific naming conventions 37 | - Detects React Native, Electron, Chromium-based browsers, engines (Blink, Gecko, WebKit) 38 | 39 | - **jitsi-local-storage/** - LocalStorage wrapper with fallback 40 | - Provides `JitsiLocalStorage` class extending EventEmitter 41 | - Falls back to `DummyLocalStorage` (in-memory) when localStorage is unavailable 42 | - Emits 'changed' events on modifications 43 | - Supports serialization with optional key exclusion 44 | 45 | - **json.ts** - Safe JSON parsing using @hapi/bourne to prevent prototype pollution 46 | 47 | - **polyfills/** - Browser polyfills (currently querySelector-related) 48 | 49 | - **random/** - Random utility functions 50 | - `roomNameGenerator.ts` - Generate random room names 51 | - `randomUtil.ts` - General random utilities (hex strings, alphanumeric strings) 52 | 53 | - **transport/** - Message transport abstraction with request/response pattern 54 | - `Transport.ts` - Main transport class with event emitter pattern 55 | - Handles MESSAGE_TYPE_EVENT, MESSAGE_TYPE_REQUEST, MESSAGE_TYPE_RESPONSE 56 | - Implements pluggable backends via `setBackend()` 57 | - `PostMessageTransportBackend.ts` - postMessage-based backend 58 | - `postis.ts` - Third-party postMessage utility (used by PostMessageTransportBackend) 59 | 60 | ### Entry Point and Exports 61 | The main `index.ts` re-exports all utility modules: 62 | - avatar 63 | - browser-detection 64 | - jitsi-local-storage 65 | - json 66 | - polyfills 67 | - random 68 | - transport 69 | 70 | All modules can be imported from the main package entry point: 71 | ```typescript 72 | import { randomHexString, Transport, BrowserDetection } from '@jitsi/js-utils'; 73 | ``` 74 | 75 | Alternatively, subpath imports are also supported via the wildcard export pattern: 76 | ```typescript 77 | import { randomHexString } from '@jitsi/js-utils/random'; 78 | import { Transport } from '@jitsi/js-utils/transport'; 79 | ``` 80 | 81 | ### TypeScript Configuration 82 | - Target: ES6 modules 83 | - Strict mode enabled 84 | - Entire codebase written in TypeScript 85 | - Build output goes to `dist/` directory with compiled .js and .d.ts files 86 | - Source files remain in root directory structure (avatar/, browser-detection/, etc.) 87 | 88 | ### Package Publishing 89 | - Published as `@jitsi/js-utils` to npm with public access 90 | - Main entry: `dist/index.js` 91 | - TypeScript types: `dist/index.d.ts` 92 | - Package type: ES module 93 | - Only the `dist/` directory is published (controlled by `files` field in package.json) 94 | - Wildcard exports pattern allows subpath imports (e.g., `@jitsi/js-utils/random`) 95 | - AutoPublish workflow triggers on master branch pushes 96 | - Version bumping handled automatically by gh-action-bump-version 97 | 98 | ## Key Dependencies 99 | - `@hapi/bourne` - Safe JSON parsing (prevents prototype pollution) 100 | - `js-md5` - MD5 hashing 101 | - `ua-parser-js` - User agent parsing for browser detection 102 | 103 | ## Coding Conventions 104 | - Uses @jitsi/eslint-config with TypeScript ESLint parser 105 | - JSDoc comments for all public APIs 106 | - Private members prefixed with underscore (_) 107 | - Event emitter pattern used for reactive components (JitsiLocalStorage, Transport) 108 | - All code written in TypeScript with strict type checking 109 | - Follow existing patterns and naming conventions in the codebase 110 | 111 | ## Testing and CI 112 | 113 | ### Test Suite 114 | - Comprehensive unit test suite with 100% code coverage 115 | - Uses @web/test-runner with @esm-bundle/chai for assertions 116 | - Tests organized alongside source files with `.spec.ts` extension 117 | - 182+ tests covering all utility modules 118 | - Run tests before committing changes 119 | 120 | ### CI Workflow 121 | The CI workflow is split into three parallel jobs: 122 | 123 | 1. **Lint Job** - Runs ESLint on all TypeScript files 124 | 2. **Test Job** - Runs tests with coverage and generates reports 125 | - Parses test results and generates GitHub check summaries 126 | - Uploads coverage to Codecov for tracking over time 127 | - Creates test result artifacts 128 | 3. **Build Job** - Compiles TypeScript to ensure no build errors 129 | 130 | All jobs must pass before merging pull requests. 131 | 132 | ### Coverage Reporting 133 | - Coverage reports generated with every test run 134 | - Integrated with Codecov for tracking coverage trends 135 | - Aim to maintain 100% code coverage for all new features 136 | - Coverage reports available as CI artifacts 137 | -------------------------------------------------------------------------------- /random/randomUtil.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for random utility functions. 3 | * Tests random string generation, element selection, and integer generation. 4 | */ 5 | import { expect } from '@esm-bundle/chai'; 6 | import { 7 | randomAlphanumString, 8 | randomElement, 9 | randomHexDigit, 10 | randomHexString, 11 | randomInt 12 | } from './randomUtil'; 13 | 14 | describe('randomUtil', () => { 15 | describe('randomAlphanumString', () => { 16 | it('should generate string with correct length', () => { 17 | const result = randomAlphanumString(10); 18 | 19 | expect(result).to.have.lengthOf(10); 20 | }); 21 | 22 | it('should generate empty string for length 0', () => { 23 | const result = randomAlphanumString(0); 24 | 25 | expect(result).to.equal(''); 26 | }); 27 | 28 | it('should generate different strings on multiple calls', () => { 29 | const result1 = randomAlphanumString(20); 30 | const result2 = randomAlphanumString(20); 31 | 32 | // While theoretically possible to be equal, probability is extremely low. 33 | expect(result1).to.not.equal(result2); 34 | }); 35 | 36 | it('should only contain alphanumeric characters', () => { 37 | const result = randomAlphanumString(100); 38 | const alphanumRegex = /^[0-9a-zA-Z]+$/; 39 | 40 | expect(result).to.match(alphanumRegex); 41 | }); 42 | 43 | it('should generate strings with various lengths', () => { 44 | expect(randomAlphanumString(1)).to.have.lengthOf(1); 45 | expect(randomAlphanumString(5)).to.have.lengthOf(5); 46 | expect(randomAlphanumString(50)).to.have.lengthOf(50); 47 | expect(randomAlphanumString(100)).to.have.lengthOf(100); 48 | }); 49 | }); 50 | 51 | describe('randomElement', () => { 52 | it('should return element from array', () => { 53 | const array = [ 'a', 'b', 'c', 'd', 'e' ]; 54 | const result = randomElement(array); 55 | 56 | expect(array).to.include(result); 57 | }); 58 | 59 | it('should return character from string', () => { 60 | const string = 'abcde'; 61 | const result = randomElement(string); 62 | 63 | expect(string).to.include(result); 64 | expect(result).to.have.lengthOf(1); 65 | }); 66 | 67 | it('should return only element from single-element array', () => { 68 | const array = [ 'only' ]; 69 | const result = randomElement(array); 70 | 71 | expect(result).to.equal('only'); 72 | }); 73 | 74 | it('should return different elements from array', () => { 75 | const array = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]; 76 | const results = new Set(); 77 | 78 | // Generate 50 samples - should get some variation. 79 | for (let i = 0; i < 50; i++) { 80 | results.add(randomElement(array)); 81 | } 82 | 83 | // With 50 samples from 10 elements, we should get at least 5 different values. 84 | expect(results.size).to.be.at.least(5); 85 | }); 86 | 87 | it('should work with different types in array', () => { 88 | const array = [ 1, 'two', true, null ]; 89 | const result = randomElement(array); 90 | 91 | expect(array).to.include(result); 92 | }); 93 | }); 94 | 95 | describe('randomHexDigit', () => { 96 | it('should return single hex digit', () => { 97 | const result = randomHexDigit(); 98 | 99 | expect(result).to.have.lengthOf(1); 100 | }); 101 | 102 | it('should return valid hex digit', () => { 103 | const result = randomHexDigit(); 104 | const hexDigits = '0123456789abcdef'; 105 | 106 | expect(hexDigits).to.include(result); 107 | }); 108 | 109 | it('should return different hex digits', () => { 110 | const results = new Set(); 111 | 112 | // Generate 50 samples - should get some variation. 113 | for (let i = 0; i < 50; i++) { 114 | results.add(randomHexDigit()); 115 | } 116 | 117 | // With 50 samples from 16 possible digits, should get at least 8 different values. 118 | expect(results.size).to.be.at.least(8); 119 | }); 120 | }); 121 | 122 | describe('randomHexString', () => { 123 | it('should generate hex string with correct length', () => { 124 | const result = randomHexString(10); 125 | 126 | expect(result).to.have.lengthOf(10); 127 | }); 128 | 129 | it('should generate empty string for length 0', () => { 130 | const result = randomHexString(0); 131 | 132 | expect(result).to.equal(''); 133 | }); 134 | 135 | it('should only contain hex digits', () => { 136 | const result = randomHexString(100); 137 | const hexRegex = /^[0-9a-f]+$/; 138 | 139 | expect(result).to.match(hexRegex); 140 | }); 141 | 142 | it('should generate different strings on multiple calls', () => { 143 | const result1 = randomHexString(20); 144 | const result2 = randomHexString(20); 145 | 146 | // While theoretically possible to be equal, probability is extremely low. 147 | expect(result1).to.not.equal(result2); 148 | }); 149 | 150 | it('should generate strings with various lengths', () => { 151 | expect(randomHexString(1)).to.have.lengthOf(1); 152 | expect(randomHexString(8)).to.have.lengthOf(8); 153 | expect(randomHexString(32)).to.have.lengthOf(32); 154 | expect(randomHexString(64)).to.have.lengthOf(64); 155 | }); 156 | }); 157 | 158 | describe('randomInt', () => { 159 | it('should return integer within range', () => { 160 | const min = 1; 161 | const max = 10; 162 | 163 | for (let i = 0; i < 20; i++) { 164 | const result = randomInt(min, max); 165 | 166 | expect(result).to.be.at.least(min); 167 | expect(result).to.be.at.most(max); 168 | expect(Number.isInteger(result)).to.be.true; 169 | } 170 | }); 171 | 172 | it('should return min when min equals max', () => { 173 | const result = randomInt(5, 5); 174 | 175 | expect(result).to.equal(5); 176 | }); 177 | 178 | it('should return different values in range', () => { 179 | const results = new Set(); 180 | 181 | // Generate 100 samples from range [1, 10]. 182 | for (let i = 0; i < 100; i++) { 183 | results.add(randomInt(1, 10)); 184 | } 185 | 186 | // Should get at least 7 different values from 10 possible. 187 | expect(results.size).to.be.at.least(7); 188 | }); 189 | 190 | it('should handle negative ranges', () => { 191 | const min = -10; 192 | const max = -1; 193 | 194 | for (let i = 0; i < 20; i++) { 195 | const result = randomInt(min, max); 196 | 197 | expect(result).to.be.at.least(min); 198 | expect(result).to.be.at.most(max); 199 | } 200 | }); 201 | 202 | it('should handle range crossing zero', () => { 203 | const min = -5; 204 | const max = 5; 205 | 206 | for (let i = 0; i < 20; i++) { 207 | const result = randomInt(min, max); 208 | 209 | expect(result).to.be.at.least(min); 210 | expect(result).to.be.at.most(max); 211 | } 212 | }); 213 | 214 | it('should handle large ranges', () => { 215 | const min = 0; 216 | const max = 1000000; 217 | 218 | for (let i = 0; i < 20; i++) { 219 | const result = randomInt(min, max); 220 | 221 | expect(result).to.be.at.least(min); 222 | expect(result).to.be.at.most(max); 223 | expect(Number.isInteger(result)).to.be.true; 224 | } 225 | }); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /transport/postis.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { safeJsonParse } from '../json'; 4 | 5 | // Originally: https://github.com/adtile/postis 6 | // 7 | // The MIT License 8 | // 9 | // Copyright (c) 2015-2015 Adtile Technologies Inc. http://www.adtile.me 10 | // 11 | // Permission is hereby granted, free of charge, to any person obtaining a copy 12 | // of this software and associated documentation files (the "Software"), to deal 13 | // in the Software without restriction, including without limitation the rights 14 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | // copies of the Software, and to permit persons to whom the Software is 16 | // furnished to do so, subject to the following conditions: 17 | // 18 | // The above copyright notice and this permission notice shall be included in 19 | // all copies or substantial portions of the Software. 20 | // 21 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | // THE SOFTWARE. 28 | 29 | /** 30 | * Postis options configuration. 31 | */ 32 | export interface PostisOptions { 33 | scope: string; 34 | window: Window; 35 | windowForEventListening?: Window; 36 | allowedOrigin?: string; 37 | } 38 | 39 | /** 40 | * Parameters for sending a message. 41 | */ 42 | export interface SendOptions { 43 | method: string; 44 | params?: any; 45 | } 46 | 47 | /** 48 | * Message data structure for postMessage communication. 49 | */ 50 | interface MessageData { 51 | postis: boolean; 52 | scope: string; 53 | method: string; 54 | params?: any; 55 | } 56 | 57 | /** 58 | * Creates a postMessage-based communication channel between windows. 59 | */ 60 | export default class Postis { 61 | private scope: string; 62 | private targetWindow: Window; 63 | private windowForEventListening: Window; 64 | private allowedOrigin?: string; 65 | private listeners: Record void>> = {}; 66 | private sendBuffer: SendOptions[] = []; 67 | private listenBuffer: Record = {}; 68 | private _ready: boolean = false; 69 | private readyMethod: string = "__ready__"; 70 | private readynessCheck: number; 71 | private readyCheckID: string; 72 | private listener: (event: MessageEvent) => void; 73 | 74 | /** 75 | * Creates a new Postis instance. 76 | * 77 | * @param {PostisOptions} options - Configuration options for the postis instance. 78 | */ 79 | constructor(options: PostisOptions) { 80 | this.scope = options.scope; 81 | this.targetWindow = options.window; 82 | this.windowForEventListening = options.windowForEventListening || window; 83 | this.allowedOrigin = options.allowedOrigin; 84 | this.readyCheckID = `${Date.now()}-${Math.random()}`; 85 | 86 | // Bind the listener method to preserve 'this' context. 87 | this.listener = this.handleMessage.bind(this); 88 | 89 | this.windowForEventListening.addEventListener("message", this.listener, false); 90 | 91 | // Start readiness check. 92 | this.readynessCheck = window.setInterval(() => { 93 | this.send({ 94 | method: this.readyMethod, 95 | params: this.readyCheckID 96 | }); 97 | }, 50); 98 | 99 | // Listen for readiness check responses. 100 | this.listen(this.readyMethod, (id: string) => { 101 | if (id === this.readyCheckID) { 102 | window.clearInterval(this.readynessCheck); 103 | this._ready = true; 104 | 105 | for (let i = 0; i < this.sendBuffer.length; i++) { 106 | this.send(this.sendBuffer[i]); 107 | } 108 | this.sendBuffer = []; 109 | } else { 110 | this.send({ 111 | method: this.readyMethod, 112 | params: id 113 | }); 114 | } 115 | }); 116 | } 117 | 118 | /** 119 | * Handles incoming postMessage events. 120 | * 121 | * @param {MessageEvent} event - The message event from postMessage. 122 | * @returns {void} 123 | */ 124 | private handleMessage(event: MessageEvent): void { 125 | let data: MessageData | null; 126 | try { 127 | data = safeJsonParse(event.data) as MessageData; 128 | } catch (e) { 129 | return; 130 | } 131 | 132 | if (this.allowedOrigin && event.origin !== this.allowedOrigin) { 133 | return; 134 | } 135 | 136 | if (data && data.postis && data.scope === this.scope) { 137 | const listenersForMethod = this.listeners[data.method]; 138 | if (listenersForMethod) { 139 | for (let i = 0; i < listenersForMethod.length; i++) { 140 | listenersForMethod[i].call(null, data.params); 141 | } 142 | } else { 143 | this.listenBuffer[data.method] = this.listenBuffer[data.method] || []; 144 | this.listenBuffer[data.method].push(data.params); 145 | } 146 | } 147 | } 148 | 149 | /** 150 | * Registers a listener for a specific method. 151 | * 152 | * @param {string} method - The method name to listen for. 153 | * @param {Function} callback - The callback function to invoke when the method is received. 154 | * @returns {void} 155 | */ 156 | listen(method: string, callback: (params: any) => void): void { 157 | this.listeners[method] = this.listeners[method] || []; 158 | this.listeners[method].push(callback); 159 | 160 | const listenBufferForMethod = this.listenBuffer[method]; 161 | if (listenBufferForMethod) { 162 | const listenersForMethod = this.listeners[method]; 163 | for (let i = 0; i < listenersForMethod.length; i++) { 164 | for (let j = 0; j < listenBufferForMethod.length; j++) { 165 | listenersForMethod[i].call(null, listenBufferForMethod[j]); 166 | } 167 | } 168 | } 169 | delete this.listenBuffer[method]; 170 | } 171 | 172 | /** 173 | * Sends a message to the target window. 174 | * 175 | * @param {SendOptions} opts - The message options containing method and params. 176 | * @returns {void} 177 | */ 178 | send(opts: SendOptions): void { 179 | const method = opts.method; 180 | 181 | if ((this._ready || opts.method === this.readyMethod) && 182 | (this.targetWindow && typeof this.targetWindow.postMessage === "function")) { 183 | this.targetWindow.postMessage(JSON.stringify({ 184 | postis: true, 185 | scope: this.scope, 186 | method: method, 187 | params: opts.params 188 | }), "*"); 189 | } else { 190 | this.sendBuffer.push(opts); 191 | } 192 | } 193 | 194 | /** 195 | * Executes the callback when the connection is ready. 196 | * 197 | * @param {Function} callback - The callback to execute when ready. 198 | * @returns {void} 199 | */ 200 | ready(callback: () => void): void { 201 | if (this._ready) { 202 | callback(); 203 | } else { 204 | setTimeout(() => { this.ready(callback); }, 50); 205 | } 206 | } 207 | 208 | /** 209 | * Destroys the postis instance and cleans up resources. 210 | * 211 | * @param {Function} [callback] - Optional callback to execute after cleanup. 212 | * @returns {void} 213 | */ 214 | destroy(callback?: () => void): void { 215 | window.clearInterval(this.readynessCheck); 216 | this._ready = false; 217 | if (this.windowForEventListening && typeof this.windowForEventListening.removeEventListener === "function") { 218 | this.windowForEventListening.removeEventListener("message", this.listener); 219 | } 220 | callback && callback(); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /jitsi-local-storage/index.ts: -------------------------------------------------------------------------------- 1 | // NOTE: The 'events' module is a devDependency (not a runtime dependency). 2 | // Consumers of this package are expected to provide 'events' via their bundler (e.g., webpack) or 3 | // environment (e.g., nodeJS). For tests only, we include 'events' in devDependencies. 4 | import EventEmitter from 'events'; 5 | 6 | /** 7 | * Dummy implementation of Storage interface. 8 | */ 9 | class DummyLocalStorage extends EventEmitter { 10 | 11 | /** 12 | * The object used for storage. 13 | */ 14 | _storage: Record = {}; 15 | 16 | /** 17 | * Empties all keys out of the storage. 18 | * 19 | * @returns {void} 20 | */ 21 | clear(): void { 22 | this._storage = {}; 23 | } 24 | 25 | /** 26 | * Returns the number of data items stored in the Storage object. 27 | * 28 | * @returns {number} The number of data items stored in the Storage object. 29 | */ 30 | get length(): number { 31 | return Object.keys(this._storage).length; 32 | } 33 | 34 | /** 35 | * Will return that key's value associated to the passed key name. 36 | * 37 | * @param {string} keyName - The key name. 38 | * @returns {string | null} The key value. 39 | */ 40 | getItem(keyName: string): string | null { 41 | return this._storage[keyName] ?? null; 42 | } 43 | 44 | /** 45 | * When passed a key name and value, will add that key to the storage, 46 | * or update that key's value if it already exists. 47 | * 48 | * @param {string} keyName - The key name. 49 | * @param {string} keyValue - The key value. 50 | * @returns {void} 51 | */ 52 | setItem(keyName: string, keyValue: string): void { 53 | this._storage[keyName] = keyValue; 54 | } 55 | 56 | /** 57 | * When passed a key name, will remove that key from the storage. 58 | * 59 | * @param {string} keyName - The key name. 60 | * @returns {void} 61 | */ 62 | removeItem(keyName: string): void { 63 | delete this._storage[keyName]; 64 | } 65 | 66 | /** 67 | * When passed a number n, this method will return the name of the nth key in the storage. 68 | * 69 | * @param {number} n - The index of the key. 70 | * @returns {string | null} The nth key name, or null if index is out of bounds. 71 | */ 72 | key(n: number): string | null { 73 | const keys = Object.keys(this._storage); 74 | 75 | if (keys.length <= n) { 76 | return null; 77 | } 78 | 79 | return keys[n]; 80 | } 81 | 82 | /** 83 | * Serializes the content of the storage. 84 | * 85 | * @param {string[]} [ignore=[]] - Array with keys from the local storage to be ignored. 86 | * @returns {string} The serialized content. 87 | */ 88 | serialize(ignore: string[] = []): string { 89 | if (ignore.length === 0) { 90 | return JSON.stringify(this._storage); 91 | } 92 | 93 | const storageCopy = { ...this._storage }; 94 | 95 | ignore.forEach(key => { 96 | delete storageCopy[key]; 97 | }); 98 | 99 | return JSON.stringify(storageCopy); 100 | } 101 | } 102 | 103 | /** 104 | * Wrapper class for browser's local storage object. 105 | */ 106 | export class JitsiLocalStorage extends EventEmitter { 107 | /** 108 | * The storage backend (either window.localStorage or DummyLocalStorage). 109 | */ 110 | private _storage: Storage | DummyLocalStorage; 111 | 112 | /** 113 | * Whether window.localStorage is disabled. 114 | */ 115 | private _localStorageDisabled: boolean = false; 116 | 117 | /** 118 | * Creates a new JitsiLocalStorage instance. 119 | */ 120 | constructor() { 121 | super(); 122 | 123 | let storage: Storage | DummyLocalStorage | undefined; 124 | 125 | try { 126 | storage = window.localStorage; 127 | this._localStorageDisabled = false; 128 | } catch (ignore) { 129 | // localStorage throws an exception. 130 | } 131 | 132 | if (!storage) { // Handles the case when window.localStorage is undefined or throws an exception. 133 | console.warn('Local storage is disabled.'); 134 | storage = new DummyLocalStorage(); 135 | this._localStorageDisabled = true; 136 | } 137 | 138 | this._storage = storage; 139 | } 140 | 141 | /** 142 | * Returns true if window.localStorage is disabled and false otherwise. 143 | * 144 | * @returns {boolean} True if window.localStorage is disabled and false otherwise. 145 | */ 146 | isLocalStorageDisabled(): boolean { 147 | return this._localStorageDisabled; 148 | } 149 | 150 | /** 151 | * Switch between window.localStorage and DummyLocalStorage. 152 | * 153 | * @param {boolean} value - Whether to disable localStorage. 154 | * @returns {void} 155 | */ 156 | setLocalStorageDisabled(value: boolean): void { 157 | this._localStorageDisabled = value; 158 | 159 | try { 160 | this._storage = value ? new DummyLocalStorage() : window.localStorage; 161 | } catch (ignore) { 162 | // localStorage throws an exception. 163 | } 164 | 165 | if (!this._storage) { 166 | this._storage = new DummyLocalStorage(); 167 | } 168 | } 169 | 170 | /** 171 | * Empties all keys out of the storage. 172 | * 173 | * @returns {void} 174 | */ 175 | clear(): void { 176 | this._storage.clear(); 177 | this.emit('changed'); 178 | } 179 | 180 | /** 181 | * Returns the number of data items stored in the Storage object. 182 | * 183 | * @returns {number} The number of data items stored in the Storage object. 184 | */ 185 | get length(): number { 186 | return this._storage.length; 187 | } 188 | 189 | /** 190 | * Returns that passed key's value. 191 | * 192 | * @param {string} keyName - The name of the key you want to retrieve the value of. 193 | * @returns {string | null} The value of the key. If the key does not exist, null is returned. 194 | */ 195 | getItem(keyName: string): string | null { 196 | return this._storage.getItem(keyName); 197 | } 198 | 199 | /** 200 | * Adds a key to the storage, or update key's value if it already exists. 201 | * 202 | * @param {string} keyName - The name of the key you want to create/update. 203 | * @param {string} keyValue - The value you want to give the key you are creating/updating. 204 | * @param {boolean} [dontEmitChangedEvent=false] - If true a changed event won't be emitted. 205 | * @returns {void} 206 | */ 207 | setItem(keyName: string, keyValue: string, dontEmitChangedEvent: boolean = false): void { 208 | this._storage.setItem(keyName, keyValue); 209 | 210 | if (!dontEmitChangedEvent) { 211 | this.emit('changed'); 212 | } 213 | } 214 | 215 | /** 216 | * Remove a key from the storage. 217 | * 218 | * @param {string} keyName - The name of the key you want to remove. 219 | * @returns {void} 220 | */ 221 | removeItem(keyName: string): void { 222 | this._storage.removeItem(keyName); 223 | this.emit('changed'); 224 | } 225 | 226 | /** 227 | * Returns the name of the nth key in the list, or null if n is greater 228 | * than or equal to the number of key/value pairs in the object. 229 | * 230 | * @param {number} i - The index of the key in the list. 231 | * @returns {string | null} The name of the nth key, or null if out of bounds. 232 | */ 233 | key(i: number): string | null { 234 | return this._storage.key(i); 235 | } 236 | 237 | /** 238 | * Serializes the content of the storage. 239 | * 240 | * @param {string[]} [ignore=[]] - Array with keys from the local storage to be ignored. 241 | * @returns {string} The serialized content. 242 | */ 243 | serialize(ignore: string[] = []): string { 244 | if (this.isLocalStorageDisabled()) { 245 | return (this._storage as DummyLocalStorage).serialize(ignore); 246 | } 247 | 248 | const length = this._storage.length; 249 | const localStorageContent: Record = {}; 250 | 251 | for (let i = 0; i < length; i++) { 252 | const key = this._storage.key(i); 253 | 254 | if (key && !ignore.includes(key)) { 255 | localStorageContent[key] = this._storage.getItem(key); 256 | } 257 | } 258 | 259 | return JSON.stringify(localStorageContent); 260 | } 261 | } 262 | 263 | export const jitsiLocalStorage = new JitsiLocalStorage(); 264 | -------------------------------------------------------------------------------- /transport/Transport.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from './constants'; 2 | import type { 3 | ITransportBackend, 4 | ITransportMessage, 5 | ITransportOptions, 6 | TransportListener 7 | } from './types'; 8 | 9 | /** 10 | * Stores the current transport backend that has to be used. Also implements 11 | * request/response mechanism. 12 | */ 13 | export default class Transport { 14 | /** 15 | * Maps an event name and listeners that have been added to the Transport instance. 16 | */ 17 | private _listeners: Map>; 18 | 19 | /** 20 | * The request ID counter used for the id property of the request. This 21 | * property is used to match the responses with the request. 22 | */ 23 | private _requestID: number; 24 | 25 | /** 26 | * Maps IDs of the requests and handlers that will process the responses of those requests. 27 | */ 28 | private _responseHandlers: Map void>; 29 | 30 | /** 31 | * A set with the events and requests that were received but not 32 | * processed by any listener. They are later passed on every new 33 | * listener until they are processed. 34 | */ 35 | private _unprocessedMessages: Set; 36 | 37 | /** 38 | * The transport backend. 39 | */ 40 | private _backend?: ITransportBackend | null; 41 | 42 | /** 43 | * Alias for on method. 44 | */ 45 | public addListener: typeof Transport.prototype.on; 46 | 47 | /** 48 | * Creates new instance. 49 | * 50 | * @param {ITransportOptions} options - Optional parameters for configuration of the transport backend. 51 | */ 52 | constructor({ backend }: ITransportOptions = {}) { 53 | this._listeners = new Map(); 54 | this._requestID = 0; 55 | this._responseHandlers = new Map(); 56 | this._unprocessedMessages = new Set(); 57 | this.addListener = this.on; 58 | 59 | if (backend) { 60 | this.setBackend(backend); 61 | } 62 | } 63 | 64 | /** 65 | * Disposes the current transport backend. 66 | */ 67 | private _disposeBackend(): void { 68 | if (this._backend) { 69 | this._backend.dispose(); 70 | this._backend = null; 71 | } 72 | } 73 | 74 | /** 75 | * Handles incoming messages from the transport backend. 76 | * 77 | * @param {ITransportMessage} message - The message. 78 | * @returns {void} 79 | */ 80 | private _onMessageReceived(message: ITransportMessage): void { 81 | if (message.type === MessageType.RESPONSE) { 82 | const handler = this._responseHandlers.get(message.id!); 83 | 84 | if (handler) { 85 | handler(message); 86 | this._responseHandlers.delete(message.id!); 87 | } 88 | } else if (message.type === MessageType.REQUEST) { 89 | this.emit('request', message.data, (result: any, error?: any) => { 90 | this._backend!.send({ 91 | type: MessageType.RESPONSE, 92 | error, 93 | id: message.id, 94 | result 95 | }); 96 | }); 97 | } else { 98 | this.emit('event', message.data); 99 | } 100 | } 101 | 102 | /** 103 | * Disposes the allocated resources. 104 | * 105 | * @returns {void} 106 | */ 107 | dispose(): void { 108 | this._responseHandlers.clear(); 109 | this._unprocessedMessages.clear(); 110 | this.removeAllListeners(); 111 | this._disposeBackend(); 112 | } 113 | 114 | /** 115 | * Calls each of the listeners registered for the event named eventName, in 116 | * the order they were registered, passing the supplied arguments to each. 117 | * 118 | * @param {string} eventName - The name of the event. 119 | * @param {...any} args - Arguments to pass to the listeners. 120 | * @returns {boolean} True if the event has been processed by any listener, false otherwise. 121 | */ 122 | emit(eventName: string, ...args: any[]): boolean { 123 | const listenersForEvent = this._listeners.get(eventName); 124 | let isProcessed = false; 125 | 126 | if (listenersForEvent?.size) { 127 | listenersForEvent.forEach(listener => { 128 | isProcessed = listener(...args) || isProcessed; 129 | }); 130 | } 131 | 132 | if (!isProcessed) { 133 | this._unprocessedMessages.add(args); 134 | } 135 | 136 | return isProcessed; 137 | } 138 | 139 | /** 140 | * Adds the listener function to the listeners collection for the event 141 | * named eventName. 142 | * 143 | * @param {string} eventName - The name of the event. 144 | * @param {TransportListener} listener - The listener that will be added. 145 | * @returns {Transport} References to the instance of Transport class, so that calls can be chained. 146 | */ 147 | on(eventName: string, listener: TransportListener): this { 148 | let listenersForEvent = this._listeners.get(eventName); 149 | 150 | if (!listenersForEvent) { 151 | listenersForEvent = new Set(); 152 | this._listeners.set(eventName, listenersForEvent); 153 | } 154 | 155 | listenersForEvent.add(listener); 156 | 157 | this._unprocessedMessages.forEach(args => { 158 | if (listener(...args)) { 159 | this._unprocessedMessages.delete(args); 160 | } 161 | }); 162 | 163 | return this; 164 | } 165 | 166 | /** 167 | * Removes all listeners, or those of the specified eventName. 168 | * 169 | * @param {string} [eventName] - The name of the event. If this parameter is not specified all listeners will be removed. 170 | * @returns {Transport} References to the instance of Transport class, so that calls can be chained. 171 | */ 172 | removeAllListeners(eventName?: string): this { 173 | if (eventName) { 174 | this._listeners.delete(eventName); 175 | } else { 176 | this._listeners.clear(); 177 | } 178 | 179 | return this; 180 | } 181 | 182 | /** 183 | * Removes the listener function from the listeners collection for the event 184 | * named eventName. 185 | * 186 | * @param {string} eventName - The name of the event. 187 | * @param {TransportListener} listener - The listener that will be removed. 188 | * @returns {Transport} References to the instance of Transport class, so that calls can be chained. 189 | */ 190 | removeListener(eventName: string, listener: TransportListener): this { 191 | const listenersForEvent = this._listeners.get(eventName); 192 | 193 | if (listenersForEvent) { 194 | listenersForEvent.delete(listener); 195 | } 196 | 197 | return this; 198 | } 199 | 200 | /** 201 | * Sends the passed event. 202 | * 203 | * @param {Object} [event={}] - The event to be sent. 204 | * @returns {void} 205 | */ 206 | sendEvent(event: any = {}): void { 207 | if (this._backend) { 208 | this._backend.send({ 209 | type: MessageType.EVENT, 210 | data: event 211 | }); 212 | } 213 | } 214 | 215 | /** 216 | * Sending request. 217 | * 218 | * @param {Object} request - The request to be sent. 219 | * @returns {Promise} A promise that resolves with the response result or rejects with an error. 220 | */ 221 | sendRequest(request: any): Promise { 222 | if (!this._backend) { 223 | return Promise.reject(new Error('No transport backend defined!')); 224 | } 225 | 226 | this._requestID++; 227 | 228 | const id = this._requestID; 229 | 230 | return new Promise((resolve, reject) => { 231 | this._responseHandlers.set(id, ({ error, result }: ITransportMessage) => { 232 | if (typeof result !== 'undefined') { 233 | resolve(result); 234 | 235 | // eslint-disable-next-line no-negated-condition 236 | } else if (typeof error !== 'undefined') { 237 | reject(error); 238 | } else { // no response 239 | reject(new Error('Unexpected response format!')); 240 | } 241 | }); 242 | 243 | try { 244 | this._backend!.send({ 245 | type: MessageType.REQUEST, 246 | data: request, 247 | id 248 | }); 249 | } catch (error) { 250 | this._responseHandlers.delete(id); 251 | reject(error); 252 | } 253 | }); 254 | } 255 | 256 | /** 257 | * Changes the current backend transport. 258 | * 259 | * @param {ITransportBackend} backend - The new transport backend that will be used. 260 | * @returns {void} 261 | */ 262 | setBackend(backend: ITransportBackend): void { 263 | this._disposeBackend(); 264 | 265 | this._backend = backend; 266 | this._backend.setReceiveCallback(this._onMessageReceived.bind(this)); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /browser-detection/BrowserDetection.ts: -------------------------------------------------------------------------------- 1 | import { UAParser } from 'ua-parser-js'; 2 | 3 | import { 4 | Browser, 5 | ENGINES, 6 | Engine, 7 | PARSER_TO_JITSI_NAME, 8 | } from './constants'; 9 | 10 | interface IBrowserInfo { 11 | engine?: string; 12 | engineVersion?: string; 13 | name: string; 14 | version: string; 15 | } 16 | 17 | /** 18 | * Detects React Native environment. 19 | * @returns {Object|undefined} - The name (REACT_NATIVE) and version. 20 | */ 21 | function _detectReactNative(): IBrowserInfo | undefined { 22 | const match = navigator.userAgent.match(/\b(react[ \t_-]*native)(?:\/(\S+))?/i); 23 | let version: string; 24 | 25 | // If we're remote debugging a React Native app, it may be treated as Chrome. Check navigator.product as well and 26 | // always return some version even if we can't get the real one. 27 | if (match || navigator.product === 'ReactNative') { 28 | let name: string = Browser.REACT_NATIVE; 29 | 30 | version = 'unknown'; 31 | 32 | if (match && match.length > 2) { 33 | name = match[1]; 34 | version = match[2]; 35 | } 36 | 37 | return { 38 | name, 39 | version 40 | }; 41 | } 42 | } 43 | 44 | /** 45 | * Returns the Jitsi recognized name for the browser 46 | * 47 | * @param {Object} [browserInfo] - Information about the browser. 48 | * @param {string} browserInfo.name - The name of the browser. 49 | * @param {string} browserInfo.version - The version of the browser. 50 | * @param {string} browserInfo.engine - The engine of the browser. 51 | * @param {string} browserInfo.engineVersion - The version of the engine of the browser. 52 | * @param {string} browserInfo.os - The os of the browser. 53 | * @param {string} browserInfo.osVersion - The os version of the browser. 54 | * @returns 55 | */ 56 | function _getJitsiBrowserInfo(browserInfo: IBrowserInfo): IBrowserInfo { 57 | const { engine, engineVersion, name, version } = browserInfo; 58 | 59 | return { 60 | name: PARSER_TO_JITSI_NAME[name], 61 | version, 62 | engine: engine ? ENGINES[engine] : undefined, 63 | engineVersion: engineVersion 64 | }; 65 | } 66 | 67 | /** 68 | * Returns information about the current browser. 69 | * @param {Object} - The parser instance. 70 | * @returns {Object} - The name and version of the browser. 71 | */ 72 | function _detect(parser: any): IBrowserInfo { 73 | const reactNativeInfo = _detectReactNative(); 74 | 75 | if (reactNativeInfo) { 76 | return reactNativeInfo; 77 | } 78 | 79 | const { name, version } = parser.getBrowser(); 80 | const { name: engine, version: engineVersion } = parser.getEngine(); 81 | 82 | return _getJitsiBrowserInfo({ 83 | name, 84 | version, 85 | engine, 86 | engineVersion }); 87 | } 88 | 89 | /** 90 | * Implements browser detection. 91 | */ 92 | export default class BrowserDetection { 93 | _parser: any; 94 | _name: string; 95 | _version: any; 96 | _engine: string | undefined; 97 | _engineVersion: string | undefined; 98 | 99 | /** 100 | * Creates new BrowserDetection instance. 101 | * 102 | * @param {Object} [browserInfo] - Information about the browser. 103 | * @param {string} browserInfo.name - The name of the browser. 104 | * @param {string} browserInfo.version - The version of the browser. 105 | * @param {string} browserInfo.engine - The engine of the browser. 106 | * @param {string} browserInfo.engineVersion - The version of the engine of the browser. 107 | * @param {string} browserInfo.os - The os of the browser. 108 | * @param {string} browserInfo.osVersion - The os version of the browser. 109 | */ 110 | constructor(browserInfo: IBrowserInfo) { 111 | this._parser = new UAParser(navigator.userAgent); 112 | 113 | const { 114 | name, 115 | version, 116 | engine, 117 | engineVersion 118 | } = browserInfo ? _getJitsiBrowserInfo(browserInfo) : _detect(this._parser); 119 | 120 | this._name = name; 121 | this._version = version; 122 | this._engine = engine; 123 | this._engineVersion = engineVersion; 124 | } 125 | 126 | /** 127 | * Checks if current browser is Chrome. 128 | * @returns {boolean} 129 | */ 130 | isChrome(): boolean { 131 | // for backward compatibility returns true for all Chromium browsers 132 | return this._name === Browser.CHROME || this._engine === Engine.BLINK; 133 | } 134 | 135 | /** 136 | * Checks if current browser is Firefox. 137 | * @returns {boolean} 138 | */ 139 | isFirefox(): boolean { 140 | return this._engine === Engine.GECKO; 141 | } 142 | 143 | /** 144 | * Checks if current browser is Safari. 145 | * @returns {boolean} 146 | */ 147 | isSafari(): boolean { 148 | return this._name === Browser.SAFARI; 149 | } 150 | 151 | /** 152 | * Checks if current environment is Electron. 153 | * @returns {boolean} 154 | */ 155 | isElectron(): boolean { 156 | return this._name === Browser.ELECTRON; 157 | } 158 | 159 | /** 160 | * Checks if current environment is React Native. 161 | * @returns {boolean} 162 | */ 163 | isReactNative(): boolean { 164 | return this._name === Browser.REACT_NATIVE; 165 | } 166 | 167 | /** 168 | * Checks if current browser is based on chromium. 169 | * @returns {boolean} 170 | */ 171 | isChromiumBased(): boolean { 172 | return this._engine === Engine.BLINK; 173 | } 174 | 175 | /** 176 | * Checks if current browser is based on webkit. 177 | * @returns {boolean} 178 | */ 179 | isWebKitBased(): boolean { 180 | return this._engine === Engine.WEBKIT; 181 | } 182 | 183 | /** 184 | * Gets current browser name. 185 | * @returns {string} 186 | */ 187 | getName(): string { 188 | if (this._name) { 189 | return this._name; 190 | } 191 | 192 | return this._parser.getBrowser().name; 193 | } 194 | 195 | /** 196 | * Returns the version of the current browser. 197 | * @returns {string} 198 | */ 199 | getVersion(): string { 200 | if (this._version) { 201 | return this._version; 202 | } 203 | 204 | return this._parser.getBrowser().version; 205 | } 206 | 207 | /** 208 | * Gets current engine name of the browser. 209 | * @returns {string} 210 | */ 211 | getEngine(): string | undefined { 212 | return this._engine; 213 | } 214 | 215 | /** 216 | * Returns the engine version of the current browser. 217 | * @returns the engine version 218 | */ 219 | getEngineVersion(): string | undefined { 220 | return this._engineVersion; 221 | } 222 | 223 | /** 224 | * Returns the operating system. 225 | */ 226 | getOS(): string { 227 | return this._parser.getOS().name; 228 | } 229 | 230 | /** 231 | * Return the os version. 232 | */ 233 | getOSVersion(): string { 234 | return this._parser.getOS().version; 235 | } 236 | 237 | /** 238 | * Compares the passed version with the current browser version. 239 | * 240 | * @param {number} version - The version to compare with. 241 | * @returns {boolean} - Returns true if the current version is greater than the passed version and false otherwise. 242 | */ 243 | isVersionGreaterThan(version: number): boolean { 244 | if (this._version) { 245 | return parseInt(this._version, 10) > version; 246 | } 247 | 248 | return false; 249 | } 250 | 251 | /** 252 | * Compares the passed version with the current browser version. 253 | * 254 | * @param {number} version - The version to compare with. 255 | * @returns {boolean} - Returns true if the current version is lower than the passed version and false otherwise. 256 | */ 257 | isVersionLessThan(version: number): boolean { 258 | if (this._version) { 259 | return parseInt(this._version, 10) < version; 260 | } 261 | 262 | return false; 263 | } 264 | 265 | /** 266 | * Compares the passed version with the current browser version. 267 | * 268 | * @param {number} version - The version to compare with. 269 | * @returns {boolean} - Returns true if the current version is equal to the passed version and false otherwise. 270 | */ 271 | isVersionEqualTo(version: number): boolean { 272 | if (this._version) { 273 | return parseInt(this._version, 10) === version; 274 | } 275 | 276 | return false; 277 | } 278 | 279 | /** 280 | * Compares the passed version with the current engine version. 281 | * 282 | * @param {number} version - The version to compare with. 283 | * @returns {boolean} - Returns true if the current version is greater than the passed version and false otherwise. 284 | */ 285 | isEngineVersionGreaterThan(version: number): boolean { 286 | if (this._engineVersion) { 287 | return parseInt(this._engineVersion, 10) > version; 288 | } 289 | 290 | return false; 291 | } 292 | 293 | /** 294 | * Compares the passed version with the current engine version. 295 | * 296 | * @param {number} version - The version to compare with. 297 | * @returns {boolean} - Returns true if the current version is lower than the passed version and false otherwise. 298 | */ 299 | isEngineVersionLessThan(version: number): boolean { 300 | if (this._engineVersion) { 301 | return parseInt(this._engineVersion, 10) < version; 302 | } 303 | 304 | return false; 305 | } 306 | 307 | /** 308 | * Compares the passed version with the current engine version. 309 | * 310 | * @param {number} version - The version to compare with. 311 | * @returns {boolean} - Returns true if the current version is equal to the passed version and false otherwise. 312 | */ 313 | isEngineVersionEqualTo(version: number): boolean { 314 | if (this._engineVersion) { 315 | return parseInt(this._engineVersion, 10) === version; 316 | } 317 | 318 | return false; 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /polyfills/querySelectorPolyfill.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents an attribute condition for selector matching. 3 | */ 4 | interface IAttributeCondition { 5 | name: string; 6 | value: string; 7 | } 8 | 9 | /** 10 | * Represents the parsed components of a CSS selector. 11 | */ 12 | interface IParsedSelector { 13 | attrConditions: IAttributeCondition[]; 14 | tagName: string | null; 15 | } 16 | 17 | // Regex constants for efficient reuse across selector parsing. 18 | const SIMPLE_TAG_NAME_REGEX = /^[a-zA-Z][\w-]*$/; 19 | const MULTI_ATTRIBUTE_SELECTOR_REGEX = /^([a-zA-Z][\w-]*)?(\[(?:\*\|)?([^=\]]+)=["']?([^"'\]]+)["']?\])+$/; 20 | const SINGLE_ATTRIBUTE_REGEX = /\[(?:\*\|)?([^=\]]+)=["']?([^"'\]]+)["']?\]/g; 21 | const WHITESPACE_AROUND_COMBINATOR_REGEX = /\s*>\s*/g; 22 | 23 | /** 24 | * Parses a CSS selector into reusable components. 25 | * 26 | * @param {string} selector - The CSS selector to parse. 27 | * @returns {IParsedSelector} - Object with tagName and attrConditions properties. 28 | */ 29 | function _parseSelector(selector: string): IParsedSelector { 30 | // Wildcard selector 31 | if (selector === '*') { 32 | return { 33 | tagName: null, // null means match all tag names 34 | attrConditions: [] 35 | }; 36 | } 37 | 38 | // Simple tag name 39 | if (SIMPLE_TAG_NAME_REGEX.test(selector)) { 40 | return { 41 | tagName: selector, 42 | attrConditions: [] 43 | }; 44 | } 45 | 46 | // Attribute selector: tagname[attr="value"] or 47 | // tagname[attr1="value1"][attr2="value2"] (with optional wildcard namespace) 48 | const multiAttrMatch = selector.match(MULTI_ATTRIBUTE_SELECTOR_REGEX); 49 | 50 | if (multiAttrMatch) { 51 | const tagName = multiAttrMatch[1]; 52 | const attrConditions: IAttributeCondition[] = []; 53 | let attrMatch; 54 | 55 | while ((attrMatch = SINGLE_ATTRIBUTE_REGEX.exec(selector)) !== null) { 56 | attrConditions.push({ 57 | name: attrMatch[1], // This properly strips the *| prefix 58 | value: attrMatch[2] 59 | }); 60 | } 61 | 62 | return { 63 | tagName, 64 | attrConditions 65 | }; 66 | } 67 | 68 | // Unsupported selector 69 | throw new SyntaxError(`Unsupported selector pattern: '${selector}'`); 70 | } 71 | 72 | /** 73 | * Filters elements by selector pattern and handles findFirst logic. 74 | * 75 | * @param {Element[]} elements - Array of elements to filter. 76 | * @param {string} selector - CSS selector to match against. 77 | * @param {boolean} findFirst - If true, return after finding the first match. 78 | * @returns {Element[] | Element | null} - Filtered results with proper return type. 79 | */ 80 | function _filterAndMatchElements(elements: Element[], selector: string, findFirst: true): Element | null; 81 | function _filterAndMatchElements(elements: Element[], selector: string, findFirst: false): Element[]; 82 | function _filterAndMatchElements(elements: Element[], selector: string, findFirst: boolean): Element[] | Element | null; 83 | function _filterAndMatchElements(elements: Element[], selector: string, findFirst: boolean): Element[] | Element | null { 84 | const { tagName, attrConditions } = _parseSelector(selector); 85 | 86 | const results: Element[] = []; 87 | 88 | for (const element of elements) { 89 | // Check tag name if specified 90 | if (tagName && !(element.localName === tagName || element.tagName === tagName)) { 91 | continue; 92 | } 93 | 94 | // Check if all attribute conditions match 95 | const allMatch = attrConditions.every(condition => 96 | element.getAttribute(condition.name) === condition.value 97 | ); 98 | 99 | if (allMatch) { 100 | results.push(element); 101 | if (findFirst) { 102 | return element; 103 | } 104 | } 105 | } 106 | 107 | return findFirst ? null : results; 108 | } 109 | 110 | /** 111 | * Handles direct child traversal for selectors with > combinators. 112 | * This is the shared logic used by both scope selectors and regular direct child selectors. 113 | * 114 | * @param {Element[]} startElements - Array of starting elements to traverse from. 115 | * @param {string[]} selectorParts - Array of selector parts split by '>'. 116 | * @param {boolean} findFirst - If true, return after finding the first match. 117 | * @returns {Element[] | Element | null} - Array of Elements for querySelectorAll, 118 | * single Element or null for querySelector. 119 | */ 120 | function _traverseDirectChildren(startElements: Element[], selectorParts: string[], findFirst: true): Element | null; 121 | function _traverseDirectChildren(startElements: Element[], selectorParts: string[], findFirst: false): Element[]; 122 | function _traverseDirectChildren(startElements: Element[], selectorParts: string[], findFirst: boolean): Element[] | Element | null; 123 | function _traverseDirectChildren(startElements: Element[], selectorParts: string[], findFirst: boolean): Element[] | Element | null { 124 | let currentElements = startElements; 125 | 126 | for (const part of selectorParts) { 127 | const nextElements: Element[] = []; 128 | 129 | currentElements.forEach(el => { 130 | // Get direct children 131 | const directChildren = Array.from(el.children || []); 132 | 133 | // Use same helper as handlers 134 | const matchingChildren = _filterAndMatchElements(directChildren, part, false); 135 | 136 | nextElements.push(...matchingChildren); 137 | }); 138 | 139 | currentElements = nextElements; 140 | 141 | // If we have no results, we can stop early (applies to both querySelector and querySelectorAll) 142 | if (currentElements.length === 0) { 143 | return findFirst ? null : []; 144 | } 145 | } 146 | 147 | return findFirst ? currentElements[0] || null : currentElements; 148 | } 149 | 150 | /** 151 | * Handles :scope pseudo-selector cases with direct child combinators. 152 | * 153 | * @param {Element} element - The Element which is the root of the tree to query. 154 | * @param {string} selector - The CSS selector. 155 | * @param {boolean} findFirst - If true, return after finding the first match. 156 | * @returns {Element[] | Element | null} - Array of Elements for querySelectorAll, 157 | * single Element or null for querySelector. 158 | */ 159 | function _handleScopeSelector(element: Element, selector: string, findFirst: true): Element | null; 160 | function _handleScopeSelector(element: Element, selector: string, findFirst: false): Element[]; 161 | function _handleScopeSelector(element: Element, selector: string, findFirst: boolean): Element[] | Element | null; 162 | function _handleScopeSelector(element: Element, selector: string, findFirst: boolean): Element[] | Element | null { 163 | let searchSelector = selector.substring(6); 164 | 165 | // Handle :scope > tagname (direct children) 166 | if (searchSelector.startsWith('>')) { 167 | searchSelector = searchSelector.substring(1); 168 | 169 | // Split by > and use shared traversal logic 170 | const parts = searchSelector.split('>'); 171 | 172 | // Start from the element itself (scope) 173 | return _traverseDirectChildren([ element ], parts, findFirst); 174 | } 175 | 176 | return null; 177 | } 178 | 179 | /** 180 | * Handles nested > selectors (direct child combinators). 181 | * 182 | * @param {Element} element - The Element which is the root of the tree to query. 183 | * @param {string} selector - The CSS selector. 184 | * @param {boolean} findFirst - If true, return after finding the first match. 185 | * @returns {Element[] | Element | null} - Array of Elements for querySelectorAll, 186 | * single Element or null for querySelector. 187 | */ 188 | function _handleDirectChildSelectors(element: Element, selector: string, findFirst: true): Element | null; 189 | function _handleDirectChildSelectors(element: Element, selector: string, findFirst: false): Element[]; 190 | function _handleDirectChildSelectors(element: Element, selector: string, findFirst: boolean): Element[] | Element | null; 191 | function _handleDirectChildSelectors(element: Element, selector: string, findFirst: boolean): Element[] | Element | null { 192 | const parts = selector.split('>'); 193 | 194 | // First find elements matching the first part (this could be descendants, not just direct children) 195 | const startElements = _querySelectorInternal(element, parts[0], false); 196 | 197 | // If no starting elements found, return early 198 | if (startElements.length === 0) { 199 | return findFirst ? null : []; 200 | } 201 | 202 | // Use shared traversal logic for the remaining parts 203 | return _traverseDirectChildren(startElements, parts.slice(1), findFirst); 204 | } 205 | 206 | /** 207 | * Handles simple tag name selectors. 208 | * 209 | * @param {Element} element - The Element which is the root of the tree to query. 210 | * @param {string} selector - The CSS selector. 211 | * @param {boolean} findFirst - If true, return after finding the first match. 212 | * @returns {Element[] | Element | null} - Array of Elements for querySelectorAll, 213 | * single Element or null for querySelector. 214 | */ 215 | function _handleSimpleTagSelector(element: Element, selector: string, findFirst: true): Element | null; 216 | function _handleSimpleTagSelector(element: Element, selector: string, findFirst: false): Element[]; 217 | function _handleSimpleTagSelector(element: Element, selector: string, findFirst: boolean): Element[] | Element | null; 218 | function _handleSimpleTagSelector(element: Element, selector: string, findFirst: boolean): Element[] | Element | null { 219 | const elements = Array.from(element.getElementsByTagName(selector)); 220 | 221 | if (findFirst) { 222 | return elements[0] || null; 223 | } 224 | 225 | return elements; 226 | } 227 | 228 | /** 229 | * Handles attribute selectors: tagname[attr="value"] or tagname[attr1="value1"][attr2="value2"]. 230 | * Supports single or multiple attributes with optional wildcard namespace (*|). 231 | * 232 | * @param {Element} element - The Element which is the root of the tree to query. 233 | * @param {string} selector - The CSS selector. 234 | * @param {boolean} findFirst - If true, return after finding the first match. 235 | * @returns {Element[] | Element | null} - Array of Elements for querySelectorAll, 236 | * single Element or null for querySelector. 237 | */ 238 | function _handleAttributeSelector(element: Element, selector: string, findFirst: true): Element | null; 239 | function _handleAttributeSelector(element: Element, selector: string, findFirst: false): Element[]; 240 | function _handleAttributeSelector(element: Element, selector: string, findFirst: boolean): Element[] | Element | null; 241 | function _handleAttributeSelector(element: Element, selector: string, findFirst: boolean): Element[] | Element | null { 242 | const { tagName } = _parseSelector(selector); // Just to get tagName for optimization 243 | 244 | // Handler's job: find the right elements to search 245 | const elementsToCheck = (tagName 246 | ? Array.from(element.getElementsByTagName(tagName)) 247 | : Array.from(element.getElementsByTagName('*'))); 248 | 249 | // Common helper does the matching 250 | return _filterAndMatchElements(elementsToCheck, selector, findFirst); 251 | } 252 | 253 | /** 254 | * Internal function that implements the core selector matching logic for both 255 | * querySelector and querySelectorAll. Supports :scope pseudo-selector, direct 256 | * child selectors, and common CSS selectors. 257 | * 258 | * @param {Element} element - The Element which is the root of the tree to query. 259 | * @param {string} selector - The CSS selector to match elements against. 260 | * @param {boolean} findFirst - If true, return after finding the first match. 261 | * @returns {Element[] | Element | null} - Array of Elements for querySelectorAll, 262 | * single Element or null for querySelector. 263 | */ 264 | function _querySelectorInternal(element: Element, selector: string, findFirst: true): Element | null; 265 | function _querySelectorInternal(element: Element, selector: string, findFirst: false): Element[]; 266 | function _querySelectorInternal(element: Element, selector: string, findFirst: boolean): Element[] | Element | null; 267 | function _querySelectorInternal(element: Element, selector: string, findFirst: boolean): Element[] | Element | null { 268 | // Normalize whitespace around > combinators first 269 | const normalizedSelector = selector.replace(WHITESPACE_AROUND_COMBINATOR_REGEX, '>'); 270 | 271 | // Handle :scope pseudo-selector 272 | if (normalizedSelector.startsWith(':scope')) { 273 | return _handleScopeSelector(element, normalizedSelector, findFirst); 274 | } 275 | 276 | // Handle nested > selectors (direct child combinators) 277 | if (normalizedSelector.includes('>')) { 278 | return _handleDirectChildSelectors(element, normalizedSelector, findFirst); 279 | } 280 | 281 | // Fast path: simple tag name 282 | if (normalizedSelector === '*' || SIMPLE_TAG_NAME_REGEX.test(normalizedSelector)) { 283 | return _handleSimpleTagSelector(element, normalizedSelector, findFirst); 284 | } 285 | 286 | // Attribute selector: tagname[attr="value"] or 287 | // tagname[attr1="value1"][attr2="value2"] (with optional wildcard namespace) 288 | if (normalizedSelector.match(MULTI_ATTRIBUTE_SELECTOR_REGEX)) { 289 | return _handleAttributeSelector(element, normalizedSelector, findFirst); 290 | } 291 | 292 | // Unsupported selector - throw SyntaxError to match browser behavior 293 | throw new SyntaxError(`Failed to execute 'querySelector${ 294 | findFirst ? '' : 'All'}' on 'Element': '${selector}' is not a valid selector.`); 295 | } 296 | 297 | /** 298 | * Converts a Node to an array of Elements for searching. 299 | * Handles Document, DocumentFragment, and Element nodes. 300 | * 301 | * @param {Node} node - The Node to convert. 302 | * @returns {Element[]} - Array of Elements to search. 303 | */ 304 | function _getSearchElements(node: Node): Element[] { 305 | const { nodeType } = node; 306 | 307 | // Document (nodeType 9) 308 | if (nodeType === 9) { 309 | const doc = node as Document; 310 | 311 | return doc.documentElement ? [ doc.documentElement ] : []; 312 | } 313 | 314 | // DocumentFragment (nodeType 11) 315 | if (nodeType === 11) { 316 | const frag = node as DocumentFragment; 317 | 318 | return Array.from(frag.children || []); 319 | } 320 | 321 | // Element (nodeType 1) or other 322 | return [ node as Element ]; 323 | } 324 | 325 | /** 326 | * Implements querySelector functionality using the shared internal logic. 327 | * Supports the same selectors as querySelectorAll but returns only the first match. 328 | * 329 | * @param {Node} node - The Node which is the root of the tree to query. 330 | * @param {string} selectors - The CSS selector to match elements against. 331 | * @returns {Element | null} - The first Element which matches the selector, or null. 332 | */ 333 | export function querySelector(node: Node, selectors: string): Element | null { 334 | const searchElements = _getSearchElements(node); 335 | const { nodeType } = node; 336 | 337 | // For Document/DocumentFragment, check if any root element matches selector 338 | // Only simple selectors can match root elements; complex selectors will throw, which is fine 339 | if (nodeType === 9 || nodeType === 11) { 340 | try { 341 | const match = _filterAndMatchElements(searchElements, selectors, true); 342 | 343 | if (match) { 344 | return match; 345 | } 346 | } catch { 347 | // Complex selector (multilevel selectors) - can't match root element, will search descendants below 348 | } 349 | } 350 | 351 | // Search descendants of all root elements 352 | for (const element of searchElements) { 353 | const result = _querySelectorInternal(element, selectors, true); 354 | 355 | if (result) { 356 | return result; 357 | } 358 | } 359 | 360 | return null; 361 | } 362 | 363 | /** 364 | * Implements querySelectorAll functionality using the shared internal logic. 365 | * Supports :scope pseudo-selector, direct child selectors, and common CSS selectors. 366 | * 367 | * @param {Node} node - The Node which is the root of the tree to query. 368 | * @param {string} selector - The CSS selector to match elements against. 369 | * @returns {Element[]} - Array of Elements matching the selector. 370 | */ 371 | export function querySelectorAll(node: Node, selector: string): Element[] { 372 | const searchElements = _getSearchElements(node); 373 | const { nodeType } = node; 374 | const results: Element[] = []; 375 | 376 | // For Document/DocumentFragment, check if any root element matches selector 377 | // Only simple selectors can match root elements; complex selectors will throw, which is fine 378 | if (nodeType === 9 || nodeType === 11) { 379 | try { 380 | const matches = _filterAndMatchElements(searchElements, selector, false); 381 | 382 | results.push(...matches); 383 | } catch { 384 | // Complex selector (multilevel selectors) - can't match root element, will search descendants below 385 | } 386 | } 387 | 388 | // Search descendants of all root elements 389 | for (const element of searchElements) { 390 | const descendants = _querySelectorInternal(element, selector, false); 391 | 392 | results.push(...descendants); 393 | } 394 | 395 | return results; 396 | } 397 | -------------------------------------------------------------------------------- /jitsi-local-storage/index.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for JitsiLocalStorage class. 3 | * Tests storage operations, event emission, and fallback behavior. 4 | */ 5 | import { expect } from '@esm-bundle/chai'; 6 | import { jitsiLocalStorage, JitsiLocalStorage } from './index'; 7 | 8 | describe('JitsiLocalStorage', () => { 9 | // Clear storage and remove all event listeners before each test. 10 | beforeEach(() => { 11 | jitsiLocalStorage.clear(); 12 | jitsiLocalStorage.removeAllListeners(); 13 | jitsiLocalStorage.setLocalStorageDisabled(false); 14 | }); 15 | 16 | describe('Basic storage operations', () => { 17 | it('should store and retrieve string values', () => { 18 | jitsiLocalStorage.setItem('testKey', 'testValue', true); // Don't emit event for this test. 19 | const value = jitsiLocalStorage.getItem('testKey'); 20 | 21 | expect(value).to.equal('testValue'); 22 | }); 23 | 24 | it('should return null for non-existent keys', () => { 25 | const value = jitsiLocalStorage.getItem('nonExistentKey'); 26 | 27 | expect(value).to.be.null; 28 | }); 29 | 30 | it('should update existing key values', () => { 31 | jitsiLocalStorage.setItem('testKey', 'value1', true); 32 | jitsiLocalStorage.setItem('testKey', 'value2', true); 33 | const value = jitsiLocalStorage.getItem('testKey'); 34 | 35 | expect(value).to.equal('value2'); 36 | }); 37 | 38 | it('should remove items from storage', () => { 39 | jitsiLocalStorage.setItem('testKey', 'testValue', true); 40 | jitsiLocalStorage.removeItem('testKey'); 41 | const value = jitsiLocalStorage.getItem('testKey'); 42 | 43 | expect(value).to.be.null; 44 | }); 45 | 46 | it('should clear all items from storage', () => { 47 | jitsiLocalStorage.setItem('key1', 'value1', true); 48 | jitsiLocalStorage.setItem('key2', 'value2', true); 49 | jitsiLocalStorage.clear(); 50 | 51 | expect(jitsiLocalStorage.getItem('key1')).to.be.null; 52 | expect(jitsiLocalStorage.getItem('key2')).to.be.null; 53 | expect(jitsiLocalStorage.length).to.equal(0); 54 | }); 55 | }); 56 | 57 | describe('Storage length property', () => { 58 | it('should return correct length when empty', () => { 59 | expect(jitsiLocalStorage.length).to.equal(0); 60 | }); 61 | 62 | it('should return correct length with items', () => { 63 | jitsiLocalStorage.setItem('key1', 'value1', true); 64 | jitsiLocalStorage.setItem('key2', 'value2', true); 65 | jitsiLocalStorage.setItem('key3', 'value3', true); 66 | 67 | expect(jitsiLocalStorage.length).to.equal(3); 68 | }); 69 | 70 | it('should update length when removing items', () => { 71 | jitsiLocalStorage.setItem('key1', 'value1', true); 72 | jitsiLocalStorage.setItem('key2', 'value2', true); 73 | expect(jitsiLocalStorage.length).to.equal(2); 74 | 75 | jitsiLocalStorage.removeItem('key1'); 76 | expect(jitsiLocalStorage.length).to.equal(1); 77 | }); 78 | }); 79 | 80 | describe('key() method', () => { 81 | it('should return key name by index', () => { 82 | jitsiLocalStorage.setItem('key1', 'value1', true); 83 | jitsiLocalStorage.setItem('key2', 'value2', true); 84 | 85 | const key0 = jitsiLocalStorage.key(0); 86 | const key1 = jitsiLocalStorage.key(1); 87 | 88 | expect([ 'key1', 'key2' ]).to.include(key0); 89 | expect([ 'key1', 'key2' ]).to.include(key1); 90 | }); 91 | 92 | it('should return null for out-of-bounds index', () => { 93 | jitsiLocalStorage.setItem('key1', 'value1', true); 94 | 95 | expect(jitsiLocalStorage.key(10)).to.be.null; 96 | }); 97 | }); 98 | 99 | describe('Event emission', () => { 100 | it('should emit "changed" event when setting item', (done) => { 101 | jitsiLocalStorage.once('changed', () => { 102 | done(); 103 | }); 104 | 105 | jitsiLocalStorage.setItem('testKey', 'testValue'); 106 | }); 107 | 108 | it('should not emit "changed" event when dontEmitChangedEvent is true', (done) => { 109 | let eventEmitted = false; 110 | 111 | jitsiLocalStorage.once('changed', () => { 112 | eventEmitted = true; 113 | }); 114 | 115 | jitsiLocalStorage.setItem('testKey', 'testValue', true); 116 | 117 | // Wait a bit to ensure event is not emitted. 118 | setTimeout(() => { 119 | expect(eventEmitted).to.be.false; 120 | done(); 121 | }, 50); 122 | }); 123 | 124 | it('should emit "changed" event when removing item', (done) => { 125 | jitsiLocalStorage.setItem('testKey', 'testValue', true); 126 | 127 | jitsiLocalStorage.once('changed', () => { 128 | done(); 129 | }); 130 | 131 | jitsiLocalStorage.removeItem('testKey'); 132 | }); 133 | 134 | it('should emit "changed" event when clearing storage', (done) => { 135 | jitsiLocalStorage.setItem('testKey', 'testValue', true); 136 | 137 | jitsiLocalStorage.once('changed', () => { 138 | done(); 139 | }); 140 | 141 | jitsiLocalStorage.clear(); 142 | }); 143 | }); 144 | 145 | describe('serialize() method', () => { 146 | it('should serialize empty storage', () => { 147 | const serialized = jitsiLocalStorage.serialize(); 148 | 149 | expect(serialized).to.equal('{}'); 150 | }); 151 | 152 | it('should serialize storage with items', () => { 153 | jitsiLocalStorage.setItem('key1', 'value1', true); 154 | jitsiLocalStorage.setItem('key2', 'value2', true); 155 | 156 | const serialized = jitsiLocalStorage.serialize(); 157 | const parsed = JSON.parse(serialized); 158 | 159 | expect(parsed).to.deep.equal({ 160 | key1: 'value1', 161 | key2: 'value2' 162 | }); 163 | }); 164 | 165 | it('should serialize storage with ignored keys', () => { 166 | jitsiLocalStorage.setItem('key1', 'value1', true); 167 | jitsiLocalStorage.setItem('key2', 'value2', true); 168 | jitsiLocalStorage.setItem('key3', 'value3', true); 169 | 170 | const serialized = jitsiLocalStorage.serialize([ 'key2' ]); 171 | const parsed = JSON.parse(serialized); 172 | 173 | expect(parsed).to.deep.equal({ 174 | key1: 'value1', 175 | key3: 'value3' 176 | }); 177 | }); 178 | 179 | it('should serialize storage with multiple ignored keys', () => { 180 | jitsiLocalStorage.setItem('key1', 'value1', true); 181 | jitsiLocalStorage.setItem('key2', 'value2', true); 182 | jitsiLocalStorage.setItem('key3', 'value3', true); 183 | 184 | const serialized = jitsiLocalStorage.serialize([ 'key1', 'key3' ]); 185 | const parsed = JSON.parse(serialized); 186 | 187 | expect(parsed).to.deep.equal({ 188 | key2: 'value2' 189 | }); 190 | }); 191 | }); 192 | 193 | describe('DummyLocalStorage fallback', () => { 194 | it('should use DummyLocalStorage when disabled', () => { 195 | jitsiLocalStorage.setLocalStorageDisabled(true); 196 | 197 | expect(jitsiLocalStorage.isLocalStorageDisabled()).to.be.true; 198 | }); 199 | 200 | it('should work with DummyLocalStorage', () => { 201 | jitsiLocalStorage.setLocalStorageDisabled(true); 202 | 203 | jitsiLocalStorage.setItem('testKey', 'testValue', true); 204 | const value = jitsiLocalStorage.getItem('testKey'); 205 | 206 | expect(value).to.equal('testValue'); 207 | }); 208 | 209 | it('should serialize DummyLocalStorage correctly', () => { 210 | jitsiLocalStorage.setLocalStorageDisabled(true); 211 | 212 | jitsiLocalStorage.setItem('key1', 'value1', true); 213 | jitsiLocalStorage.setItem('key2', 'value2', true); 214 | 215 | const serialized = jitsiLocalStorage.serialize(); 216 | const parsed = JSON.parse(serialized); 217 | 218 | expect(parsed).to.deep.equal({ 219 | key1: 'value1', 220 | key2: 'value2' 221 | }); 222 | }); 223 | 224 | it('should serialize DummyLocalStorage with ignored keys', () => { 225 | jitsiLocalStorage.setLocalStorageDisabled(true); 226 | 227 | jitsiLocalStorage.setItem('key1', 'value1', true); 228 | jitsiLocalStorage.setItem('key2', 'value2', true); 229 | jitsiLocalStorage.setItem('key3', 'value3', true); 230 | 231 | const serialized = jitsiLocalStorage.serialize([ 'key2' ]); 232 | const parsed = JSON.parse(serialized); 233 | 234 | expect(parsed).to.deep.equal({ 235 | key1: 'value1', 236 | key3: 'value3' 237 | }); 238 | }); 239 | 240 | it('should switch between localStorage and DummyLocalStorage', () => { 241 | // Start with regular localStorage. 242 | expect(jitsiLocalStorage.isLocalStorageDisabled()).to.be.false; 243 | 244 | // Switch to DummyLocalStorage. 245 | jitsiLocalStorage.setLocalStorageDisabled(true); 246 | expect(jitsiLocalStorage.isLocalStorageDisabled()).to.be.true; 247 | 248 | // Switch back to regular localStorage. 249 | jitsiLocalStorage.setLocalStorageDisabled(false); 250 | expect(jitsiLocalStorage.isLocalStorageDisabled()).to.be.false; 251 | }); 252 | 253 | it('should not share data when switching storage backends', () => { 254 | // Set item in regular localStorage. 255 | jitsiLocalStorage.setItem('testKey', 'value1', true); 256 | 257 | // Switch to DummyLocalStorage (starts empty). 258 | jitsiLocalStorage.setLocalStorageDisabled(true); 259 | expect(jitsiLocalStorage.getItem('testKey')).to.be.null; 260 | 261 | // Set different value in DummyLocalStorage. 262 | jitsiLocalStorage.setItem('testKey', 'value2', true); 263 | expect(jitsiLocalStorage.getItem('testKey')).to.equal('value2'); 264 | 265 | // Switch back to regular localStorage (should have original value). 266 | jitsiLocalStorage.setLocalStorageDisabled(false); 267 | expect(jitsiLocalStorage.getItem('testKey')).to.equal('value1'); 268 | }); 269 | }); 270 | 271 | describe('DummyLocalStorage removeItem', () => { 272 | it('should remove items from DummyLocalStorage', () => { 273 | jitsiLocalStorage.setLocalStorageDisabled(true); 274 | 275 | jitsiLocalStorage.setItem('key1', 'value1', true); 276 | jitsiLocalStorage.setItem('key2', 'value2', true); 277 | expect(jitsiLocalStorage.getItem('key1')).to.equal('value1'); 278 | 279 | jitsiLocalStorage.removeItem('key1'); 280 | expect(jitsiLocalStorage.getItem('key1')).to.be.null; 281 | expect(jitsiLocalStorage.getItem('key2')).to.equal('value2'); 282 | 283 | // Restore 284 | jitsiLocalStorage.setLocalStorageDisabled(false); 285 | }); 286 | }); 287 | 288 | describe('DummyLocalStorage key() method', () => { 289 | it('should return null for out-of-bounds index in DummyLocalStorage', () => { 290 | jitsiLocalStorage.setLocalStorageDisabled(true); 291 | 292 | jitsiLocalStorage.setItem('key1', 'value1', true); 293 | expect(jitsiLocalStorage.key(0)).to.equal('key1'); 294 | expect(jitsiLocalStorage.key(1)).to.be.null; // Out of bounds 295 | expect(jitsiLocalStorage.key(999)).to.be.null; // Way out of bounds 296 | 297 | // Restore 298 | jitsiLocalStorage.setLocalStorageDisabled(false); 299 | }); 300 | }); 301 | 302 | describe('DummyLocalStorage length getter', () => { 303 | it('should return correct length from DummyLocalStorage', () => { 304 | jitsiLocalStorage.setLocalStorageDisabled(true); 305 | 306 | // Initially empty 307 | expect(jitsiLocalStorage.length).to.equal(0); 308 | 309 | // Add items 310 | jitsiLocalStorage.setItem('key1', 'value1', true); 311 | expect(jitsiLocalStorage.length).to.equal(1); 312 | 313 | jitsiLocalStorage.setItem('key2', 'value2', true); 314 | expect(jitsiLocalStorage.length).to.equal(2); 315 | 316 | // Remove item 317 | jitsiLocalStorage.removeItem('key1'); 318 | expect(jitsiLocalStorage.length).to.equal(1); 319 | 320 | // Clear all 321 | jitsiLocalStorage.clear(); 322 | expect(jitsiLocalStorage.length).to.equal(0); 323 | 324 | // Restore 325 | jitsiLocalStorage.setLocalStorageDisabled(false); 326 | }); 327 | }); 328 | 329 | describe('Error handling in setLocalStorageDisabled', () => { 330 | it('should fallback to DummyLocalStorage if localStorage access fails during switch', () => { 331 | // Save original localStorage 332 | const originalLocalStorage = Object.getOwnPropertyDescriptor(window, 'localStorage'); 333 | 334 | try { 335 | // First set to dummy storage 336 | jitsiLocalStorage.setLocalStorageDisabled(true); 337 | 338 | // Mock localStorage to throw an error when accessed 339 | Object.defineProperty(window, 'localStorage', { 340 | get() { 341 | throw new Error('localStorage is disabled'); 342 | }, 343 | configurable: true 344 | }); 345 | 346 | // Try to switch to regular localStorage (should catch error and fallback to Dummy) 347 | jitsiLocalStorage.setLocalStorageDisabled(false); 348 | 349 | // Should still work with DummyLocalStorage as fallback 350 | jitsiLocalStorage.setItem('test', 'value', true); 351 | expect(jitsiLocalStorage.getItem('test')).to.equal('value'); 352 | } finally { 353 | // Restore original localStorage 354 | if (originalLocalStorage) { 355 | Object.defineProperty(window, 'localStorage', originalLocalStorage); 356 | } 357 | 358 | // Reset to normal state 359 | jitsiLocalStorage.setLocalStorageDisabled(false); 360 | } 361 | }); 362 | 363 | it('should handle case when storage becomes undefined after error', () => { 364 | // Create a new instance to test 365 | const instance = new JitsiLocalStorage(); 366 | const originalLocalStorage = Object.getOwnPropertyDescriptor(window, 'localStorage'); 367 | 368 | try { 369 | // Set to dummy first 370 | instance.setLocalStorageDisabled(true); 371 | 372 | // Mock localStorage to return undefined 373 | Object.defineProperty(window, 'localStorage', { 374 | get() { 375 | return undefined as any; 376 | }, 377 | configurable: true 378 | }); 379 | 380 | // Try to switch to localStorage - will get undefined, should fallback to Dummy 381 | instance.setLocalStorageDisabled(false); 382 | 383 | // Verify it still works 384 | instance.setItem('test', 'value', true); 385 | expect(instance.getItem('test')).to.equal('value'); 386 | } finally { 387 | // Restore original localStorage 388 | if (originalLocalStorage) { 389 | Object.defineProperty(window, 'localStorage', originalLocalStorage); 390 | } 391 | } 392 | }); 393 | }); 394 | 395 | describe('Constructor error handling', () => { 396 | it('should fallback to DummyLocalStorage if localStorage throws during construction', () => { 397 | // Save original localStorage 398 | const originalLocalStorage = Object.getOwnPropertyDescriptor(window, 'localStorage'); 399 | 400 | try { 401 | // Mock localStorage to throw an error when accessed 402 | Object.defineProperty(window, 'localStorage', { 403 | get() { 404 | throw new Error('localStorage is disabled'); 405 | }, 406 | configurable: true 407 | }); 408 | 409 | // Create a new instance - should catch error and use DummyLocalStorage 410 | const instance = new JitsiLocalStorage(); 411 | 412 | // Should work with DummyLocalStorage 413 | instance.setItem('test', 'value', true); 414 | expect(instance.getItem('test')).to.equal('value'); 415 | expect(instance.isLocalStorageDisabled()).to.be.true; 416 | } finally { 417 | // Restore original localStorage 418 | if (originalLocalStorage) { 419 | Object.defineProperty(window, 'localStorage', originalLocalStorage); 420 | } 421 | } 422 | }); 423 | 424 | it('should fallback to DummyLocalStorage if localStorage is undefined', () => { 425 | // Save original localStorage 426 | const originalLocalStorage = Object.getOwnPropertyDescriptor(window, 'localStorage'); 427 | 428 | try { 429 | // Mock localStorage to be undefined 430 | Object.defineProperty(window, 'localStorage', { 431 | get() { 432 | return undefined; 433 | }, 434 | configurable: true 435 | }); 436 | 437 | // Create a new instance - should use DummyLocalStorage 438 | const instance = new JitsiLocalStorage(); 439 | 440 | // Should work with DummyLocalStorage 441 | instance.setItem('test', 'value', true); 442 | expect(instance.getItem('test')).to.equal('value'); 443 | expect(instance.isLocalStorageDisabled()).to.be.true; 444 | } finally { 445 | // Restore original localStorage 446 | if (originalLocalStorage) { 447 | Object.defineProperty(window, 'localStorage', originalLocalStorage); 448 | } 449 | } 450 | }); 451 | }); 452 | }); 453 | -------------------------------------------------------------------------------- /browser-detection/BrowserDetection.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for BrowserDetection class. 3 | * Tests browser and engine detection logic with various user agent strings. 4 | */ 5 | import { expect } from '@esm-bundle/chai'; 6 | import BrowserDetection from './BrowserDetection'; 7 | import { Browser, Engine } from './constants'; 8 | 9 | describe('BrowserDetection', () => { 10 | describe('Chrome detection', () => { 11 | it('should detect Chrome browser', () => { 12 | const browserInfo = { 13 | name: 'Chrome', 14 | version: '120.0.0.0', 15 | engine: 'Blink', 16 | engineVersion: '120.0.0.0' 17 | }; 18 | const detector = new BrowserDetection(browserInfo); 19 | 20 | expect(detector.getName()).to.equal(Browser.CHROME); 21 | expect(detector.isChrome()).to.be.true; 22 | expect(detector.isChromiumBased()).to.be.true; 23 | }); 24 | 25 | it('should extract Chrome version correctly', () => { 26 | const browserInfo = { 27 | name: 'Chrome', 28 | version: '120.0.6099.109', 29 | engine: 'Blink', 30 | engineVersion: '120.0.0.0' 31 | }; 32 | const detector = new BrowserDetection(browserInfo); 33 | 34 | expect(detector.getVersion()).to.equal('120.0.6099.109'); 35 | }); 36 | 37 | it('should detect Chromium-based browsers via Blink engine', () => { 38 | const browserInfo = { 39 | name: 'Edge', 40 | version: '120.0.0.0', 41 | engine: 'Blink', 42 | engineVersion: '120.0.0.0' 43 | }; 44 | const detector = new BrowserDetection(browserInfo); 45 | 46 | expect(detector.isChromiumBased()).to.be.true; 47 | expect(detector.getEngine()).to.equal(Engine.BLINK); 48 | }); 49 | }); 50 | 51 | describe('Firefox detection', () => { 52 | it('should detect Firefox browser', () => { 53 | const browserInfo = { 54 | name: 'Firefox', 55 | version: '121.0', 56 | engine: 'Gecko', 57 | engineVersion: '121.0' 58 | }; 59 | const detector = new BrowserDetection(browserInfo); 60 | 61 | expect(detector.getName()).to.equal(Browser.FIREFOX); 62 | expect(detector.isFirefox()).to.be.true; 63 | expect(detector.getEngine()).to.equal(Engine.GECKO); 64 | }); 65 | 66 | it('should not detect Firefox as Chromium-based', () => { 67 | const browserInfo = { 68 | name: 'Firefox', 69 | version: '121.0', 70 | engine: 'Gecko', 71 | engineVersion: '121.0' 72 | }; 73 | const detector = new BrowserDetection(browserInfo); 74 | 75 | expect(detector.isChromiumBased()).to.be.false; 76 | expect(detector.isChrome()).to.be.false; 77 | }); 78 | }); 79 | 80 | describe('Safari detection', () => { 81 | it('should detect Safari browser', () => { 82 | const browserInfo = { 83 | name: 'Safari', 84 | version: '17.2', 85 | engine: 'WebKit', 86 | engineVersion: '605.1.15' 87 | }; 88 | const detector = new BrowserDetection(browserInfo); 89 | 90 | expect(detector.getName()).to.equal(Browser.SAFARI); 91 | expect(detector.isSafari()).to.be.true; 92 | expect(detector.isWebKitBased()).to.be.true; 93 | }); 94 | 95 | it('should detect WebKit engine correctly', () => { 96 | const browserInfo = { 97 | name: 'Safari', 98 | version: '17.2', 99 | engine: 'WebKit', 100 | engineVersion: '605.1.15' 101 | }; 102 | const detector = new BrowserDetection(browserInfo); 103 | 104 | expect(detector.getEngine()).to.equal(Engine.WEBKIT); 105 | expect(detector.isWebKitBased()).to.be.true; 106 | expect(detector.isChromiumBased()).to.be.false; 107 | }); 108 | }); 109 | 110 | describe('Electron detection', () => { 111 | it('should detect Electron environment', () => { 112 | const browserInfo = { 113 | name: 'Electron', 114 | version: '28.0.0', 115 | engine: 'Blink', 116 | engineVersion: '120.0.0.0' 117 | }; 118 | const detector = new BrowserDetection(browserInfo); 119 | 120 | expect(detector.getName()).to.equal(Browser.ELECTRON); 121 | expect(detector.isElectron()).to.be.true; 122 | }); 123 | }); 124 | 125 | describe('Version comparison', () => { 126 | describe('isVersionGreaterThan', () => { 127 | it('should return true when browser version is greater', () => { 128 | const browserInfo = { 129 | name: 'Chrome', 130 | version: '120.0.0.0', 131 | engine: 'Blink', 132 | engineVersion: '120.0.0.0' 133 | }; 134 | const detector = new BrowserDetection(browserInfo); 135 | 136 | expect(detector.isVersionGreaterThan(119)).to.be.true; 137 | expect(detector.isVersionGreaterThan(100)).to.be.true; 138 | }); 139 | 140 | it('should return false when browser version is not greater', () => { 141 | const browserInfo = { 142 | name: 'Chrome', 143 | version: '120.0.0.0', 144 | engine: 'Blink', 145 | engineVersion: '120.0.0.0' 146 | }; 147 | const detector = new BrowserDetection(browserInfo); 148 | 149 | expect(detector.isVersionGreaterThan(120)).to.be.false; 150 | expect(detector.isVersionGreaterThan(121)).to.be.false; 151 | }); 152 | }); 153 | 154 | describe('isVersionLessThan', () => { 155 | it('should return true when browser version is less', () => { 156 | const browserInfo = { 157 | name: 'Chrome', 158 | version: '120.0.0.0', 159 | engine: 'Blink', 160 | engineVersion: '120.0.0.0' 161 | }; 162 | const detector = new BrowserDetection(browserInfo); 163 | 164 | expect(detector.isVersionLessThan(121)).to.be.true; 165 | expect(detector.isVersionLessThan(130)).to.be.true; 166 | }); 167 | 168 | it('should return false when browser version is not less', () => { 169 | const browserInfo = { 170 | name: 'Chrome', 171 | version: '120.0.0.0', 172 | engine: 'Blink', 173 | engineVersion: '120.0.0.0' 174 | }; 175 | const detector = new BrowserDetection(browserInfo); 176 | 177 | expect(detector.isVersionLessThan(120)).to.be.false; 178 | expect(detector.isVersionLessThan(119)).to.be.false; 179 | }); 180 | }); 181 | 182 | describe('isVersionEqualTo', () => { 183 | it('should return true when browser version is equal', () => { 184 | const browserInfo = { 185 | name: 'Chrome', 186 | version: '120.0.0.0', 187 | engine: 'Blink', 188 | engineVersion: '120.0.0.0' 189 | }; 190 | const detector = new BrowserDetection(browserInfo); 191 | 192 | expect(detector.isVersionEqualTo(120)).to.be.true; 193 | }); 194 | 195 | it('should return false when browser version is not equal', () => { 196 | const browserInfo = { 197 | name: 'Chrome', 198 | version: '120.0.0.0', 199 | engine: 'Blink', 200 | engineVersion: '120.0.0.0' 201 | }; 202 | const detector = new BrowserDetection(browserInfo); 203 | 204 | expect(detector.isVersionEqualTo(119)).to.be.false; 205 | expect(detector.isVersionEqualTo(121)).to.be.false; 206 | }); 207 | }); 208 | }); 209 | 210 | describe('Engine version comparison', () => { 211 | describe('isEngineVersionGreaterThan', () => { 212 | it('should return true when engine version is greater', () => { 213 | const browserInfo = { 214 | name: 'Chrome', 215 | version: '120.0.0.0', 216 | engine: 'Blink', 217 | engineVersion: '120.0.0.0' 218 | }; 219 | const detector = new BrowserDetection(browserInfo); 220 | 221 | expect(detector.isEngineVersionGreaterThan(119)).to.be.true; 222 | }); 223 | 224 | it('should return false when engine version is not greater', () => { 225 | const browserInfo = { 226 | name: 'Chrome', 227 | version: '120.0.0.0', 228 | engine: 'Blink', 229 | engineVersion: '120.0.0.0' 230 | }; 231 | const detector = new BrowserDetection(browserInfo); 232 | 233 | expect(detector.isEngineVersionGreaterThan(120)).to.be.false; 234 | expect(detector.isEngineVersionGreaterThan(121)).to.be.false; 235 | }); 236 | }); 237 | 238 | describe('isEngineVersionLessThan', () => { 239 | it('should return true when engine version is less', () => { 240 | const browserInfo = { 241 | name: 'Chrome', 242 | version: '120.0.0.0', 243 | engine: 'Blink', 244 | engineVersion: '120.0.0.0' 245 | }; 246 | const detector = new BrowserDetection(browserInfo); 247 | 248 | expect(detector.isEngineVersionLessThan(121)).to.be.true; 249 | }); 250 | 251 | it('should return false when engine version is not less', () => { 252 | const browserInfo = { 253 | name: 'Chrome', 254 | version: '120.0.0.0', 255 | engine: 'Blink', 256 | engineVersion: '120.0.0.0' 257 | }; 258 | const detector = new BrowserDetection(browserInfo); 259 | 260 | expect(detector.isEngineVersionLessThan(120)).to.be.false; 261 | }); 262 | }); 263 | 264 | describe('isEngineVersionEqualTo', () => { 265 | it('should return true when engine version is equal', () => { 266 | const browserInfo = { 267 | name: 'Chrome', 268 | version: '120.0.0.0', 269 | engine: 'Blink', 270 | engineVersion: '120.0.0.0' 271 | }; 272 | const detector = new BrowserDetection(browserInfo); 273 | 274 | expect(detector.isEngineVersionEqualTo(120)).to.be.true; 275 | }); 276 | 277 | it('should return false when engine version is not equal', () => { 278 | const browserInfo = { 279 | name: 'Chrome', 280 | version: '120.0.0.0', 281 | engine: 'Blink', 282 | engineVersion: '120.0.0.0' 283 | }; 284 | const detector = new BrowserDetection(browserInfo); 285 | 286 | expect(detector.isEngineVersionEqualTo(119)).to.be.false; 287 | }); 288 | }); 289 | }); 290 | 291 | describe('Multiple browser checks', () => { 292 | it('should correctly identify which browser it is not', () => { 293 | const browserInfo = { 294 | name: 'Chrome', 295 | version: '120.0.0.0', 296 | engine: 'Blink', 297 | engineVersion: '120.0.0.0' 298 | }; 299 | const detector = new BrowserDetection(browserInfo); 300 | 301 | expect(detector.isChrome()).to.be.true; 302 | expect(detector.isFirefox()).to.be.false; 303 | expect(detector.isSafari()).to.be.false; 304 | expect(detector.isElectron()).to.be.false; 305 | expect(detector.isReactNative()).to.be.false; 306 | }); 307 | }); 308 | 309 | describe('React Native detection', () => { 310 | it('should detect React Native from user agent with version', () => { 311 | // Save original user agent 312 | const originalUserAgent = navigator.userAgent; 313 | 314 | try { 315 | // Mock React Native user agent (lowercase to match Browser.REACT_NATIVE constant) 316 | Object.defineProperty(navigator, 'userAgent', { 317 | value: 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) react-native/0.70.0', 318 | configurable: true 319 | }); 320 | 321 | const detector = new BrowserDetection(); 322 | // When version is present in UA, name is extracted from the match 323 | expect(detector.getName()).to.equal('react-native'); 324 | expect(detector.getVersion()).to.equal('0.70.0'); 325 | expect(detector.isReactNative()).to.be.true; 326 | } finally { 327 | // Restore original values 328 | Object.defineProperty(navigator, 'userAgent', { 329 | value: originalUserAgent, 330 | configurable: true 331 | }); 332 | } 333 | }); 334 | 335 | it('should detect React Native from navigator.product', () => { 336 | // Save original values 337 | const originalUserAgent = navigator.userAgent; 338 | const originalProduct = navigator.product; 339 | 340 | try { 341 | // Mock navigator.product for React Native 342 | Object.defineProperty(navigator, 'product', { 343 | value: 'ReactNative', 344 | configurable: true 345 | }); 346 | 347 | const detector = new BrowserDetection(); 348 | expect(detector.getName()).to.equal(Browser.REACT_NATIVE); 349 | expect(detector.isReactNative()).to.be.true; 350 | } finally { 351 | // Restore original values 352 | Object.defineProperty(navigator, 'product', { 353 | value: originalProduct, 354 | configurable: true 355 | }); 356 | } 357 | }); 358 | }); 359 | 360 | describe('Constructor without arguments (uses user agent)', () => { 361 | it('should detect browser from current user agent', () => { 362 | const detector = new BrowserDetection(); 363 | 364 | // Just verify it creates a detector and can call methods 365 | expect(detector.getName()).to.be.a('string'); 366 | expect(detector.getVersion()).to.be.a('string'); 367 | expect(detector.getEngine()).to.be.a('string'); 368 | }); 369 | 370 | it('should use parser to get browser name when no cached name', () => { 371 | const detector = new BrowserDetection(); 372 | 373 | // Call getName which should fallback to parser if no cached _name 374 | const name = detector.getName(); 375 | expect(name).to.be.a('string'); 376 | expect(name.length).to.be.greaterThan(0); 377 | }); 378 | }); 379 | 380 | describe('Additional OS and Engine methods', () => { 381 | it('should get engine version', () => { 382 | const browserInfo = { 383 | name: 'Chrome', 384 | version: '120.0.0.0', 385 | engine: 'Blink', 386 | engineVersion: '120.5.0.0' 387 | }; 388 | const detector = new BrowserDetection(browserInfo); 389 | 390 | expect(detector.getEngineVersion()).to.equal('120.5.0.0'); 391 | }); 392 | 393 | it('should get OS name', () => { 394 | const detector = new BrowserDetection(); 395 | 396 | expect(detector.getOS()).to.be.a('string'); 397 | }); 398 | 399 | it('should get OS version', () => { 400 | const detector = new BrowserDetection(); 401 | const osVersion = detector.getOSVersion(); 402 | 403 | // OS version can be undefined in headless CI environments (e.g., GitHub Actions) 404 | // but should be a string when available in regular browsers 405 | if (osVersion !== undefined) { 406 | expect(osVersion).to.be.a('string'); 407 | } 408 | }); 409 | 410 | it('should return false for isVersionLessThan when version is empty string', () => { 411 | const browserInfo = { 412 | name: 'Chrome', 413 | version: '', 414 | engine: 'Blink', 415 | engineVersion: '120.0.0.0' 416 | }; 417 | const detector = new BrowserDetection(browserInfo); 418 | 419 | expect(detector.isVersionLessThan(100)).to.be.false; 420 | }); 421 | 422 | it('should return false for isVersionGreaterThan when version is empty string', () => { 423 | const browserInfo = { 424 | name: 'Chrome', 425 | version: '', 426 | engine: 'Blink', 427 | engineVersion: '120.0.0.0' 428 | }; 429 | const detector = new BrowserDetection(browserInfo); 430 | 431 | expect(detector.isVersionGreaterThan(100)).to.be.false; 432 | }); 433 | 434 | it('should return false for isVersionEqualTo when version is empty string', () => { 435 | const browserInfo = { 436 | name: 'Chrome', 437 | version: '', 438 | engine: 'Blink', 439 | engineVersion: '120.0.0.0' 440 | }; 441 | const detector = new BrowserDetection(browserInfo); 442 | 443 | expect(detector.isVersionEqualTo(100)).to.be.false; 444 | }); 445 | 446 | it('should fallback to parser getVersion when _version is empty', () => { 447 | const browserInfo = { 448 | name: 'Chrome', 449 | version: '', 450 | engine: 'Blink', 451 | engineVersion: '120.0.0.0' 452 | }; 453 | const detector = new BrowserDetection(browserInfo); 454 | 455 | // Should fallback to parser which gets version from navigator.userAgent 456 | const version = detector.getVersion(); 457 | expect(version).to.be.a('string'); 458 | }); 459 | 460 | it('should return false for engine version greater comparison when engineVersion is empty', () => { 461 | const browserInfo = { 462 | name: 'Chrome', 463 | version: '120.0.0.0', 464 | engine: 'Blink', 465 | engineVersion: '' 466 | }; 467 | const detector = new BrowserDetection(browserInfo); 468 | 469 | expect(detector.isEngineVersionGreaterThan(100)).to.be.false; 470 | }); 471 | 472 | it('should return false for engine version less comparison when engineVersion is empty', () => { 473 | const browserInfo = { 474 | name: 'Chrome', 475 | version: '120.0.0.0', 476 | engine: 'Blink', 477 | engineVersion: '' 478 | }; 479 | const detector = new BrowserDetection(browserInfo); 480 | 481 | expect(detector.isEngineVersionLessThan(100)).to.be.false; 482 | }); 483 | 484 | it('should return false for engine version equal comparison when engineVersion is empty', () => { 485 | const browserInfo = { 486 | name: 'Chrome', 487 | version: '120.0.0.0', 488 | engine: 'Blink', 489 | engineVersion: '' 490 | }; 491 | const detector = new BrowserDetection(browserInfo); 492 | 493 | expect(detector.isEngineVersionEqualTo(100)).to.be.false; 494 | }); 495 | 496 | it('should handle browser info without engine (undefined branch)', () => { 497 | // Test the ternary: engine ? ENGINES[engine] : undefined 498 | const browserInfo = { 499 | name: 'Chrome', 500 | version: '120.0.0.0' 501 | }; 502 | const detector = new BrowserDetection(browserInfo); 503 | 504 | // When engine is not provided, getEngine should return undefined 505 | expect(detector.getEngine()).to.be.undefined; 506 | }); 507 | }); 508 | }); 509 | --------------------------------------------------------------------------------