├── .changeset
├── README.md
└── config.json
├── .editorconfig
├── .gitattributes
├── .github
└── workflows
│ ├── ci.yml
│ ├── playwright-tests.yml
│ ├── release-pr.yml
│ └── release.yml
├── .gitignore
├── .node-version
├── .prettierignore
├── .prettierrc
├── .vscode
├── settings.json
└── tasks.json
├── .yarn
├── patches
│ ├── @jest-environment-npm-28.1.3-506a81a227.patch
│ └── superstruct-npm-1.0.4-44d328b887.patch
└── releases
│ └── yarn-4.1.1.cjs
├── .yarnrc.yml
├── README.md
├── examples
└── create-react-app-typescript
│ ├── .gitignore
│ ├── README.md
│ ├── package.json
│ ├── playwright.config.ts
│ ├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
│ ├── src
│ ├── App.css
│ ├── App.tsx
│ ├── index.css
│ ├── index.tsx
│ ├── logo.svg
│ ├── react-app-env.d.ts
│ ├── reportWebVitals.ts
│ └── setupTests.ts
│ ├── test
│ ├── example.spec.ts
│ └── upload-and-check-recording.js
│ └── tsconfig.json
├── package.json
├── packages
├── cypress
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── example
│ │ ├── .gitignore
│ │ ├── cypress.config.js
│ │ ├── cypress
│ │ │ ├── e2e
│ │ │ │ └── example.cy.js
│ │ │ └── support
│ │ │ │ └── e2e.js
│ │ ├── modules.d.ts
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── favicon.ico
│ │ │ ├── index.html
│ │ │ ├── logo192.png
│ │ │ ├── logo512.png
│ │ │ ├── manifest.json
│ │ │ └── robots.txt
│ │ ├── src
│ │ │ ├── App.css
│ │ │ ├── App.tsx
│ │ │ ├── index.css
│ │ │ ├── index.tsx
│ │ │ ├── logo.svg
│ │ │ └── reportWebVitals.ts
│ │ └── tsconfig.json
│ ├── package.json
│ ├── src
│ │ ├── constants.ts
│ │ ├── error.ts
│ │ ├── features.ts
│ │ ├── fixture.ts
│ │ ├── index.ts
│ │ ├── junit.ts
│ │ ├── reporter.ts
│ │ ├── server.ts
│ │ ├── steps.ts
│ │ └── support.ts
│ ├── support.d.ts
│ ├── support.js
│ ├── tests
│ │ └── driver.ts
│ └── tsconfig.json
├── jest
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── bin.js
│ ├── package.json
│ ├── runner.d.ts
│ ├── runner.js
│ ├── src
│ │ ├── bin.ts
│ │ ├── index.ts
│ │ ├── runner.ts
│ │ └── utils.ts
│ └── tsconfig.json
├── node
│ ├── LICENSE
│ ├── README.md
│ ├── bin
│ │ └── replay-node
│ ├── package.json
│ └── src
│ │ └── bin.js
├── playwright
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── reporter.d.ts
│ ├── reporter.js
│ ├── src
│ │ ├── constants.ts
│ │ ├── error.ts
│ │ ├── fixture.ts
│ │ ├── index.ts
│ │ ├── playwrightTypes.ts
│ │ ├── reporter.ts
│ │ └── stackTrace.ts
│ └── tsconfig.json
├── puppeteer
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── bin.js
│ ├── first-run.js
│ ├── package.json
│ ├── src
│ │ ├── bin.ts
│ │ ├── first-run.ts
│ │ ├── index.ts
│ │ └── install.ts
│ └── tsconfig.json
├── replayio
│ ├── .eslintrc.json
│ ├── .prettierignore
│ ├── CHANGELOG.md
│ ├── Contributing.md
│ ├── README.md
│ ├── bin.js
│ ├── jest.config.js
│ ├── package.json
│ ├── src
│ │ ├── bin.ts
│ │ ├── commands
│ │ │ ├── info.ts
│ │ │ ├── list.ts
│ │ │ ├── login.ts
│ │ │ ├── logout.ts
│ │ │ ├── open.ts
│ │ │ ├── record.ts
│ │ │ ├── remove.ts
│ │ │ ├── update.ts
│ │ │ ├── upload-source-maps.ts
│ │ │ ├── upload.ts
│ │ │ └── whoami.ts
│ │ ├── config.ts
│ │ └── utils
│ │ │ ├── async
│ │ │ ├── logAsyncOperation.ts
│ │ │ └── logPromise.ts
│ │ │ ├── browser
│ │ │ ├── getBrowserPath.ts
│ │ │ ├── getRunningProcess.ts
│ │ │ ├── killBrowserIfRunning.ts
│ │ │ ├── launchBrowser.ts
│ │ │ └── reportBrowserCrash.ts
│ │ │ ├── commander
│ │ │ ├── commander.test.ts
│ │ │ ├── finalizeCommander.ts
│ │ │ ├── formatCommandOrOptionLine.ts
│ │ │ ├── formatOutput.ts
│ │ │ ├── registerCommand.ts
│ │ │ └── types.ts
│ │ │ ├── confirm.ts
│ │ │ ├── findMostRecentFile.ts
│ │ │ ├── formatting.ts
│ │ │ ├── graphql
│ │ │ └── fetchViewerFromGraphQL.ts
│ │ │ ├── initialization
│ │ │ ├── checkForNpmUpdate.ts
│ │ │ ├── checkForRuntimeUpdate.ts
│ │ │ ├── getCurrentRuntimeMetadata.ts
│ │ │ ├── initialize.ts
│ │ │ ├── promptForAuthentication.ts
│ │ │ ├── promptForNpmUpdate.ts
│ │ │ ├── promptForRuntimeUpdate.ts
│ │ │ └── types.ts
│ │ │ ├── installation
│ │ │ ├── config.ts
│ │ │ ├── getLatestReleases.test.ts
│ │ │ ├── getLatestReleases.ts
│ │ │ ├── installRelease.ts
│ │ │ └── types.ts
│ │ │ ├── prompt
│ │ │ ├── config.ts
│ │ │ ├── prompt.ts
│ │ │ ├── shouldPrompt.ts
│ │ │ ├── types.ts
│ │ │ └── updateCachedPromptData.ts
│ │ │ ├── recordings
│ │ │ └── uploadRecordings.ts
│ │ │ └── whoami.ts
│ └── tsconfig.json
├── shared
│ ├── jest.config.js
│ ├── package.json
│ ├── src
│ │ ├── ProcessError.ts
│ │ ├── array.test.ts
│ │ ├── array.ts
│ │ ├── async
│ │ │ ├── createDeferred.ts
│ │ │ ├── createPromiseQueue.test.ts
│ │ │ ├── createPromiseQueue.ts
│ │ │ ├── isPromiseLike.ts
│ │ │ ├── raceWithTimeout.ts
│ │ │ ├── retryOnFailure.test.ts
│ │ │ ├── retryOnFailure.ts
│ │ │ └── timeoutAfter.ts
│ │ ├── authentication
│ │ │ ├── AuthenticationError.ts
│ │ │ ├── authenticateByBrowser.ts
│ │ │ ├── config.ts
│ │ │ ├── getAccessToken.ts
│ │ │ ├── getAuthInfo.ts
│ │ │ ├── logoutIfAuthenticated.ts
│ │ │ ├── refreshAccessTokenOrThrow.ts
│ │ │ └── types.ts
│ │ ├── cache.ts
│ │ ├── cachedFetch.test.ts
│ │ ├── cachedFetch.ts
│ │ ├── config.ts
│ │ ├── date.ts
│ │ ├── getDeviceId.ts
│ │ ├── getObservabilityCachePath.ts
│ │ ├── getReplayPath.ts
│ │ ├── graphql
│ │ │ ├── GraphQLError.ts
│ │ │ ├── cachePath.ts
│ │ │ ├── fetchAuthInfoFromGraphQL.ts
│ │ │ ├── queryGraphQL.ts
│ │ │ └── updateCachedAuthInfo.ts
│ │ ├── hashValue.ts
│ │ ├── launchDarklylient.ts
│ │ ├── logUpdate.ts
│ │ ├── logger.ts
│ │ ├── maskString.test.ts
│ │ ├── maskString.ts
│ │ ├── mixpanelClient.test.ts
│ │ ├── mixpanelClient.ts
│ │ ├── printTable.ts
│ │ ├── process
│ │ │ ├── exitProcess.ts
│ │ │ ├── exitTasks.ts
│ │ │ ├── killProcess.ts
│ │ │ ├── registerExitTask.ts
│ │ │ ├── types.ts
│ │ │ └── waitForExitTasks.ts
│ │ ├── protocol
│ │ │ ├── ProtocolClient.ts
│ │ │ ├── ProtocolError.ts
│ │ │ ├── api
│ │ │ │ ├── addOriginalSource.ts
│ │ │ │ ├── addSourceMap.ts
│ │ │ │ ├── beginRecordingMultipartUpload.ts
│ │ │ │ ├── beginRecordingUpload.ts
│ │ │ │ ├── checkIfResourceExists.ts
│ │ │ │ ├── createResource.ts
│ │ │ │ ├── createSession.ts
│ │ │ │ ├── endRecordingMultipartUpload.ts
│ │ │ │ ├── endRecordingUpload.ts
│ │ │ │ ├── ensureProcessed.ts
│ │ │ │ ├── getResourceToken.ts
│ │ │ │ ├── processRecording.ts
│ │ │ │ ├── releaseSession.ts
│ │ │ │ ├── reportCrash.ts
│ │ │ │ ├── setAccessToken.ts
│ │ │ │ └── setRecordingMetadata.ts
│ │ │ └── types.ts
│ │ ├── recording
│ │ │ ├── canUpload.ts
│ │ │ ├── config.ts
│ │ │ ├── createSettledDeferred.ts
│ │ │ ├── findRecordingsWithShortIds.ts
│ │ │ ├── formatRecording.ts
│ │ │ ├── getRecordings.test.ts
│ │ │ ├── getRecordings.ts
│ │ │ ├── metadata
│ │ │ │ ├── addMetadata.ts
│ │ │ │ ├── legacy
│ │ │ │ │ ├── README.md
│ │ │ │ │ ├── env.test.ts
│ │ │ │ │ ├── env.ts
│ │ │ │ │ ├── source.test.ts
│ │ │ │ │ ├── source.ts
│ │ │ │ │ └── test
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── v1.ts
│ │ │ │ │ │ └── v2.ts
│ │ │ │ └── sanitizeMetadata.ts
│ │ │ ├── printRecordings.ts
│ │ │ ├── readRecordingLog.ts
│ │ │ ├── removeFromDisk.ts
│ │ │ ├── selectRecordings.ts
│ │ │ ├── types.ts
│ │ │ ├── updateRecordingLog.ts
│ │ │ └── upload
│ │ │ │ ├── types.ts
│ │ │ │ ├── uploadCrashData.ts
│ │ │ │ ├── uploadRecording.ts
│ │ │ │ ├── uploadSourceMaps.ts
│ │ │ │ ├── uploadWorker.ts
│ │ │ │ └── validateRecordingMetadata.ts
│ │ ├── runtime
│ │ │ ├── config.ts
│ │ │ ├── getLatestRuntimeRelease.ts
│ │ │ ├── getRuntimePath.ts
│ │ │ ├── installLatestRuntimeRelease.ts
│ │ │ ├── parseBuildId.ts
│ │ │ └── types.ts
│ │ ├── session
│ │ │ ├── createTaskQueue.test.ts
│ │ │ ├── createTaskQueue.ts
│ │ │ ├── getUserAgent.ts
│ │ │ ├── initializeAuthInfo.ts
│ │ │ ├── initializePackageInfo.ts
│ │ │ ├── initializeSession.ts
│ │ │ ├── types.ts
│ │ │ ├── waitForAuthInfo.ts
│ │ │ └── waitForPackageInfo.ts
│ │ ├── spawnProcess.ts
│ │ ├── strings
│ │ │ └── decode.ts
│ │ └── theme.ts
│ └── tsconfig.json
├── sourcemap-upload-webpack-plugin
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── .prettierrc
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ ├── index.js
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
├── sourcemap-upload
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── .npmignore
│ ├── .prettierrc
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
└── test-utils
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ ├── config.ts
│ ├── getAccessToken.ts
│ ├── index.ts
│ ├── legacy-cli
│ │ ├── README.md
│ │ ├── error.ts
│ │ ├── generateDefaultTitle.ts
│ │ ├── listAllRecordings.ts
│ │ ├── recordingLog.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── logging.ts
│ ├── metadata.ts
│ ├── metrics.ts
│ ├── reporter.ts
│ ├── sha-1.d.ts
│ ├── terminal.ts
│ ├── testId.ts
│ └── types.ts
│ ├── testId.d.ts
│ ├── testId.js
│ └── tsconfig.json
├── scripts
├── pkg-build
│ ├── bin.js
│ ├── package.json
│ ├── src
│ │ ├── bin.ts
│ │ ├── makePackagePredicate.ts
│ │ └── plugins
│ │ │ ├── bundledDependencies.ts
│ │ │ ├── esbuild.ts
│ │ │ ├── resolveErrors.ts
│ │ │ └── typescriptDeclarations.ts
│ └── tsconfig.json
└── tsconfig
│ ├── base.json
│ └── package.json
├── tsconfig.json
├── turbo.json
└── yarn.lock
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
3 | "access": "public",
4 | "baseBranch": "main",
5 | "changelog": ["@changesets/changelog-github", { "repo": "replayio/replay-cli" }],
6 | "commit": false,
7 | "fixed": [],
8 | "ignore": [],
9 | "linked": [],
10 | "updateInternalDependencies": "patch",
11 | "privatePackages": false,
12 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
13 | "onlyUpdatePeerDependentsWhenOutOfRange": true,
14 | "updateInternalDependents": "always"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 |
7 | [*.{js,json,yml}]
8 | charset = utf-8
9 | indent_style = space
10 | indent_size = 2
11 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | /.yarn/** linguist-vendored
2 | /.yarn/releases/* binary
3 | /.yarn/plugins/**/* binary
4 | /.pnp.* binary linguist-generated
5 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Build, lint and test
2 |
3 | on: [pull_request, workflow_dispatch]
4 |
5 | jobs:
6 | lint:
7 | runs-on: ubuntu-latest
8 | timeout-minutes: 20
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: actions/setup-node@v4
12 | with:
13 | node-version: "18"
14 | cache: "yarn"
15 | - run: yarn --immutable
16 | - run: yarn run build
17 | # it's added here to deps to avoid affecting Turborepo's dependency graph and related caching
18 | - run: yarn add --dev "replayio@workspace:^"
19 | - run: npx --no replayio install
20 | - run: yarn run lint
21 | - run: yarn run typecheck
22 | - run: yarn run test
23 |
--------------------------------------------------------------------------------
/.github/workflows/playwright-tests.yml:
--------------------------------------------------------------------------------
1 | name: Playwright tests
2 | on:
3 | pull_request:
4 | push:
5 | branches: [main, playwright-next]
6 |
7 | jobs:
8 | playwright-tests:
9 | name: Playwright tests
10 | runs-on: ubuntu-latest
11 | timeout-minutes: 20
12 | strategy:
13 | fail-fast: false
14 | matrix:
15 | playwright-version: [1.52.x, next]
16 | steps:
17 | - uses: actions/checkout@v4
18 | - uses: actions/setup-node@v4
19 | with:
20 | node-version: "18"
21 | cache: "yarn"
22 | - run: yarn --immutable
23 | - run: yarn run build
24 | # it's added here to deps to avoid affecting Turborepo's dependency graph and related caching
25 | - run: yarn add --dev "replayio@workspace:^"
26 | - run: npx --no replayio install
27 | # @playwright/test depends on fixed version of playwright and that depends on fixed version of playwright-core
28 | # so it should be enough to only enforce the version of @playwright/test here
29 | - run: yarn set resolution "@playwright/test@*" ${{ matrix.playwright-version }}
30 | - run: yarn test
31 | working-directory: examples/create-react-app-typescript
32 | - run: node test/upload-and-check-recording.js
33 | working-directory: examples/create-react-app-typescript
34 | env:
35 | REPLAY_API_KEY: ${{ secrets.RECORD_REPLAY_API_KEY }}
36 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency: ${{ github.workflow }}-${{ github.ref }}
9 |
10 | permissions: {}
11 |
12 | jobs:
13 | release:
14 | name: Release
15 |
16 | permissions:
17 | contents: write # to create release (changesets/action)
18 | issues: write # to post issue comments (changesets/action)
19 | pull-requests: write # to create pull request (changesets/action)
20 |
21 | if: github.repository == 'replayio/replay-cli'
22 |
23 | timeout-minutes: 20
24 |
25 | runs-on: ubuntu-latest
26 |
27 | steps:
28 | - uses: actions/checkout@v4
29 |
30 | - uses: actions/setup-node@v4
31 | with:
32 | node-version: "18"
33 | cache: "yarn"
34 |
35 | - run: yarn --immutable
36 |
37 | - run: yarn run build
38 |
39 | - name: Set up NPM token
40 | run: |
41 | echo "npmAuthToken: $NPM_TOKEN" >> .yarnrc.yml
42 | env:
43 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
44 |
45 | - name: Create Release Pull Request or Publish to npm
46 | uses: changesets/action@v1
47 | with:
48 | publish: yarn run release
49 | env:
50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | dist
4 | lerna-debug.log
5 | e2e-repos
6 | tsconfig.tsbuildinfo
7 | .turbo
8 |
9 | .pnp.*
10 | .yarn/*
11 | !.yarn/patches
12 | !.yarn/plugins
13 | !.yarn/releases
14 | !.yarn/sdks
15 | !.yarn/versions
16 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | v18.19.1
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | **/dist/
3 | **/lib/index.d.ts
4 | **/lib/index.js
5 | package-lock.json
6 | e2e-repos
7 | .turbo
8 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": false,
4 | "tabWidth": 2,
5 | "trailingComma": "es5",
6 | "arrowParens": "avoid",
7 | "printWidth": 100
8 | }
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "label": "turbo watch",
6 | "type": "shell",
7 | "command": "yarn run watch",
8 | "presentation": {
9 | "echo": true,
10 | "reveal": "silent",
11 | "focus": false,
12 | "panel": "dedicated",
13 | "showReuseMessage": true,
14 | "clear": false
15 | }
16 | }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/.yarn/patches/@jest-environment-npm-28.1.3-506a81a227.patch:
--------------------------------------------------------------------------------
1 | diff --git a/build/index.d.ts b/build/index.d.ts
2 | index 28c4291a052a16ae45954b48ee56c5aa74b83120..b2e71e49ec9962f0088739cacf995ade9bb4614c 100644
3 | --- a/build/index.d.ts
4 | +++ b/build/index.d.ts
5 | @@ -325,7 +325,8 @@ export declare interface JestEnvironmentConfig {
6 | }
7 |
8 | export declare interface JestImportMeta extends ImportMeta {
9 | - jest: Jest;
10 | + // it creates cnflicts between mismatched Jest versions in the repo
11 | + // jest: Jest;
12 | }
13 |
14 | export declare type Module = NodeModule;
15 |
--------------------------------------------------------------------------------
/.yarn/patches/superstruct-npm-1.0.4-44d328b887.patch:
--------------------------------------------------------------------------------
1 | diff --git a/package.json b/package.json
2 | index 6f759661ef1f6ad76ed41b150b63a922b00e11b3..9928998719f56bb23d1b0603ee45ba7e737e1604 100644
3 | --- a/package.json
4 | +++ b/package.json
5 | @@ -4,7 +4,6 @@
6 | "version": "1.0.4",
7 | "license": "MIT",
8 | "repository": "git://github.com/ianstormtaylor/superstruct.git",
9 | - "type": "module",
10 | "main": "./dist/index.cjs",
11 | "module": "./dist/index.mjs",
12 | "types": "./dist/index.d.ts",
13 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nmSelfReferences: false
2 |
3 | nodeLinker: node-modules
4 |
5 | yarnPath: .yarn/releases/yarn-4.1.1.cjs
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Replay CLI
2 |
3 | The Replay CLI provides packages for interacting with Replay to record, manage, and upload replays, as well as upload sourcemaps.
4 |
5 | To use Replay with a Desktop Browser, visit [replay.io](https://www.replay.io/) to download and install.
6 |
7 | ## Packages
8 |
9 | - [`/replayio`](./packages/replayio/README.md) CLI for viewing and uploading recordings
10 | - [`/cypress`](./packages/cypress/README.md) Beta Plugin for recording and capturing metadata for Cypress tests.
11 | - [`/playwright`](./packages/playwright/README.md) Beta Plugin for recording and capturing metadata for Playwright tests.
12 | - [`/puppeteer`](./packages/puppeteer/README.md) Experimental Plugin for recording Puppeteer tests.
13 | - [`/node`](./packages/node/README.md) Experimental CLI for recording Node.
14 | - [`/sourcemap-upload`](./packages/sourcemap-upload/README.md) CLI for uploading sourcemaps to Replay servers to use when viewing replays.
15 | - [`/sourcemap-upload-webpack-plugin`](./packages/sourcemap-upload-webpack-plugin/README.md) Webpack plugin for `sourcemap-upload`
16 |
17 | ## Developing
18 |
19 | 1. `yarn`
20 | 2. `yarn run build`
21 |
22 | That should create an installed version of the package in `dist` within each directory in `packages`.
23 |
24 | ## Testing
25 |
26 | You can run the unit tests for all of the packages with `yarn test`. You can run the unit tests for any individual package with `yarn run test` within that package.
27 |
28 | ## Deploying
29 |
30 | 1. Create changeset files in all PRs affecting the release artifacts by calling `yarn changeset`
31 | 2. Once the release is ready merge the currently open Version Packages PR
32 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/sdks
12 | !.yarn/versions
13 |
14 | # testing
15 | /coverage
16 | /test-results
17 |
18 | # production
19 | /build
20 |
21 | # misc
22 | .DS_Store
23 | .env.local
24 | .env.development.local
25 | .env.test.local
26 | .env.production.local
27 |
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/README.md:
--------------------------------------------------------------------------------
1 | ### Replay/Playwright Example
2 |
3 | This directory contains a single example Playwright test, which can be run with `yarn run test`.
4 |
5 | ### Confirming That Your Test Was Recorded
6 |
7 | Congratulations! You just recorded your first test with Replay! If you run `npx @replayio/replay ls` you should see an entry describing the details of the recording you just made. Mine looks like this:
8 |
9 | ```
10 | [
11 | {
12 | "id": 1146850316,
13 | "createTime": "Tue Mar 15 2022 15:27:55 GMT-0700 (Pacific Daylight Time)",
14 | "runtime": "gecko",
15 | "metadata": {
16 | "title": "Replay of localhost:3000",
17 | "uri": "http://localhost:3000/"
18 | },
19 | "status": "onDisk",
20 | "path": "/Users/josh/.replay/recording-1146850316.dat"
21 | }
22 | ]
23 | ```
24 |
25 | ### Uploading Your Replay
26 |
27 | You can now upload that replay by copying it's id and passing that as an argument to `npx @replayio/replay upload`, like this:
28 |
29 | ```
30 | npx @replayio/replay upload 1146850316
31 | ```
32 |
33 | \*Don't forget to set your `REPLAY_API_KEY`, which can be created from the settings panel of `app.replay.io`.
34 |
35 | If all goes well you should see something like the following output:
36 |
37 | ```
38 | Starting upload for 1146850316...
39 | Created remote recording 884d0a6a-78e2-4762-bcd7-b96dd649c0d3, uploading...
40 | Setting recording metadata for 884d0a6a-78e2-4762-bcd7-b96dd649c0d3
41 | Upload finished! View your Replay at: https://app.replay.io/recording/884d0a6a-78e2-4762-bcd7-b96dd649c0d3
42 | ```
43 |
44 | You can now review that test run in Replay!
45 |
46 | ### Todo List
47 |
48 | - Talk about automatically uploading things via the GitHub action.
49 | - Add a playwright/test setup for a simple Next.js App
50 | - Add Replay's Playwright Test Adapter
51 | - Add GH Comments which list the new replay recordings
52 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-react-app-typescript",
3 | "private": true,
4 | "dependencies": {
5 | "@playwright/test": "^1.52.0",
6 | "@replayio/playwright": "workspace:^",
7 | "@replayio/replay": "latest",
8 | "@testing-library/jest-dom": "^5.16.2",
9 | "@testing-library/react": "^12.1.4",
10 | "@testing-library/user-event": "^13.5.0",
11 | "@types/jest": "^27.4.1",
12 | "@types/node": "^20.11.27",
13 | "@types/react": "^17.0.40",
14 | "@types/react-dom": "^17.0.13",
15 | "react": "^17.0.2",
16 | "react-dom": "^17.0.2",
17 | "react-scripts": "5.0.0",
18 | "typescript": "^5.4.2",
19 | "web-vitals": "^2.1.4"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "playwright test --project replay-chromium",
25 | "eject": "react-scripts eject"
26 | },
27 | "eslintConfig": {
28 | "extends": [
29 | "react-app",
30 | "react-app/jest"
31 | ]
32 | },
33 | "browserslist": {
34 | "production": [
35 | ">0.2%",
36 | "not dead",
37 | "not op_mini all"
38 | ],
39 | "development": [
40 | "last 1 chrome version",
41 | "last 1 firefox version",
42 | "last 1 safari version"
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "@playwright/test";
2 | import { devices as replayDevices, replayReporter } from "@replayio/playwright";
3 |
4 | export default defineConfig({
5 | forbidOnly: !!process.env.CI,
6 | use: {
7 | trace: "on-first-retry",
8 | },
9 | webServer: {
10 | command: "yarn run start",
11 | port: 3000,
12 | timeout: 30 * 1000,
13 | reuseExistingServer: !process.env.CI,
14 | },
15 | reporter: [
16 | // replicating Playwright's defaults
17 | process.env.CI ? (["dot"] as const) : (["list"] as const),
18 | replayReporter({}),
19 | ],
20 | projects: [
21 | {
22 | name: "replay-chromium",
23 | use: {
24 | ...replayDevices["Replay Chromium"],
25 | },
26 | },
27 | ],
28 | });
29 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replayio/replay-cli/45ab2dad62ed3dfbc240e0d2cf680699baa241c2/examples/create-react-app-typescript/public/favicon.ico
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 | React App
25 |
26 |
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replayio/replay-cli/45ab2dad62ed3dfbc240e0d2cf680699baa241c2/examples/create-react-app-typescript/public/logo192.png
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replayio/replay-cli/45ab2dad62ed3dfbc240e0d2cf680699baa241c2/examples/create-react-app-typescript/public/logo512.png
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import logo from "./logo.svg";
3 | import "./App.css";
4 |
5 | function App() {
6 | return (
7 |
23 | );
24 | }
25 |
26 | export default App;
27 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
4 | "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
5 | -webkit-font-smoothing: antialiased;
6 | -moz-osx-font-smoothing: grayscale;
7 | }
8 |
9 | code {
10 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
11 | }
12 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import "./index.css";
4 | import App from "./App";
5 | import reportWebVitals from "./reportWebVitals";
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById("root")
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from "web-vitals";
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import "@testing-library/jest-dom";
6 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/test/example.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("basic test", async ({ page }) => {
4 | await page.goto("http://localhost:3000/");
5 | const title = page.locator(".App p");
6 | await expect(title).toHaveText("Edit src/App.tsx and save to reload.");
7 | });
8 |
--------------------------------------------------------------------------------
/examples/create-react-app-typescript/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"]
20 | }
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "replay-cli",
3 | "private": true,
4 | "packageManager": "yarn@4.1.1",
5 | "workspaces": [
6 | "packages/*",
7 | "packages/*/example",
8 | "scripts/*",
9 | "examples/*"
10 | ],
11 | "directories": {
12 | "example": "examples"
13 | },
14 | "scripts": {
15 | "build": "turbo run build --filter=\"./packages/*\"",
16 | "watch": "turbo watch build --filter=\"./packages/*\"",
17 | "test": "yarn run test:unit",
18 | "test:unit": "yarn workspaces foreach --all --exclude=. --exclude=examples/create-react-app-typescript --exclude=packages/cypress/example --topological run test",
19 | "typecheck": "turbo run typecheck --filter=\"./packages/*\" --filter=\"./scripts/*\"",
20 | "typecheck:watch": "turbo watch typecheck --filter=\"./packages/*\" --filter=\"./scripts/*\"",
21 | "lint": "prettier --check .",
22 | "changeset": "changeset",
23 | "release": "yarn workspaces foreach --no-private --all --topological npm publish --tolerate-republish --access public && changeset tag",
24 | "release:pr": "yarn workspaces foreach --no-private --all --topological npm publish --tolerate-republish --access public"
25 | },
26 | "author": "",
27 | "license": "ISC",
28 | "devDependencies": {
29 | "@changesets/changelog-github": "^0.5.0",
30 | "@changesets/cli": "^2.27.5",
31 | "@replayio/protocol": "^0.73.0",
32 | "@types/ws": "^8",
33 | "prettier": "^2.7.1",
34 | "turbo": "^2.0.5",
35 | "typescript": "^5.5.2",
36 | "ws": "^8.17.0"
37 | },
38 | "resolutions": {
39 | "@jest/types": "^27.5.1",
40 | "@types/jest": "^28.1.5",
41 | "@types/node": "^20.11.27",
42 | "@jest/environment@npm:^27.5.1": "patch:@jest/environment@npm%3A28.1.3#~/.yarn/patches/@jest-environment-npm-28.1.3-506a81a227.patch",
43 | "@jest/environment@npm:^28.1.3": "patch:@jest/environment@npm%3A28.1.3#~/.yarn/patches/@jest-environment-npm-28.1.3-506a81a227.patch",
44 | "superstruct": "patch:superstruct@npm%3A1.0.4#~/.yarn/patches/superstruct-npm-1.0.4-44d328b887.patch"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/cypress/.gitignore:
--------------------------------------------------------------------------------
1 | tests/fixtures
2 |
--------------------------------------------------------------------------------
/packages/cypress/README.md:
--------------------------------------------------------------------------------
1 | # `@replayio/cypress`
2 |
3 | Plugin to record your [Cypress](https://cypress.io) tests with [Replay](https://replay.io)
4 |
5 | **Check out the ["Recording Automated Tests Guide"](https://docs.replay.io/docs/recording-automated-tests-5bf7d91b65cd46deab1867b07bd12bdf) to get started.**
6 |
7 | Use with [action-cypress](https://github.com/replayio/action-cypress) to automatically upload replays of failed tests.
8 |
9 | ## Installation
10 |
11 | `npm i @replayio/cypress`
12 |
13 | ## Configuration
14 |
15 | The Replay adapter for cypress requires two updates: one to your `cypress.config.js` and one to your support file in `cypress/e2e/support.js`.
16 |
17 | ```js
18 | // cypress.config.js
19 | import { defineConfig } from "cypress";
20 | import cypressReplay, { wrapOn } from "@replayio/cypress";
21 |
22 | module.exports = defineConfig({
23 | e2e: {
24 | setupNodeEvents(cyOn, config) {
25 | const on = wrapOn(cyOn);
26 | // Adds replay-chromium browsers
27 | // and hooks into Cypress lifecycle methods to capture test
28 | // metadata and results
29 | cypressReplay(on, config);
30 | return config;
31 | },
32 | },
33 | });
34 | ```
35 |
36 | ```js
37 | // cypress/e2e/support.js
38 |
39 | import "@replayio/cypress/support";
40 | ```
41 |
42 | ## Runtime Configuration
43 |
44 | - Use the `--browser` flag to select the Replay Chromium to record
45 | - To enable capturing metadata for the tests, you must set `RECORD_REPLAY_METADATA_FILE` to an accessible file path.
46 | - To hide the Cypress sidebar and only show your application, set `CYPRESS_NO_COMMAND_LOG`.
47 |
48 | ```bash
49 | RECORD_REPLAY_METADATA_FILE=$(mktemp) \
50 | npx cypress run --browser replay-chromium
51 | ```
52 |
53 | ## Parallel runs on CI
54 |
55 | If you have a large test suite, you might choose to split your test suite up and run them in parallel across multiple machines but still treat them as a single suite. By default, `@replayio/cypress` will generate a UUID for the suite and store it in the recording metadata under `test.run.id` but in this case each machine will have its own id.
56 |
57 | In order to link these independently ran tests together, you can generate your own UUID and set it in the `RECORD_REPLAY_TEST_RUN_ID` environment variable and it will be used instead of generating a value.
58 |
--------------------------------------------------------------------------------
/packages/cypress/example/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | cypress/screenshots/
26 | cypress/videos/
27 |
--------------------------------------------------------------------------------
/packages/cypress/example/cypress.config.js:
--------------------------------------------------------------------------------
1 | const { defineConfig } = require("cypress");
2 | const { plugin: replayPlugin } = require("@replayio/cypress");
3 |
4 | module.exports = defineConfig({
5 | e2e: {
6 | setupNodeEvents(on, config) {
7 | replayPlugin(on, config, {
8 | upload: true,
9 | apiKey: process.env.REPLAY_API_KEY,
10 | });
11 | return config;
12 | },
13 | },
14 | });
15 |
--------------------------------------------------------------------------------
/packages/cypress/example/cypress/e2e/example.cy.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | it("basic test", () => {
4 | cy.visit("http://localhost:3000/");
5 |
6 | cy.get(".App p").contains("Edit src/App.tsx and save to reload.");
7 | });
8 |
--------------------------------------------------------------------------------
/packages/cypress/example/cypress/support/e2e.js:
--------------------------------------------------------------------------------
1 | import "@replayio/cypress/support";
2 |
--------------------------------------------------------------------------------
/packages/cypress/example/modules.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.css";
2 | declare module "*.svg";
3 |
--------------------------------------------------------------------------------
/packages/cypress/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replayio-cli/cypress-example",
3 | "private": true,
4 | "dependencies": {
5 | "cra-template-typescript": "1.2.0",
6 | "react": "^18.2.0",
7 | "react-dom": "^18.2.0",
8 | "react-scripts": "5.0.1",
9 | "web-vitals": "^3.5.2"
10 | },
11 | "devDependencies": {
12 | "@replayio/cypress": "workspace:^",
13 | "@types/react": "^18",
14 | "@types/react-dom": "^18",
15 | "cypress": "^13.11.0"
16 | },
17 | "scripts": {
18 | "start": "react-scripts start",
19 | "build": "react-scripts build",
20 | "test": "cypress run --browser replay-chromium",
21 | "eject": "react-scripts eject"
22 | },
23 | "browserslist": {
24 | "production": [
25 | ">0.2%",
26 | "not dead",
27 | "not op_mini all"
28 | ],
29 | "development": [
30 | "last 1 chrome version",
31 | "last 1 firefox version",
32 | "last 1 safari version"
33 | ]
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/cypress/example/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replayio/replay-cli/45ab2dad62ed3dfbc240e0d2cf680699baa241c2/packages/cypress/example/public/favicon.ico
--------------------------------------------------------------------------------
/packages/cypress/example/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 | React App
25 |
26 |
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/packages/cypress/example/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replayio/replay-cli/45ab2dad62ed3dfbc240e0d2cf680699baa241c2/packages/cypress/example/public/logo192.png
--------------------------------------------------------------------------------
/packages/cypress/example/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replayio/replay-cli/45ab2dad62ed3dfbc240e0d2cf680699baa241c2/packages/cypress/example/public/logo512.png
--------------------------------------------------------------------------------
/packages/cypress/example/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/packages/cypress/example/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/packages/cypress/example/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/packages/cypress/example/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import logo from "./logo.svg";
3 | import "./App.css";
4 |
5 | function App() {
6 | return (
7 |
23 | );
24 | }
25 |
26 | export default App;
27 |
--------------------------------------------------------------------------------
/packages/cypress/example/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
4 | "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
5 | -webkit-font-smoothing: antialiased;
6 | -moz-osx-font-smoothing: grayscale;
7 | }
8 |
9 | code {
10 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
11 | }
12 |
--------------------------------------------------------------------------------
/packages/cypress/example/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import "./index.css";
4 | import App from "./App";
5 | import reportWebVitals from "./reportWebVitals";
6 |
7 | const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
8 | root.render(
9 |
10 |
11 |
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/packages/cypress/example/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from "web-vitals";
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/packages/cypress/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "noEmit": true,
5 | "module": "ESNext",
6 | "moduleResolution": "Bundler",
7 | "esModuleInterop": true,
8 | "target": "esnext",
9 | "lib": ["DOM", "ESNext"],
10 | "jsx": "react-jsx",
11 | "types": ["cypress"]
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/cypress/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replayio/cypress",
3 | "version": "3.1.3",
4 | "description": "Plugin to record your Cypress tests with Replay",
5 | "main": "./dist/index.js",
6 | "exports": {
7 | ".": "./dist/index.js",
8 | "./support": "./dist/support.js",
9 | "./package.json": "./package.json"
10 | },
11 | "engines": {
12 | "node": ">=18"
13 | },
14 | "files": [
15 | "dist",
16 | "*.js",
17 | "*.d.ts"
18 | ],
19 | "scripts": {
20 | "prepare": "npm run build",
21 | "build": "pkg-build",
22 | "test": "echo \"Error: no test specified\"",
23 | "typecheck": "tsc --noEmit"
24 | },
25 | "repository": {
26 | "type": "git",
27 | "url": "git+https://github.com/replayio/replay-cli.git"
28 | },
29 | "author": "",
30 | "license": "BSD-3-Clause",
31 | "bugs": {
32 | "url": "https://github.com/replayio/replay-cli/issues"
33 | },
34 | "homepage": "https://github.com/replayio/replay-cli/blob/main/packages/cypress/README.md",
35 | "devDependencies": {
36 | "@replay-cli/pkg-build": "workspace:^",
37 | "@replay-cli/shared": "workspace:^",
38 | "@replay-cli/tsconfig": "workspace:^",
39 | "@replayio/test-utils": "workspace:^",
40 | "@types/debug": "^4.1.7",
41 | "@types/node": "^20.11.27",
42 | "@types/semver": "^7.3.13",
43 | "@types/stack-utils": "^2.0.3",
44 | "@types/uuid": "^9.0.1",
45 | "@types/ws": "^8.5.10",
46 | "cypress": "^13.11.0",
47 | "turbo": "^2.0.5",
48 | "typescript": "^5.5.2"
49 | },
50 | "dependencies": {
51 | "chalk": "^4.1.2",
52 | "debug": "^4.3.4",
53 | "fs-extra": "^11.2.0",
54 | "is-uuid": "^1.0.2",
55 | "jsonata": "^1.8.6",
56 | "launchdarkly-node-client-sdk": "^3.2.1",
57 | "mixpanel": "^0.18.0",
58 | "node-fetch": "^2.6.7",
59 | "p-map": "^4.0.0",
60 | "semver": "^7.5.2",
61 | "sha-1": "^1.0.0",
62 | "stack-utils": "^2.0.6",
63 | "superstruct": "^1.0.4",
64 | "terminate": "^2.6.1",
65 | "txml": "^3.2.5",
66 | "undici": "^5.28.4",
67 | "uuid": "^8.3.2",
68 | "winston": "^3.13.0",
69 | "winston-loki": "^6.1.2",
70 | "ws": "^8.14.2"
71 | },
72 | "peerDependencies": {
73 | "cypress": "^13"
74 | },
75 | "@replay-cli/pkg-build": {
76 | "entrypoints": [
77 | "./src/index.ts",
78 | "./src/support.ts"
79 | ]
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/packages/cypress/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const CONNECT_TASK_NAME = "replay-connect";
2 | export const AFTER_EACH_HOOK = `"after each" hook`;
3 |
--------------------------------------------------------------------------------
/packages/cypress/src/error.ts:
--------------------------------------------------------------------------------
1 | import { TestMetadataV2 } from "@replayio/test-utils";
2 | import type { StepEvent } from "./support";
3 |
4 | type Test = TestMetadataV2.Test;
5 |
6 | export enum Errors {
7 | UnexpectedError = -1,
8 | NoTestResults = 101,
9 | MismatchedStep = 201,
10 | TestMissing = 202,
11 | }
12 |
13 | export class StepAssertionError extends Error {
14 | name = "StepAssertionError";
15 | message: string;
16 | step: StepEvent;
17 | code: number;
18 |
19 | constructor(step: StepEvent, code: number, message: string) {
20 | super();
21 | this.step = step;
22 | this.code = code;
23 | this.message = message;
24 | }
25 | }
26 |
27 | export function isStepAssertionError(e: any): e is StepAssertionError {
28 | return e instanceof Error && e.name === "StepAssertionError";
29 | }
30 |
31 | export function assertCurrentTest(
32 | currentTest: Test | undefined,
33 | step: StepEvent
34 | ): asserts currentTest is Test {
35 | if (!currentTest) {
36 | throw new StepAssertionError(step, Errors.TestMissing, "currentTest does not exist");
37 | }
38 | }
39 |
40 | export function assertMatchingStep(
41 | currentStep: StepEvent,
42 | previousStep: StepEvent | undefined
43 | ): asserts previousStep is StepEvent {
44 | if (
45 | !currentStep ||
46 | !previousStep ||
47 | !currentStep.command ||
48 | !previousStep.command ||
49 | currentStep.command.id !== previousStep.command.id
50 | ) {
51 | throw new StepAssertionError(currentStep, Errors.MismatchedStep, "Mismatched step event");
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/packages/cypress/src/features.ts:
--------------------------------------------------------------------------------
1 | export enum PluginFeature {
2 | Plugin = "plugin",
3 | Support = "support",
4 | Metrics = "metrics",
5 | }
6 |
7 | type PluginFeatureOption = PluginFeature | `no-${PluginFeature}` | "none" | "all";
8 |
9 | function isValidFeatureOption(feature: string): feature is PluginFeatureOption {
10 | if (["all", "none"].includes(feature)) {
11 | return true;
12 | }
13 |
14 | if (feature.startsWith("no-")) {
15 | feature = feature.substring(3);
16 | }
17 |
18 | return Object.values(PluginFeature).includes(feature as PluginFeature);
19 | }
20 |
21 | function parsePluginFeatureOptions(options: string) {
22 | return options
23 | .toLowerCase()
24 | .split(",")
25 | .map(s => s.trim())
26 | .filter(s => isValidFeatureOption(s)) as PluginFeatureOption[];
27 | }
28 |
29 | export function getFeatures(options: string | undefined) {
30 | const allFeatures = Object.values(PluginFeature);
31 |
32 | if (options) {
33 | return parsePluginFeatureOptions(options).reduce((acc, feature) => {
34 | if (feature === "all") {
35 | return allFeatures;
36 | } else if (feature === "none") {
37 | return [];
38 | } else if (feature.startsWith("no-")) {
39 | feature = feature.substring(3) as PluginFeatureOption;
40 |
41 | if (acc.includes(feature as PluginFeature)) {
42 | return acc.filter(f => f !== feature);
43 | }
44 | } else if (!acc.includes(feature as any)) {
45 | acc.push(feature as PluginFeature);
46 | }
47 |
48 | return acc;
49 | }, []);
50 | }
51 |
52 | return allFeatures;
53 | }
54 |
55 | export function isFeatureEnabled(options: string | undefined, feature: PluginFeature) {
56 | const features = getFeatures(options);
57 |
58 | return features.includes(feature);
59 | }
60 |
--------------------------------------------------------------------------------
/packages/cypress/src/fixture.ts:
--------------------------------------------------------------------------------
1 | import { logInfo } from "@replay-cli/shared/logger";
2 | import { writeFileSync, appendFileSync, mkdirSync } from "fs";
3 | import path from "path";
4 |
5 | function getFixtureFile() {
6 | return (
7 | process.env.REPLAY_CYPRESS_FIXTURE_FILE ||
8 | // TODO [ryanjduffy] - This assumes we're running in dist directory and
9 | // walks back up to put the fixture.log near driver.ts. This would be a
10 | // weird default if we asked users to run this so this logic should be
11 | // smarter.
12 | path.resolve(__filename, "../../tests/fixtures/fixture.log")
13 | );
14 | }
15 |
16 | export function initFixtureFile() {
17 | logInfo("InitFixtureFile:Started", {
18 | updateFixture: process.env.REPLAY_CYPRESS_UPDATE_FIXTURE,
19 | });
20 | if (process.env.REPLAY_CYPRESS_UPDATE_FIXTURE) {
21 | logInfo("InitFixtureFile:FixtureFile", { fixtureFile: getFixtureFile() });
22 | try {
23 | mkdirSync(path.dirname(getFixtureFile()), { recursive: true });
24 | writeFileSync(getFixtureFile(), "");
25 | } catch (e) {
26 | console.error(e);
27 | process.env.REPLAY_CYPRESS_UPDATE_FIXTURE = undefined;
28 | }
29 | }
30 | }
31 |
32 | export function appendToFixtureFile(type: string, value: any) {
33 | if (process.env.REPLAY_CYPRESS_UPDATE_FIXTURE) {
34 | try {
35 | appendFileSync(getFixtureFile(), JSON.stringify({ type, value }) + "\n");
36 | } catch (e) {
37 | console.error(e);
38 | process.env.REPLAY_CYPRESS_UPDATE_FIXTURE = undefined;
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/cypress/src/server.ts:
--------------------------------------------------------------------------------
1 | import { logInfo } from "@replay-cli/shared/logger";
2 | import http from "http";
3 | import { AddressInfo } from "net";
4 | import { WebSocketServer } from "ws";
5 |
6 | export async function createServer() {
7 | logInfo("CreateServer:Started");
8 |
9 | const server = http.createServer();
10 | const wss = new WebSocketServer({ noServer: true });
11 |
12 | server.on("upgrade", function upgrade(request, socket, head) {
13 | logInfo("CreateServer:UpgradeRequest");
14 | wss.handleUpgrade(request, socket, head, function done(ws) {
15 | logInfo("CreateServer:Upgraded");
16 | wss.emit("connection", ws, request);
17 | });
18 | });
19 |
20 | return new Promise<{ server: WebSocketServer; port: number }>(resolve => {
21 | const config = {
22 | // Pick any available port unless set by user
23 | port: process.env.CYPRESS_REPLAY_SOCKET_PORT
24 | ? Number.parseInt(process.env.CYPRESS_REPLAY_SOCKET_PORT)
25 | : 0,
26 | // Explicitly use ipv4 unless set by user
27 | host: process.env.CYPRESS_REPLAY_SOCKET_HOST || "0.0.0.0",
28 | };
29 |
30 | logInfo("CreateServer:ServerConfig", { config });
31 |
32 | server.listen(config, () => {
33 | const { address, port } = server.address() as AddressInfo;
34 | logInfo("CreateServer:Listening", { address, port });
35 | resolve({ server: wss, port });
36 | });
37 | });
38 | }
39 |
--------------------------------------------------------------------------------
/packages/cypress/support.d.ts:
--------------------------------------------------------------------------------
1 | import "./dist/support.js";
2 |
--------------------------------------------------------------------------------
/packages/cypress/support.js:
--------------------------------------------------------------------------------
1 | require("./dist/support.js");
2 |
--------------------------------------------------------------------------------
/packages/cypress/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | "target": "es2022",
5 | "lib": ["dom", "es2023"],
6 | "resolveJsonModule": true
7 | },
8 | "include": ["src/**/*.ts"],
9 | "references": [
10 | {
11 | "path": "../test-utils"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/packages/jest/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
--------------------------------------------------------------------------------
/packages/jest/README.md:
--------------------------------------------------------------------------------
1 | # `@replayio/jest`
2 |
3 | Provides utilities to support using [Replay](https://replay.io) with [Jest](https://jestjs.io/)
4 |
--------------------------------------------------------------------------------
/packages/jest/bin.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | "use strict";
3 |
4 | require("./dist/bin.js");
5 |
--------------------------------------------------------------------------------
/packages/jest/runner.d.ts:
--------------------------------------------------------------------------------
1 | export { default } from "./dist/runner.js";
2 |
--------------------------------------------------------------------------------
/packages/jest/runner.js:
--------------------------------------------------------------------------------
1 | Object.defineProperty(exports, "__esModule", { value: true });
2 | exports.default = require("./dist/runner.js").default;
3 |
--------------------------------------------------------------------------------
/packages/jest/src/bin.ts:
--------------------------------------------------------------------------------
1 | import { spawn, spawnSync } from "child_process";
2 | import path from "path";
3 | import { cwd } from "process";
4 |
5 | import { waitForProcessExit, findExecutablePath } from "./utils";
6 |
7 | main();
8 |
9 | async function main() {
10 | function logMessage(prefix: string, msg: string) {
11 | console.log(`replay-jest${prefix ? " " + prefix : ""}: ${msg}`);
12 | }
13 |
14 | function logFailure(why: any) {
15 | logMessage("failed", why);
16 | }
17 |
18 | // Make sure the replay version of node is installed and updated.
19 | const replayNodePath = findExecutablePath("replay-node");
20 | if (!replayNodePath) {
21 | logFailure(`replay-node not available, try "npm i @replayio/node -g"`);
22 | return;
23 | }
24 | logMessage("", "Updating replay-node ...");
25 | spawnSync(replayNodePath, ["--update"]);
26 |
27 | // Directory where replay-node will install the node binary.
28 | const baseReplayDirectory =
29 | process.env.RECORD_REPLAY_DIRECTORY || path.join(process.env.HOME!, ".replay");
30 | const replayNodeBinaryPath = path.join(baseReplayDirectory, "node", "node");
31 |
32 | const jestPath = findJestPath();
33 | if (!jestPath) {
34 | logFailure(`Could not find jest path`);
35 | return;
36 | }
37 |
38 | const replayProcess = spawn(replayNodeBinaryPath, [jestPath, ...process.argv.slice(2)], {
39 | stdio: "inherit",
40 | });
41 |
42 | const { code, signal } = await waitForProcessExit(replayProcess);
43 |
44 | process.exit(code || (signal ? 1 : 0));
45 | }
46 |
47 | function findJestPath() {
48 | try {
49 | return require.resolve("jest/bin/jest", {
50 | paths: [cwd()],
51 | });
52 | } catch (e) {
53 | console.error(e);
54 | }
55 | return findExecutablePath("jest");
56 | }
57 |
--------------------------------------------------------------------------------
/packages/jest/src/index.ts:
--------------------------------------------------------------------------------
1 | import ReplayRunner, { getMetadataFilePath } from "./runner";
2 |
3 | export { ReplayRunner, getMetadataFilePath };
4 |
--------------------------------------------------------------------------------
/packages/jest/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { ChildProcess, spawnSync } from "child_process";
2 |
3 | function defer(): {
4 | promise: Promise;
5 | resolve: undefined | ((value: T) => void);
6 | reject: undefined | ((reason?: any) => void);
7 | } {
8 | let resolve = undefined;
9 | let reject = undefined;
10 | const promise = new Promise((res, rej) => {
11 | resolve = res;
12 | reject = rej;
13 | });
14 | return { promise, resolve, reject };
15 | }
16 |
17 | function waitForProcessExit(childProcess: ChildProcess) {
18 | const exitWaiter = defer<{ code: number | null; signal: NodeJS.Signals | null }>();
19 | childProcess.on(
20 | "exit",
21 | (code, signal) => exitWaiter.resolve && exitWaiter.resolve({ code, signal })
22 | );
23 | return exitWaiter.promise;
24 | }
25 |
26 | function findExecutablePath(executable: string) {
27 | const { stdout } = spawnSync("which", [executable], { stdio: "pipe" });
28 | const path = stdout.toString().trim();
29 | return path.length ? path : null;
30 | }
31 |
32 | export { waitForProcessExit, findExecutablePath };
33 |
--------------------------------------------------------------------------------
/packages/jest/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | "resolveJsonModule": true
5 | },
6 | "include": ["src/**/*.ts"],
7 | "references": [
8 | {
9 | "path": "../test-utils"
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/packages/node/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2022, Record Replay Inc.
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/packages/node/README.md:
--------------------------------------------------------------------------------
1 | # @replayio/node
2 |
3 | CLI tool for creating recordings using the [Replay](https://replay.io) version of node.
4 |
5 | ## Overview
6 |
7 | The replay version of node is a replacement for the `node` executable which saves a recording of its behavior to disk that can be uploaded to the record/replay web service for viewing. `replay-node` is a CLI tool that allows the replay version of node to be selectively used when running node scripts.
8 |
9 | ## Installation
10 |
11 | `npm i @replayio/node --global`
12 |
13 | ## Usage
14 |
15 | `replay-node` can be used in the following ways to create recordings. Afterwards, use the [@replayio/replay](https://www.npmjs.com/package/@replayio/replay) CLI tool to manage and upload the recordings.
16 |
17 | `replay-node script.js ...args`
18 |
19 | Use the replay version of node to record a specific script and arguments.
20 |
21 | `replay-node --exec executable ...args`
22 |
23 | Run an executable command with `$PATH` updated so that all node scripts will use the Replay version of node to record their behavior.
24 |
25 | `replay-node --update`
26 |
27 | Ensure the replay version of node is downloaded/updated.
28 |
29 | ### Supported environment variables:
30 |
31 | - RECORD_REPLAY_DIRECTORY (defaults to $HOME/.replay)
32 | - RECORD_REPLAY_NODE_DIRECTORY (defaults to $RECORD_REPLAY_DIRECTORY/node)
33 | Allows to specify a folder in which the replay-patched `node` binary is to be found.
34 | - RECORD_REPLAY_DRIVER
35 | Allows you to specify the path to the `recordreplay.so` driver library.
36 |
--------------------------------------------------------------------------------
/packages/node/bin/replay-node:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | require("../src/bin");
4 |
--------------------------------------------------------------------------------
/packages/node/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replayio/node",
3 | "version": "0.3.0",
4 | "description": "CLI tool for creating recordings using the replay version of node",
5 | "bin": {
6 | "replay-node": "bin/replay-node"
7 | },
8 | "scripts": {
9 | "test": "echo \"Error: no test specified\""
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/replayio/replay-cli.git"
14 | },
15 | "author": "",
16 | "license": "BSD-3-Clause",
17 | "bugs": {
18 | "url": "https://github.com/replayio/replay-cli/issues"
19 | },
20 | "homepage": "https://github.com/replayio/replay-cli/blob/main/packages/node/README.md"
21 | }
22 |
--------------------------------------------------------------------------------
/packages/playwright/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
--------------------------------------------------------------------------------
/packages/playwright/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replayio/playwright",
3 | "version": "4.0.0",
4 | "description": "Configuration utilities for using the Replay browsers with playwright",
5 | "main": "./dist/index.js",
6 | "exports": {
7 | ".": "./dist/index.js",
8 | "./reporter": "./dist/reporter.js",
9 | "./package.json": "./package.json"
10 | },
11 | "files": [
12 | "dist",
13 | "src",
14 | "*.js",
15 | "*.d.ts"
16 | ],
17 | "scripts": {
18 | "prepare": "yarn run build",
19 | "build": "pkg-build",
20 | "test": "echo \"Error: no test specified\"",
21 | "typecheck": "tsc --noEmit"
22 | },
23 | "devDependencies": {
24 | "@playwright/test": "^1.52.0",
25 | "@replay-cli/pkg-build": "workspace:^",
26 | "@replay-cli/shared": "workspace:^",
27 | "@replay-cli/tsconfig": "workspace:^",
28 | "@replayio/test-utils": "workspace:^",
29 | "@types/debug": "^4.1.8",
30 | "@types/node": "^20.11.27",
31 | "@types/stack-utils": "^2.0.3",
32 | "@types/uuid": "^8.3.4",
33 | "@types/ws": "^8.5.10",
34 | "turbo": "^2.0.5",
35 | "typescript": "^5.5.2"
36 | },
37 | "repository": {
38 | "type": "git",
39 | "url": "git+https://github.com/replayio/replay-cli.git"
40 | },
41 | "author": "",
42 | "license": "BSD-3-Clause",
43 | "bugs": {
44 | "url": "https://github.com/replayio/replay-cli/issues"
45 | },
46 | "homepage": "https://github.com/replayio/replay-cli/blob/main/packages/playwright/README.md",
47 | "dependencies": {
48 | "chalk": "^4.1.2",
49 | "debug": "^4.3.4",
50 | "fs-extra": "^11.2.0",
51 | "is-uuid": "^1.0.2",
52 | "jsonata": "^1.8.6",
53 | "launchdarkly-node-client-sdk": "^3.2.1",
54 | "mixpanel": "^0.18.0",
55 | "node-fetch": "^2.6.7",
56 | "p-map": "^4.0.0",
57 | "sha-1": "^1.0.0",
58 | "stack-utils": "^2.0.6",
59 | "superstruct": "^1.0.4",
60 | "undici": "^5.28.4",
61 | "uuid": "^8.3.2",
62 | "winston": "^3.13.0",
63 | "winston-loki": "^6.1.2",
64 | "ws": "^8.13.0"
65 | },
66 | "peerDependencies": {
67 | "@playwright/test": "^1.52.0"
68 | },
69 | "@replay-cli/pkg-build": {
70 | "entrypoints": [
71 | "./src/index.ts",
72 | "./src/reporter.ts"
73 | ]
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/packages/playwright/reporter.d.ts:
--------------------------------------------------------------------------------
1 | export { default } from "./dist/reporter.js";
2 |
--------------------------------------------------------------------------------
/packages/playwright/reporter.js:
--------------------------------------------------------------------------------
1 | Object.defineProperty(exports, "__esModule", { value: true });
2 | exports.default = require("./dist/reporter.js").default;
3 |
--------------------------------------------------------------------------------
/packages/playwright/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const REPLAY_CONTENT_TYPE = "application/vnd.replay.message+json";
2 |
--------------------------------------------------------------------------------
/packages/playwright/src/error.ts:
--------------------------------------------------------------------------------
1 | export enum Errors {
2 | UnexpectedError = -1,
3 | MissingCurrentStep = 201,
4 | }
5 |
--------------------------------------------------------------------------------
/packages/playwright/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | "target": "ES2017",
5 | "resolveJsonModule": true
6 | },
7 | "include": ["src/**/*.ts"],
8 | "references": [
9 | {
10 | "path": "../test-utils"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/packages/puppeteer/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
--------------------------------------------------------------------------------
/packages/puppeteer/README.md:
--------------------------------------------------------------------------------
1 | # @replayio/puppeteer
2 |
3 | Provides utilities to support using [Replay](https://replay.io) with [Puppeteer](https://pptr.dev)
4 |
5 | Use with [replayio/action-upload](https://github.com/Replayio/action-upload) to automatically upload replays of puppeteer scripts. [Check out our documentation here.](https://docs.replay.io/docs/recording-puppeteer-5525cfad405e41a18b940af3d09d68be#5525cfad405e41a18b940af3d09d68be)
6 |
7 | Exports
8 |
9 | - `getExecutablePath()` - Returns the path to the replay chromium browser.
10 | - `getMetadataFilePath(workerIndex: number = 0)` - Returns the path of a worker-specific metadata file keyed by the `workerIndex`. The file path will be within the `RECORD_REPLAY_DIRECTORY`.
11 |
12 | ### Metadata
13 |
14 | You can add metadata to your puppeteer recordings using either the `RECORD_REPLAY_METADATA` or `RECORD_REPLAY_METADATA_FILE` environment variable. If both are set, `RECORD_REPLAY_METADATA_FILE` takes precedence.
15 |
16 | > Currently, this metadata is only available locally except for `title`
17 |
18 | - `RECORD_REPLAY_METADATA_FILE` - The path to a file containing JSON-formatted metadata
19 | - `RECORD_REPLAY_METADATA` - JSON-formatted metadata string
20 |
21 | ```js
22 | const puppeteer = require("puppeteer");
23 | const { getExecutablePath } = require("@replayio/puppeteer");
24 |
25 | (async () => {
26 | const browser = await puppeteer.launch({
27 | headless: false,
28 | executablePath: getExecutablePath(),
29 | });
30 | const page = await browser.newPage();
31 | await page.goto("https://replay.io");
32 | await page.screenshot({ path: "replay.png" });
33 |
34 | await page.close();
35 | await browser.close();
36 | })();
37 | ```
38 |
--------------------------------------------------------------------------------
/packages/puppeteer/bin.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | "use strict";
3 |
4 | require("./dist/bin.js");
5 |
--------------------------------------------------------------------------------
/packages/puppeteer/first-run.js:
--------------------------------------------------------------------------------
1 | const { existsSync } = require("fs");
2 | if (existsSync("dist")) {
3 | require("./dist/first-run.js");
4 | }
5 |
--------------------------------------------------------------------------------
/packages/puppeteer/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replayio/puppeteer",
3 | "version": "0.2.12",
4 | "description": "Configuration utilities for using the Replay browsers with puppeteer",
5 | "main": "./dist/index.js",
6 | "bin": {
7 | "replayio-puppeteer": "./bin.js"
8 | },
9 | "files": [
10 | "dist",
11 | "*.js",
12 | "*.d.ts"
13 | ],
14 | "scripts": {
15 | "postinstall": "node ./first-run.js",
16 | "prepare": "yarn run build",
17 | "build": "pkg-build",
18 | "test": "echo \"Error: no test specified\"",
19 | "typecheck": "tsc --noEmit"
20 | },
21 | "devDependencies": {
22 | "@replay-cli/pkg-build": "workspace:^",
23 | "@replay-cli/shared": "workspace:^",
24 | "@replay-cli/tsconfig": "workspace:^",
25 | "@replayio/test-utils": "workspace:^",
26 | "@types/node": "^20.11.27",
27 | "@types/stack-utils": "^2.0.3",
28 | "turbo": "^2.0.5",
29 | "typescript": "^5.5.2"
30 | },
31 | "repository": {
32 | "type": "git",
33 | "url": "git+https://github.com/replayio/replay-cli.git"
34 | },
35 | "author": "",
36 | "license": "BSD-3-Clause",
37 | "bugs": {
38 | "url": "https://github.com/replayio/replay-cli/issues"
39 | },
40 | "homepage": "https://github.com/replayio/replay-cli/blob/main/packages/puppeteer/README.md",
41 | "dependencies": {
42 | "chalk": "^4.1.2",
43 | "debug": "^4.3.4",
44 | "fs-extra": "^11.2.0",
45 | "is-uuid": "^1.0.2",
46 | "jsonata": "^1.8.6",
47 | "launchdarkly-node-client-sdk": "^3.2.1",
48 | "mixpanel": "^0.18.0",
49 | "node-fetch": "^2.6.7",
50 | "p-map": "^4.0.0",
51 | "sha-1": "^1.0.0",
52 | "stack-utils": "^2.0.6",
53 | "superstruct": "^1.0.4",
54 | "undici": "^5.28.4",
55 | "uuid": "^8.3.2",
56 | "winston": "^3.13.0",
57 | "winston-loki": "^6.1.2",
58 | "ws": "^8.14.2"
59 | },
60 | "@replay-cli/pkg-build": {
61 | "entrypoints": [
62 | "./src/bin.ts",
63 | "./src/first-run.ts",
64 | "./src/index.ts"
65 | ]
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/packages/puppeteer/src/bin.ts:
--------------------------------------------------------------------------------
1 | import install from "./install";
2 |
3 | let [, , cmd] = process.argv;
4 |
5 | function commandInstall() {
6 | console.log("Installing Replay browsers for puppeteer");
7 |
8 | install().then(() => {
9 | console.log("Done");
10 | });
11 | }
12 |
13 | function help() {
14 | console.log(`
15 | npx @replayio/puppeteer
16 |
17 | Provides utilities to support using Replay (https://replay.io) with Puppeteer
18 |
19 | Available commands:
20 |
21 | - install
22 | Installs the Replay Chromium browser
23 | `);
24 | }
25 |
26 | switch (cmd) {
27 | case "install":
28 | commandInstall();
29 | break;
30 | case "help":
31 | default:
32 | help();
33 | break;
34 | }
35 |
--------------------------------------------------------------------------------
/packages/puppeteer/src/first-run.ts:
--------------------------------------------------------------------------------
1 | import install from "./install";
2 |
3 | if (!process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD) {
4 | console.log("Installing Replay browsers for puppeteer");
5 |
6 | install().then(
7 | () => {
8 | console.log("Done");
9 | },
10 | error => {
11 | console.error(error);
12 | }
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/packages/puppeteer/src/index.ts:
--------------------------------------------------------------------------------
1 | import { getRuntimePath } from "@replay-cli/shared/runtime/getRuntimePath";
2 | import {
3 | getMetadataFilePath as getMetadataFilePathBase,
4 | initMetadataFile,
5 | } from "@replayio/test-utils";
6 |
7 | function getDeviceConfig() {
8 | const executablePath = getExecutablePath();
9 |
10 | const env: Record = {
11 | ...process.env,
12 | RECORD_ALL_CONTENT: 1,
13 | RECORD_REPLAY_METADATA_FILE: initMetadataFile(getMetadataFilePath()),
14 | };
15 |
16 | if (process.env.RECORD_REPLAY_NO_RECORD) {
17 | env.RECORD_ALL_CONTENT = "";
18 | // Setting an invalid path for chromium will disable recording
19 | env.RECORD_REPLAY_DRIVER = __filename;
20 | }
21 |
22 | return {
23 | launchOptions: {
24 | executablePath,
25 | env,
26 | },
27 | defaultBrowserType: "chromium",
28 | };
29 | }
30 |
31 | export function getMetadataFilePath(workerIndex = 0) {
32 | return getMetadataFilePathBase("PUPPETEER", workerIndex);
33 | }
34 |
35 | export function getExecutablePath() {
36 | return getRuntimePath();
37 | }
38 |
39 | export const devices = {
40 | get "Replay Chromium"() {
41 | return getDeviceConfig();
42 | },
43 | };
44 |
--------------------------------------------------------------------------------
/packages/puppeteer/src/install.ts:
--------------------------------------------------------------------------------
1 | import { logError } from "@replay-cli/shared/logger";
2 | import { waitForExitTasks } from "@replay-cli/shared/process/waitForExitTasks";
3 | import { installLatestRuntimeRelease } from "@replay-cli/shared/runtime/installLatestRuntimeRelease";
4 | import { initializeSession } from "@replay-cli/shared/session/initializeSession";
5 | import { getAccessToken } from "@replayio/test-utils";
6 | import { name as packageName, version as packageVersion } from "../package.json";
7 |
8 | export default async function install() {
9 | try {
10 | await initializeSession({
11 | accessToken: getAccessToken(),
12 | packageName,
13 | packageVersion,
14 | });
15 | } catch (error) {
16 | logError("Failed to identify for logger", { error });
17 | }
18 |
19 | try {
20 | await installLatestRuntimeRelease();
21 | } finally {
22 | await waitForExitTasks();
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/puppeteer/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | "lib": ["dom"],
5 | "resolveJsonModule": true
6 | },
7 | "include": ["src/**/*.ts"],
8 | "references": [
9 | {
10 | "path": "../test-utils"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/packages/replayio/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier"],
3 | "rules": {
4 | "prettier/prettier": "error"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/packages/replayio/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | **/dist/
3 | **/lib/index.d.ts
4 | **/lib/index.js
5 | package-lock.json
6 | e2e-repos
7 |
--------------------------------------------------------------------------------
/packages/replayio/Contributing.md:
--------------------------------------------------------------------------------
1 | This project uses the [Yarn package manager](https://yarnpkg.com/). To install project dependencies:
2 |
3 | ```bash
4 | # Workspace root
5 | yarn
6 | yarn run build
7 | ```
8 |
9 | To build the CLI:
10 |
11 | ```bash
12 | # packages/replayio
13 | yarn build
14 | ```
15 |
16 | To test your changes locally:
17 |
18 | ```bash
19 | # packages/replayio
20 | ./bin.js
21 | ```
22 |
23 | Before submitting a pull request, make sure you've checked types, formatting, and tests:
24 |
25 | ```bash
26 | yarn typecheck
27 | yarn lint
28 | yarn test
29 | ```
30 |
--------------------------------------------------------------------------------
/packages/replayio/README.md:
--------------------------------------------------------------------------------
1 | # replayio
2 |
3 | CLI tool for creating and uploading [Replay](https://replay.io) recordings.
4 |
5 | ## Installation
6 |
7 | ```bash
8 | npm install --global replayio
9 | ```
10 |
11 | ## Usage
12 |
13 | To see all available commands, run:
14 |
15 | ```bash
16 | replayio
17 | ```
18 |
19 | For help on a specific command, use the `help` command:
20 |
21 | ```bash
22 | replayio help list
23 | ```
24 |
25 | This CLI will automatically prompt you to log into your Replay account (or to register one). You can use an `REPLAY_API_KEY` environment variable for authentication instead if you prefer.
26 |
27 | The CLI will also prompt you to download the Replay runtime if you have not already done so.
28 |
29 | ## Contributing
30 |
31 | Contributing guide can be found [here](contributing.md).
32 |
--------------------------------------------------------------------------------
/packages/replayio/bin.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | "use strict";
4 |
5 | require("./dist/bin.js");
6 |
--------------------------------------------------------------------------------
/packages/replayio/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: "ts-jest",
4 | testEnvironment: "node",
5 | testMatch: ["**/(*.)+(spec|test).[jt]s?(x)"],
6 | moduleNameMapper: {
7 | uuid: require.resolve("uuid"),
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/packages/replayio/src/bin.ts:
--------------------------------------------------------------------------------
1 | import { logError } from "@replay-cli/shared/logger";
2 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
3 | import { finalizeCommander } from "./utils/commander/finalizeCommander";
4 |
5 | // Commands self-register with "commander"
6 | import "./commands/info";
7 | import "./commands/list";
8 | import "./commands/login";
9 | import "./commands/logout";
10 | import "./commands/open";
11 | import "./commands/record";
12 | import "./commands/remove";
13 | import "./commands/update";
14 | import "./commands/upload";
15 | import "./commands/upload-source-maps";
16 | import "./commands/whoami";
17 |
18 | finalizeCommander();
19 |
20 | // If the process is terminated by CTRL+C while waiting for an async function
21 | // avoid ERR_UNHANDLED_REJECTION from being printed to the console
22 | process.on("uncaughtException", async error => {
23 | if (error.name !== "UnhandledPromiseRejection") {
24 | logError("UncaughtException", { error });
25 | console.error(error);
26 | }
27 |
28 | await exitProcess(1);
29 | });
30 |
--------------------------------------------------------------------------------
/packages/replayio/src/commands/info.ts:
--------------------------------------------------------------------------------
1 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
2 | import { parseBuildId } from "@replay-cli/shared/runtime/parseBuildId";
3 | import { highlight } from "@replay-cli/shared/theme";
4 | import { name as packageName, version as packageVersion } from "../../package.json";
5 | import { registerCommand } from "../utils/commander/registerCommand";
6 | import { getCurrentRuntimeMetadata } from "../utils/initialization/getCurrentRuntimeMetadata";
7 |
8 | registerCommand("info", {
9 | checkForNpmUpdate: false,
10 | checkForRuntimeUpdate: false,
11 | requireAuthentication: false,
12 | })
13 | .description("Display info for installed Replay dependencies")
14 | .action(info);
15 |
16 | async function info() {
17 | console.log(`Currently using ${highlight(`${packageName}@${packageVersion}`)}`);
18 |
19 | const metadata = getCurrentRuntimeMetadata("chromium");
20 | if (metadata) {
21 | const { buildId, forkedVersion } = metadata;
22 |
23 | const { releaseDate } = parseBuildId(buildId);
24 |
25 | console.log("\nReplay Chromium");
26 | console.log(`• Release date: ${highlight(releaseDate.toLocaleDateString())}`);
27 | if (forkedVersion) {
28 | console.log(`• Forked version: ${highlight(forkedVersion)}`);
29 | }
30 | console.log(`• Build id: ${highlight(buildId)}`);
31 | }
32 |
33 | await exitProcess(0);
34 | }
35 |
--------------------------------------------------------------------------------
/packages/replayio/src/commands/list.ts:
--------------------------------------------------------------------------------
1 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
2 | import { getRecordings } from "@replay-cli/shared/recording/getRecordings";
3 | import { printRecordings } from "@replay-cli/shared/recording/printRecordings";
4 | import { registerCommand } from "../utils/commander/registerCommand";
5 |
6 | registerCommand("list")
7 | .description("List all local recordings")
8 | .option("--json", "Format output as JSON")
9 | .action(list);
10 |
11 | async function list({ json = false }: { json?: boolean }) {
12 | const recordings = getRecordings();
13 |
14 | if (json) {
15 | console.log(JSON.stringify(recordings, null, 2));
16 | } else if (recordings.length === 0) {
17 | console.log("No recordings found");
18 | } else {
19 | console.log(printRecordings(recordings));
20 | }
21 |
22 | await exitProcess(0);
23 | }
24 |
--------------------------------------------------------------------------------
/packages/replayio/src/commands/login.ts:
--------------------------------------------------------------------------------
1 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
2 | import { registerCommand } from "../utils/commander/registerCommand";
3 | import { whoami } from "../utils/whoami";
4 |
5 | registerCommand("login", {
6 | requireAuthentication: true,
7 | })
8 | .description("Log into your Replay account (or register)")
9 | .action(login);
10 |
11 | async function login() {
12 | await whoami();
13 |
14 | await exitProcess(0);
15 | }
16 |
--------------------------------------------------------------------------------
/packages/replayio/src/commands/logout.ts:
--------------------------------------------------------------------------------
1 | import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
2 | import { logoutIfAuthenticated } from "@replay-cli/shared/authentication/logoutIfAuthenticated";
3 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
4 | import { highlight } from "@replay-cli/shared/theme";
5 | import { registerCommand } from "../utils/commander/registerCommand";
6 |
7 | registerCommand("logout").description("Log out of your Replay account").action(logout);
8 |
9 | async function logout() {
10 | await logoutIfAuthenticated();
11 |
12 | const { accessToken, apiKeySource } = await getAccessToken();
13 | if (accessToken && apiKeySource) {
14 | console.log(
15 | `You have been signed out but you are still authenticated by the ${highlight(
16 | apiKeySource
17 | )} env variable`
18 | );
19 | } else {
20 | console.log("You are now signed out");
21 | }
22 |
23 | await exitProcess(0);
24 | }
25 |
--------------------------------------------------------------------------------
/packages/replayio/src/commands/open.ts:
--------------------------------------------------------------------------------
1 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
2 | import { killBrowserIfRunning } from "../utils/browser/killBrowserIfRunning";
3 | import { launchBrowser } from "../utils/browser/launchBrowser";
4 | import { registerCommand } from "../utils/commander/registerCommand";
5 |
6 | registerCommand("open", { checkForRuntimeUpdate: true, requireAuthentication: true })
7 | .argument("[url]", `URL to open (default: "about:blank")`)
8 | .description("Open the replay browser with recording disabled")
9 | .action(open)
10 | .allowUnknownOption();
11 |
12 | async function open(url: string = "about:blank") {
13 | await killBrowserIfRunning();
14 |
15 | await launchBrowser(url, { record: false });
16 |
17 | await exitProcess(0);
18 | }
19 |
--------------------------------------------------------------------------------
/packages/replayio/src/commands/update.ts:
--------------------------------------------------------------------------------
1 | import { logError } from "@replay-cli/shared/logger";
2 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
3 | import { statusSuccess } from "@replay-cli/shared/theme";
4 | import { registerCommand } from "../utils/commander/registerCommand";
5 | import { checkForNpmUpdate } from "../utils/initialization/checkForNpmUpdate";
6 | import { checkForRuntimeUpdate } from "../utils/initialization/checkForRuntimeUpdate";
7 | import { promptForNpmUpdate } from "../utils/initialization/promptForNpmUpdate";
8 | import { installRelease } from "../utils/installation/installRelease";
9 |
10 | registerCommand("update", {
11 | checkForRuntimeUpdate: false,
12 | checkForNpmUpdate: false,
13 | })
14 | .description("Update Replay")
15 | .alias("install")
16 | .action(update);
17 |
18 | async function update() {
19 | try {
20 | const [runtimeUpdateCheck, npmUpdateCheck] = await Promise.all([
21 | checkForRuntimeUpdate(),
22 | checkForNpmUpdate(),
23 | ]);
24 |
25 | if (runtimeUpdateCheck.hasUpdate && npmUpdateCheck.hasUpdate) {
26 | await installRelease(runtimeUpdateCheck.toVersion);
27 | await promptForNpmUpdate(npmUpdateCheck, false);
28 | } else if (npmUpdateCheck.hasUpdate) {
29 | console.log(statusSuccess("✔"), "You have the latest version of the Replay Browser");
30 |
31 | await promptForNpmUpdate(npmUpdateCheck, false);
32 | } else if (runtimeUpdateCheck.hasUpdate) {
33 | console.log(statusSuccess("✔"), "You have the latest version of replayio");
34 |
35 | await installRelease(runtimeUpdateCheck.toVersion);
36 | } else {
37 | console.log(
38 | statusSuccess("✔"),
39 | "You have the latest version of replayio and the Replay Browser"
40 | );
41 | }
42 |
43 | await exitProcess(0);
44 | } catch (error) {
45 | logError("Update:Failed", { error });
46 | console.error(error);
47 |
48 | await exitProcess(1);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/packages/replayio/src/commands/upload-source-maps.ts:
--------------------------------------------------------------------------------
1 | import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
2 | import { logError } from "@replay-cli/shared/logger";
3 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
4 | import { dim } from "@replay-cli/shared/theme";
5 | import { uploadSourceMaps as uploadSourceMapsExternal } from "@replayio/sourcemap-upload";
6 | import { replayApiServer } from "../config";
7 | import { logPromise } from "../utils/async/logPromise";
8 | import { registerCommand } from "../utils/commander/registerCommand";
9 |
10 | registerCommand("upload-source-maps ", { requireAuthentication: true })
11 | .description("Upload source-maps for a Workspace")
12 | .requiredOption(
13 | "-g, --group ",
14 | "The name to group this source map into, e.g. A commit SHA or release version."
15 | )
16 | .option(
17 | "-x, --extensions ",
18 | `A comma-separated list of file extensions to process; ${dim('default ".js,.map"')}`,
19 | (value: string) => value.split(",")
20 | )
21 | .option(
22 | "-i, --ignore ",
23 | "Ignore files that match this pattern",
24 | (value: string, previous: Array = []) => {
25 | return previous.concat([value]);
26 | }
27 | )
28 | .option("--root ", "The base directory to use when computing relative paths")
29 | .action(uploadSourceMaps);
30 |
31 | async function uploadSourceMaps(
32 | filePaths: string[],
33 | {
34 | extensions,
35 | group,
36 | ignore,
37 | root,
38 | }: {
39 | extensions?: string[];
40 | group: string;
41 | ignore?: string[];
42 | root?: string;
43 | }
44 | ) {
45 | const { accessToken } = await getAccessToken();
46 | const uploadPromise = uploadSourceMapsExternal({
47 | extensions,
48 | filepaths: filePaths,
49 | group,
50 | ignore,
51 | key: accessToken,
52 | root,
53 | server: replayApiServer,
54 | });
55 |
56 | await logPromise(uploadPromise, {
57 | messages: {
58 | pending: "Uploading source maps...",
59 | success: "Source maps uploaded",
60 | failed: error => {
61 | logError("UploadSourceMaps:Failed", { error });
62 | return `Source maps upload failed:\n${error}`;
63 | },
64 | },
65 | });
66 |
67 | await exitProcess(0);
68 | }
69 |
--------------------------------------------------------------------------------
/packages/replayio/src/commands/upload.ts:
--------------------------------------------------------------------------------
1 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
2 | import { findRecordingsWithShortIds } from "@replay-cli/shared/recording/findRecordingsWithShortIds";
3 | import { getRecordings } from "@replay-cli/shared/recording/getRecordings";
4 | import { printRecordings } from "@replay-cli/shared/recording/printRecordings";
5 | import { selectRecordings } from "@replay-cli/shared/recording/selectRecordings";
6 | import { LocalRecording } from "@replay-cli/shared/recording/types";
7 | import { dim } from "@replay-cli/shared/theme";
8 | import { registerCommand } from "../utils/commander/registerCommand";
9 | import { uploadRecordings } from "../utils/recordings/uploadRecordings";
10 |
11 | registerCommand("upload", { requireAuthentication: true })
12 | .argument("[ids...]", `Recording ids ${dim("(comma-separated)")}`, value => value.split(","))
13 | .option("-a, --all", "Upload all recordings")
14 | .description("Upload recording(s)")
15 | .action(upload);
16 |
17 | async function upload(
18 | shortIds: string[],
19 | {
20 | all = false,
21 | }: {
22 | all?: boolean;
23 | } = {}
24 | ) {
25 | const recordings = getRecordings();
26 |
27 | let selectedRecordings: LocalRecording[] = [];
28 | if (shortIds.length > 0) {
29 | selectedRecordings = findRecordingsWithShortIds(recordings, shortIds);
30 | } else if (all) {
31 | selectedRecordings = recordings;
32 | } else if (recordings.length === 0) {
33 | console.log("No recordings found.");
34 | } else {
35 | if (!process.stdin.isTTY) {
36 | console.log("Recording ids argument required for non-TTY environments.");
37 |
38 | await exitProcess(1);
39 | }
40 |
41 | selectedRecordings = await selectRecordings(recordings, {
42 | defaultSelected: recording => recording.metadata.processType === "root",
43 | noSelectableRecordingsMessage:
44 | "The recording(s) below cannot be uploaded.\n" +
45 | printRecordings(recordings, { showHeaderRow: false }),
46 | prompt: "Which recordings would you like to upload?",
47 | selectionMessage: "The following recording(s) will be uploaded:",
48 | });
49 | }
50 |
51 | if (selectedRecordings.length > 0) {
52 | await uploadRecordings(selectedRecordings, { processingBehavior: "start-processing" });
53 | }
54 |
55 | await exitProcess(0);
56 | }
57 |
--------------------------------------------------------------------------------
/packages/replayio/src/commands/whoami.ts:
--------------------------------------------------------------------------------
1 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
2 | import { registerCommand } from "../utils/commander/registerCommand";
3 | import { whoami } from "../utils/whoami";
4 |
5 | registerCommand("whoami", {
6 | checkForNpmUpdate: false,
7 | checkForRuntimeUpdate: false,
8 | requireAuthentication: false,
9 | })
10 | .description("Display info about the current user")
11 | .action(command);
12 |
13 | const DOCS_URL = "https://docs.replay.io/reference/api-keys";
14 |
15 | async function command() {
16 | await whoami();
17 |
18 | await exitProcess(0);
19 | }
20 |
--------------------------------------------------------------------------------
/packages/replayio/src/config.ts:
--------------------------------------------------------------------------------
1 | // TODO [PRO-720] Remove these in favor of values exported by "shared"
2 | export const replayApiServer = process.env.REPLAY_API_SERVER || "https://api.replay.io";
3 | export const replayAppHost = process.env.REPLAY_APP_SERVER || "https://app.replay.io";
4 | export const replayWsServer =
5 | process.env.RECORD_REPLAY_SERVER || process.env.REPLAY_SERVER || "wss://dispatch.replay.io";
6 |
7 | const isCI = !!process.env.CI;
8 | const isDebugging = !!process.env.DEBUG;
9 | const isTTY = process.stdout.isTTY;
10 |
11 | export const disableAnimatedLog = isCI || isDebugging || !isTTY;
12 |
13 | export const disableMixpanel = isCI || process.env.NODE_ENV === "test";
14 | export const mixpanelToken =
15 | process.env.REPLAY_MIXPANEL_TOKEN || "ffaeda9ef8fb976a520ca3a65bba5014";
16 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/async/logPromise.ts:
--------------------------------------------------------------------------------
1 | import { logAsyncOperation, LogProgressOptions } from "./logAsyncOperation";
2 |
3 | export async function logPromise(
4 | promise: Promise,
5 | options: LogProgressOptions & {
6 | messages: {
7 | failed?: string | ((error: Error) => string);
8 | pending: string;
9 | success?: string | ((result: PromiseType) => string);
10 | };
11 | }
12 | ) {
13 | const { delayBeforeLoggingMs, messages } = options;
14 |
15 | const progress = logAsyncOperation(messages.pending, {
16 | delayBeforeLoggingMs,
17 | });
18 |
19 | try {
20 | const result = await promise;
21 | const message =
22 | typeof messages.success === "function" ? messages.success(result) : messages.success;
23 | progress.setSuccess(message || "");
24 | } catch (error) {
25 | const message =
26 | typeof messages.failed === "function" ? messages.failed(error as Error) : messages.failed;
27 | progress.setFailed(message || "");
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/browser/getBrowserPath.ts:
--------------------------------------------------------------------------------
1 | // TODO [PRO-720] Consolidate with code in @replay-cli/shared/src/runtime
2 |
3 | import { join } from "path";
4 | import { runtimeMetadata, runtimePath } from "../installation/config";
5 |
6 | export function getBrowserPath() {
7 | return join(runtimePath, ...runtimeMetadata.path);
8 | }
9 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/browser/getRunningProcess.ts:
--------------------------------------------------------------------------------
1 | import { logDebug } from "@replay-cli/shared/logger";
2 | import findProcess from "find-process";
3 | import { getBrowserPath } from "./getBrowserPath";
4 |
5 | export async function getRunningProcess() {
6 | const browserExecutablePath = getBrowserPath();
7 |
8 | const processes = await findProcess("name", browserExecutablePath);
9 | if (processes.length > 0) {
10 | const match = processes[0];
11 |
12 | logDebug("GetRunningProcess:AlreadyRunning", { pid: match.pid });
13 |
14 | return match;
15 | }
16 |
17 | return null;
18 | }
19 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/browser/killBrowserIfRunning.ts:
--------------------------------------------------------------------------------
1 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
2 | import { killProcess } from "@replay-cli/shared/process/killProcess";
3 | import { confirm } from "../confirm";
4 | import { getRunningProcess } from "./getRunningProcess";
5 |
6 | export async function killBrowserIfRunning() {
7 | const process = await getRunningProcess();
8 | if (process) {
9 | const confirmed = await confirm(
10 | "The replay browser is already running. You'll need to close it before running this command.\n\nWould you like to close it now?",
11 | true
12 | );
13 | if (confirmed) {
14 | const killResult = await killProcess(process.pid);
15 | if (!killResult) {
16 | console.log("Something went wrong trying to close the replay browser. Please try again.");
17 |
18 | await exitProcess(1);
19 | }
20 | } else {
21 | await exitProcess(0);
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/commander/finalizeCommander.ts:
--------------------------------------------------------------------------------
1 | import { Help, program } from "commander";
2 | import { formatOutput } from "./formatOutput";
3 |
4 | export function finalizeCommander() {
5 | try {
6 | program.configureHelp({
7 | formatHelp: (command, helper) => {
8 | const help = new Help();
9 | const helpText = help.formatHelp(command, helper);
10 | return formatOutput(helpText);
11 | },
12 | sortOptions: true,
13 | sortSubcommands: true,
14 | });
15 | program.configureOutput({
16 | writeErr: (text: string) => process.stderr.write(formatOutput(text)),
17 | writeOut: (text: string) => process.stdout.write(formatOutput(text)),
18 | });
19 | program.helpCommand("help [command]", "Display help for command");
20 | program.helpOption("-h, --help", "Display help for command");
21 | program.parse();
22 | } catch (error) {
23 | console.error(error);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/commander/formatCommandOrOptionLine.ts:
--------------------------------------------------------------------------------
1 | import { dim, highlight, highlightAlternate } from "@replay-cli/shared/theme";
2 |
3 | export function formatCommandOrOptionLine(line: string): string {
4 | // drop aliases
5 | line = line.replace(/(^\s*[a-zA-Z]+)(\|[a-zA-Z]+)/, (_, p1, p2) => p1 + " ".repeat(p1.length));
6 | // highlight flags
7 | line = line.replace(/ (-{1,2}[a-zA-Z0-9\-\_]+)/g, highlightAlternate(" $1"));
8 | // highlight arguments
9 | line = line.replace(/ ([<\[][a-zA-Z0-9\-\_\.]+[>\]])/g, highlight(" $1"));
10 | // highlight default values
11 | line = line.replace(/\(default: ([^)]+)\)/, dim(`(default: ${highlight("$1")})`));
12 | return line;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/commander/formatOutput.ts:
--------------------------------------------------------------------------------
1 | import { highlight, highlightAlternate } from "@replay-cli/shared/theme";
2 | import { drawBoxAroundText } from "../formatting";
3 | import { formatCommandOrOptionLine } from "./formatCommandOrOptionLine";
4 | import { Block } from "./types";
5 |
6 | export function formatOutput(originalText: string): string {
7 | const blocks: Block[] = [];
8 |
9 | let currentBlock: null | Block = null;
10 | let lines: string[] = [];
11 |
12 | originalText.split("\n").forEach(line => {
13 | if (currentBlock != null) {
14 | if (!line.trim()) {
15 | blocks.push(currentBlock);
16 | currentBlock = null;
17 | } else {
18 | currentBlock.lines.push(formatCommandOrOptionLine(line));
19 | }
20 | } else if (
21 | line.startsWith("Arguments:") ||
22 | line.startsWith("Commands:") ||
23 | line.startsWith("Options:")
24 | ) {
25 | currentBlock = {
26 | label: line.replace(":", "").trim(),
27 | lines: [],
28 | };
29 | } else if (line.startsWith("Usage:")) {
30 | line = line.replace(/ (-{1,2}[a-zA-Z0-9\-\_]+)/g, highlightAlternate(" $1"));
31 | line = line.replace(/ ([<\[][a-zA-Z0-9\-\_\.]+[>\]])/g, highlight(" $1"));
32 |
33 | lines.push(line);
34 | } else {
35 | lines.push(line);
36 | }
37 | });
38 |
39 | blocks.forEach(block => {
40 | const blockText = block.lines.map(line => `${line} `).join("\n");
41 | const boxedText =
42 | drawBoxAroundText(blockText, {
43 | headerLabel: block.label,
44 | }) + "\n";
45 |
46 | lines.push(...boxedText.split("\n"));
47 | });
48 |
49 | return lines.join("\n");
50 | }
51 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/commander/registerCommand.ts:
--------------------------------------------------------------------------------
1 | import { trackEvent } from "@replay-cli/shared/mixpanelClient";
2 | import { program } from "commander";
3 | import { initialize } from "../initialization/initialize";
4 |
5 | export function registerCommand(
6 | commandName: string,
7 | config: {
8 | checkForNpmUpdate?: boolean;
9 | checkForRuntimeUpdate?: boolean;
10 | requireAuthentication?: boolean;
11 | } = {}
12 | ) {
13 | const {
14 | checkForNpmUpdate = true,
15 | checkForRuntimeUpdate = false,
16 | requireAuthentication = false,
17 | } = config;
18 |
19 | return program.command(commandName).hook("preAction", async () => {
20 | trackEvent("command", { commandName });
21 |
22 | await initialize({
23 | checkForNpmUpdate,
24 | checkForRuntimeUpdate,
25 | requireAuthentication,
26 | });
27 | });
28 | }
29 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/commander/types.ts:
--------------------------------------------------------------------------------
1 | export type Block = {
2 | label: string;
3 | lines: string[];
4 | };
5 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/confirm.ts:
--------------------------------------------------------------------------------
1 | // @ts-ignore TS types are busted; see github.com/enquirer/enquirer/issues/212
2 | import { Confirm } from "bvaughn-enquirer";
3 |
4 | export async function confirm(message: string, defaultValue: boolean, footer?: string) {
5 | const confirm = new Confirm({
6 | footer,
7 | hideHelp: true,
8 | hideOutput: true,
9 | initial: defaultValue,
10 | message,
11 | name: "confirmation",
12 | });
13 | const confirmed = await confirm.run();
14 | return confirmed === true;
15 | }
16 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/findMostRecentFile.ts:
--------------------------------------------------------------------------------
1 | import { readdir, stat } from "fs-extra";
2 | import { join } from "path";
3 |
4 | export async function findMostRecentFile(
5 | directory: string,
6 | predicate: (fileName: string) => boolean = () => true
7 | ) {
8 | let mostRecent = undefined as string | undefined;
9 | let mostRecentMtimeMs = 0;
10 |
11 | await Promise.all(
12 | (
13 | await readdir(directory)
14 | ).map(async fileName => {
15 | const filePath = join(directory, fileName);
16 | let stats;
17 |
18 | try {
19 | stats = await stat(filePath);
20 | } catch {
21 | return;
22 | }
23 |
24 | if (
25 | !stats.isFile() ||
26 | !predicate(fileName) ||
27 | (mostRecent && stats.mtimeMs < mostRecentMtimeMs)
28 | ) {
29 | return;
30 | }
31 |
32 | mostRecent = filePath;
33 | mostRecentMtimeMs = stats.mtimeMs;
34 | })
35 | );
36 |
37 | return mostRecent;
38 | }
39 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/formatting.ts:
--------------------------------------------------------------------------------
1 | import { dim } from "@replay-cli/shared/theme";
2 | import strip from "strip-ansi";
3 |
4 | export function drawBoxAroundText(
5 | text: string,
6 | options: {
7 | headerLabel?: string;
8 | }
9 | ) {
10 | const { headerLabel } = options;
11 |
12 | const lines = text.split("\n");
13 | const lineLength = lines.reduce((maxLength, line) => {
14 | const length = strip(line).length;
15 | return Math.max(maxLength, length);
16 | }, 0);
17 |
18 | if (lineLength + 2 > process.stdout.columns) {
19 | if (headerLabel) {
20 | return headerLabel ? `${dim(`${headerLabel}:`)}\n${text}` : text;
21 | }
22 | }
23 |
24 | let formatted: string[] = [];
25 | if (headerLabel) {
26 | const headerWithPadding = ` ${headerLabel} `;
27 | formatted.push(
28 | dim(`${"┌"}${headerWithPadding}${"─".repeat(lineLength - headerWithPadding.length)}${"┐"}`)
29 | );
30 | } else {
31 | formatted.push(dim(`${"┌"}${"─".repeat(lineLength)}${"┐"}`));
32 | }
33 |
34 | lines.filter(Boolean).map(line => {
35 | const delta = lineLength - strip(line).length;
36 | const padding = delta > 0 ? " ".repeat(delta) : "";
37 | formatted.push(`${dim("│")}${line}${padding}${dim("│")}`);
38 | });
39 |
40 | formatted.push(dim(`${"└"}${"─".repeat(lineLength)}${"┘"}`));
41 |
42 | return formatted.join("\n");
43 | }
44 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/graphql/fetchViewerFromGraphQL.ts:
--------------------------------------------------------------------------------
1 | import { GraphQLError } from "@replay-cli/shared/graphql/GraphQLError";
2 | import { queryGraphQL } from "@replay-cli/shared/graphql/queryGraphQL";
3 | import { logInfo } from "@replay-cli/shared/logger";
4 |
5 | export type AuthInfo = {
6 | userEmail: string | undefined;
7 | userName: string | undefined;
8 | teamName: string | undefined;
9 | };
10 |
11 | export async function fetchViewerFromGraphQL(accessToken: string): Promise {
12 | logInfo("FetchViewerFromGraphQL:Start");
13 |
14 | const { data, errors } = await queryGraphQL(
15 | "ViewerInfo",
16 | `
17 | query ViewerInfo {
18 | viewer {
19 | email
20 | user {
21 | name
22 | }
23 | }
24 | auth {
25 | workspaces {
26 | edges {
27 | node {
28 | name
29 | }
30 | }
31 | }
32 | }
33 | }
34 | `,
35 | {},
36 | accessToken
37 | );
38 |
39 | if (errors) {
40 | throw new GraphQLError("Failed to fetch auth info", errors);
41 | }
42 |
43 | const response = data as {
44 | viewer: {
45 | email: string;
46 | user: {
47 | name: string;
48 | } | null;
49 | };
50 | auth: {
51 | workspaces: {
52 | edges: {
53 | node: {
54 | name: string;
55 | };
56 | }[];
57 | };
58 | };
59 | };
60 |
61 | const { viewer, auth } = response;
62 |
63 | return {
64 | userEmail: viewer?.email,
65 | userName: viewer?.user?.name,
66 | teamName: auth?.workspaces?.edges?.[0]?.node?.name,
67 | };
68 | }
69 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/initialization/checkForNpmUpdate.ts:
--------------------------------------------------------------------------------
1 | import { logError } from "@replay-cli/shared/logger";
2 | import { createAsyncFunctionWithTracking } from "@replay-cli/shared/mixpanelClient";
3 | import { fetch } from "undici";
4 | import { version as currentVersion, name as packageName } from "../../../package.json";
5 | import { shouldPrompt } from "../prompt/shouldPrompt";
6 | import { UpdateCheck } from "./types";
7 |
8 | const PROMPT_ID = "npm-update";
9 |
10 | export const checkForNpmUpdate = createAsyncFunctionWithTracking(
11 | async function checkForNpmUpdate(): Promise> {
12 | try {
13 | // https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-metadata-format
14 | const response = await fetch(`https://registry.npmjs.org/${packageName}`, {
15 | headers: {
16 | Accept: "application/vnd.npm.install-v1+json",
17 | },
18 | });
19 | const json: any = await response.json();
20 | const latestVersion = json["dist-tags"].latest;
21 |
22 | return {
23 | hasUpdate: currentVersion !== latestVersion,
24 | fromVersion: currentVersion,
25 | shouldShowPrompt: shouldPrompt({
26 | id: PROMPT_ID,
27 | metadata: latestVersion,
28 | }),
29 | toVersion: latestVersion,
30 | };
31 | } catch (error) {
32 | logError("CheckForNpmUpdate:Failed", { error });
33 | }
34 |
35 | return {
36 | hasUpdate: undefined,
37 | };
38 | },
39 | "update.npm.check",
40 | result => ({
41 | hasUpdate: result?.hasUpdate,
42 | newPackageVersion: result?.hasUpdate ? result?.toVersion : null,
43 | shouldShowPrompt: !!(result?.hasUpdate && result?.shouldShowPrompt),
44 | })
45 | );
46 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/initialization/getCurrentRuntimeMetadata.ts:
--------------------------------------------------------------------------------
1 | import { readFromCache } from "@replay-cli/shared/cache";
2 | import { metadataPath } from "../installation/config";
3 | import { MetadataJSON, Runtime } from "../installation/types";
4 |
5 | export function getCurrentRuntimeMetadata(runtime: Runtime) {
6 | const metadata = readFromCache(metadataPath);
7 | return metadata?.[runtime];
8 | }
9 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/initialization/promptForAuthentication.ts:
--------------------------------------------------------------------------------
1 | import { raceWithTimeout } from "@replay-cli/shared/async/raceWithTimeout";
2 | import { authenticateByBrowser } from "@replay-cli/shared/authentication/authenticateByBrowser";
3 | import { logError } from "@replay-cli/shared/logger";
4 | import { exitProcess } from "@replay-cli/shared/process/exitProcess";
5 | import { highlight } from "@replay-cli/shared/theme";
6 |
7 | const TIMEOUT = 60_000;
8 |
9 | export async function promptForAuthentication() {
10 | const accessToken = await raceWithTimeout(authenticateByBrowser(), TIMEOUT);
11 | if (!accessToken) {
12 | logError("Authentication timed out");
13 |
14 | console.log("");
15 | console.log(highlight("Log in timed out; please try again"));
16 |
17 | await exitProcess(1);
18 | }
19 |
20 | return accessToken;
21 | }
22 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/initialization/promptForNpmUpdate.ts:
--------------------------------------------------------------------------------
1 | import { highlight } from "@replay-cli/shared/theme";
2 | import { name as packageName } from "../../../package.json";
3 | import { prompt } from "../prompt/prompt";
4 | import { updateCachedPromptData } from "../prompt/updateCachedPromptData";
5 | import { UpdateCheckResult } from "./types";
6 |
7 | const PROMPT_ID = "npm-update";
8 |
9 | export async function promptForNpmUpdate(
10 | updateCheck: UpdateCheckResult,
11 | promptBeforeContinuing: boolean = true
12 | ) {
13 | const { fromVersion, toVersion } = updateCheck;
14 |
15 | console.log("");
16 | console.log("A new version of replayio is available!");
17 | console.log(" Installed version:", highlight(fromVersion));
18 | console.log(" New version:", highlight(toVersion));
19 | console.log("");
20 | console.log("To upgrade, run the following:");
21 | console.log(highlight(` npm install --global ${packageName}@${toVersion}`));
22 | console.log("");
23 |
24 | if (promptBeforeContinuing) {
25 | if (process.stdin.isTTY) {
26 | console.log("Press any key to continue");
27 | console.log("");
28 |
29 | await prompt();
30 | }
31 | }
32 |
33 | updateCachedPromptData({
34 | id: PROMPT_ID,
35 | metadata: toVersion,
36 | });
37 | }
38 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/initialization/promptForRuntimeUpdate.ts:
--------------------------------------------------------------------------------
1 | import { trackEvent } from "@replay-cli/shared/mixpanelClient";
2 | import { emphasize } from "@replay-cli/shared/theme";
3 | import { name as packageName } from "../../../package.json";
4 | import { installRelease } from "../installation/installRelease";
5 | import { prompt } from "../prompt/prompt";
6 | import { updateCachedPromptData } from "../prompt/updateCachedPromptData";
7 | import { Version } from "./checkForRuntimeUpdate";
8 | import { UpdateCheckResult } from "./types";
9 | import { getLatestRelease } from "../installation/getLatestReleases";
10 |
11 | const PROMPT_ID = "runtime-update";
12 |
13 | export async function promptForRuntimeUpdate(updateCheck: UpdateCheckResult) {
14 | const { fromVersion, toVersion } = updateCheck;
15 |
16 | // If the user hasn't installed Replay runtime, they'll have to install it
17 | // Otherwise let's check for potential updates and ask them (at most) once per day
18 | let confirmed = fromVersion == null;
19 |
20 | if (fromVersion) {
21 | if (!process.stdin.isTTY) {
22 | console.log("A new version of the Replay browser is available.");
23 | console.log(`Run "${emphasize(`${packageName} upgrade`)}" to update`);
24 | } else {
25 | console.log("");
26 | console.log(`A new version of the Replay browser is available.`);
27 | console.log(`Press ${emphasize("[Enter]")} to upgrade or press any other key to skip.`);
28 | console.log("");
29 |
30 | confirmed = await prompt();
31 | }
32 | } else {
33 | console.log("");
34 | console.log("In order to record a Replay, you'll have to first install the browser.");
35 | console.log(`Press any key to continue`);
36 | console.log("");
37 |
38 | await prompt();
39 | }
40 |
41 | updateCachedPromptData({
42 | id: PROMPT_ID,
43 | metadata: toVersion.buildId,
44 | });
45 |
46 | if (confirmed) {
47 | try {
48 | const latestRelease = await getLatestRelease();
49 | await installRelease({
50 | buildId: latestRelease.buildId,
51 | forkedVersion: latestRelease.version,
52 | });
53 | } catch (error) {
54 | // A failed update is not a critical error;
55 | // A failed install will be handled later
56 | }
57 | } else {
58 | trackEvent("update.runtime.skipped", { newRuntimeVersion: toVersion });
59 | }
60 |
61 | console.log("");
62 | }
63 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/initialization/types.ts:
--------------------------------------------------------------------------------
1 | export type UpdateCheckFailed = {
2 | hasUpdate: undefined;
3 | };
4 |
5 | export type UpdateCheckResult = {
6 | hasUpdate: boolean | undefined;
7 | fromVersion: Version | undefined;
8 | shouldShowPrompt: boolean;
9 | toVersion: Version;
10 | };
11 |
12 | export type UpdateCheck = UpdateCheckFailed | UpdateCheckResult;
13 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/installation/getLatestReleases.ts:
--------------------------------------------------------------------------------
1 | import { logDebug } from "@replay-cli/shared/logger";
2 | import assert from "node:assert/strict";
3 | import { fetch } from "undici";
4 | import { replayAppHost } from "../../config";
5 | import { runtimeMetadata } from "./config";
6 | import { Release } from "./types";
7 |
8 | const { architecture, platform, runtime } = runtimeMetadata;
9 |
10 | export async function getLatestRelease() {
11 | logDebug("GetLatestRelease:Start");
12 |
13 | const response = await fetch(`${replayAppHost}/api/releases`);
14 | const json = (await response.json()) as Release[];
15 | const latestRelease = json.find(
16 | release =>
17 | release.platform === platform &&
18 | release.runtime === runtime &&
19 | (release.architecture === architecture || release.architecture === "unknown")
20 | );
21 |
22 | logDebug("GetLatestRelease:LatestRelease", { latestRelease });
23 | assert(latestRelease, `No release found for ${platform}:${runtime}`);
24 |
25 | return latestRelease;
26 | }
27 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/installation/types.ts:
--------------------------------------------------------------------------------
1 | // TODO [PRO-720] Consolidate with code in @replay-cli/shared/src/runtime
2 |
3 | export type Executable = "darwin:chromium" | "linux:chromium" | "win32:chromium";
4 | export type Platform = "macOS" | "linux" | "windows";
5 |
6 | // This CLI only supports Chromium for the time being
7 | export type Runtime = "chromium" | "node";
8 |
9 | export type Architecture = "arm" | "x86_64" | "unknown";
10 |
11 | export type Release = {
12 | architecture: Architecture;
13 | buildFile: string;
14 | buildId: string;
15 | platform: Platform;
16 | releaseFile: string;
17 | runtime: Runtime;
18 | time: string;
19 |
20 | // Gecko releases don't have a version string
21 | version: string | null;
22 | };
23 |
24 | export type MetadataJSON = {
25 | [Key in Runtime]?: {
26 | buildId: string;
27 | forkedVersion: string | null;
28 | installDate: string;
29 | };
30 | };
31 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/prompt/config.ts:
--------------------------------------------------------------------------------
1 | import { getReplayPath } from "@replay-cli/shared/getReplayPath";
2 |
3 | export const promptHistoryPath = getReplayPath("profile", "promptHistory.json");
4 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/prompt/prompt.ts:
--------------------------------------------------------------------------------
1 | export async function prompt({
2 | onExit,
3 | signal,
4 | }: {
5 | onExit?: () => void;
6 | signal?: AbortSignal;
7 | } = {}): Promise {
8 | return new Promise(resolve => {
9 | if (signal?.aborted) {
10 | resolve(false);
11 | return;
12 | }
13 | const stdin = process.stdin;
14 | const prevRaw = stdin.isRaw;
15 |
16 | stdin.setRawMode(true);
17 | stdin.resume();
18 | stdin.setEncoding("utf8");
19 |
20 | function abortListener() {
21 | destroy();
22 | resolve(false);
23 | }
24 |
25 | function destroy() {
26 | stdin.off("data", onData);
27 | stdin.setRawMode(prevRaw);
28 | stdin.setEncoding();
29 | signal?.removeEventListener("abort", abortListener);
30 | }
31 |
32 | function onData(data: string) {
33 | destroy();
34 |
35 | switch (data) {
36 | case "\n":
37 | case "\r":
38 | resolve(true);
39 | break;
40 | case "\x03":
41 | // \x03 is Ctrl+C (aka "End of text")
42 | // https://donsnotes.com/tech/charsets/ascii.html
43 | onExit?.();
44 | process.exit(0);
45 | default:
46 | resolve(false);
47 | break;
48 | }
49 | }
50 |
51 | stdin.on("data", onData);
52 | signal?.addEventListener("abort", abortListener);
53 | });
54 | }
55 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/prompt/shouldPrompt.ts:
--------------------------------------------------------------------------------
1 | import { readFromCache } from "@replay-cli/shared/cache";
2 | import { promptHistoryPath } from "./config";
3 | import { PromptHistory } from "./types";
4 |
5 | const ONE_DAY = 1000 * 60 * 60 * 24;
6 |
7 | export function shouldPrompt({
8 | id,
9 | metadata: metadataNext,
10 | minimumIntervalMs = ONE_DAY,
11 | }: {
12 | id: string;
13 | metadata: any;
14 | minimumIntervalMs?: number;
15 | }) {
16 | const cache = readFromCache(promptHistoryPath);
17 | if (!cache) {
18 | return true;
19 | }
20 |
21 | const entry = cache[id];
22 | if (entry == null || typeof entry !== "object") {
23 | return true;
24 | }
25 |
26 | const { metadata: metadataPrev, time } = entry;
27 | if (Date.now() - time >= minimumIntervalMs) {
28 | return true;
29 | }
30 |
31 | if (metadataNext !== metadataPrev) {
32 | return true;
33 | }
34 |
35 | return false;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/prompt/types.ts:
--------------------------------------------------------------------------------
1 | export type PromptHistory = {
2 | [id: string]: {
3 | metadata: any;
4 | time: number;
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/prompt/updateCachedPromptData.ts:
--------------------------------------------------------------------------------
1 | import { readFromCache, writeToCache } from "@replay-cli/shared/cache";
2 | import { promptHistoryPath } from "./config";
3 | import { PromptHistory } from "./types";
4 |
5 | export function updateCachedPromptData({ id, metadata }: { id: string; metadata: any }) {
6 | const cache = readFromCache(promptHistoryPath) ?? {};
7 | writeToCache(promptHistoryPath, {
8 | ...cache,
9 | [id]: {
10 | metadata,
11 | time: Date.now(),
12 | },
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/packages/replayio/src/utils/whoami.ts:
--------------------------------------------------------------------------------
1 | import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
2 | import { getAuthInfo } from "@replay-cli/shared/authentication/getAuthInfo";
3 | import { dim, emphasize, highlight, link } from "@replay-cli/shared/theme";
4 | import { name as packageName } from "../../package.json";
5 | import { fetchViewerFromGraphQL } from "../utils/graphql/fetchViewerFromGraphQL";
6 |
7 | const DOCS_URL = "https://docs.replay.io/reference/api-keys";
8 |
9 | export async function whoami() {
10 | const { accessToken, apiKeySource } = await getAccessToken();
11 | if (accessToken) {
12 | const authInfo = await getAuthInfo(accessToken);
13 |
14 | const { userEmail, userName, teamName } = await fetchViewerFromGraphQL(accessToken);
15 |
16 | if (apiKeySource) {
17 | console.log(`You are authenticated by API key ${dim(`(process.env.${apiKeySource})`)}`);
18 | console.log("");
19 | if (authInfo.type === "user") {
20 | console.log(`This API key belongs to ${emphasize(userName)} (${userEmail})`);
21 | console.log(`Recordings you upload are ${emphasize("private")} by default`);
22 | } else {
23 | console.log(`This API key belongs to the team named ${emphasize(teamName)}`);
24 | console.log(`Recordings you upload are ${emphasize("shared")} with other team members`);
25 | }
26 | console.log("");
27 | console.log(`Learn more about API keys at ${link(DOCS_URL)}`);
28 | } else {
29 | console.log(`You are signed in as ${emphasize(userName)} (${userEmail})`);
30 | console.log("");
31 | console.log(`Recordings you upload are ${emphasize("private")} by default`);
32 | console.log("");
33 | console.log(`Learn about other ways to sign in at ${link(DOCS_URL)}`);
34 | }
35 | } else {
36 | console.log("You are not authenticated");
37 | console.log("");
38 | console.log(`Sign in by running ${highlight(`${packageName} login`)}`);
39 | console.log("");
40 | console.log("You can also authenticate with an API key");
41 | console.log(`Learn more at ${link(DOCS_URL)}`);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/replayio/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | "target": "es2022",
5 | "lib": ["es2023"],
6 | "resolveJsonModule": true,
7 | "types": ["node", "jest"]
8 | },
9 | "include": ["src/**/*.ts"],
10 | "exclude": ["**/*.test.ts"],
11 | "references": [
12 | {
13 | "path": "../shared"
14 | },
15 | {
16 | "path": "../sourcemap-upload"
17 | }
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/packages/shared/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: "ts-jest",
4 | rootDir: "src",
5 | testEnvironment: "node",
6 | testMatch: ["**/(*.)+test.ts?(x)"],
7 | };
8 |
--------------------------------------------------------------------------------
/packages/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replay-cli/shared",
3 | "version": "0.0.0",
4 | "private": true,
5 | "exports": {
6 | "./*": "./dist/*.js"
7 | },
8 | "scripts": {
9 | "build": "pkg-build",
10 | "test": "jest --ci",
11 | "typecheck": "tsc --noEmit"
12 | },
13 | "dependencies": {
14 | "bvaughn-enquirer": "2.4.2",
15 | "chalk": "^4.1.2",
16 | "cli-spinners": "^2.9.2",
17 | "date-fns": "^2.28.0",
18 | "debug": "^4.3.4",
19 | "find-process": "^1.4.7",
20 | "fs-extra": "^11.2.0",
21 | "launchdarkly-node-client-sdk": "^3.2.1",
22 | "log-update": "^4",
23 | "mixpanel": "^0.18.0",
24 | "open": "^8.4.2",
25 | "pretty-ms": "^7.0.1",
26 | "stack-utils": "^2.0.6",
27 | "strip-ansi": "^6.0.1",
28 | "superstruct": "^1.0.4",
29 | "table": "^6.8.2",
30 | "undici": "^5.28.4",
31 | "winston": "^3.13.0",
32 | "winston-loki": "^6.1.2",
33 | "ws": "^7.5.0"
34 | },
35 | "devDependencies": {
36 | "@replay-cli/pkg-build": "workspace:^",
37 | "@types/jest": "^28.1.5",
38 | "@types/stack-utils": "^2.0.3",
39 | "jest": "^28.1.3",
40 | "turbo": "^2.0.5",
41 | "typescript": "^5.5.2"
42 | },
43 | "@replay-cli/pkg-build": {
44 | "entrypoints": [
45 | "./src/**/*.ts"
46 | ]
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/shared/src/ProcessError.ts:
--------------------------------------------------------------------------------
1 | export class ProcessError extends Error {
2 | stderr: string;
3 |
4 | constructor(message: string, stderr: string) {
5 | super(message);
6 |
7 | this.stderr = stderr;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/shared/src/async/isPromiseLike.ts:
--------------------------------------------------------------------------------
1 | export function isPromiseLike(value: any): value is PromiseLike {
2 | return value && typeof value.then === "function";
3 | }
4 |
--------------------------------------------------------------------------------
/packages/shared/src/async/raceWithTimeout.ts:
--------------------------------------------------------------------------------
1 | import { isTimeoutResult, timeoutAfter } from "./timeoutAfter";
2 |
3 | export async function raceWithTimeout(
4 | promise: Promise,
5 | timeoutMs: number,
6 | abortController?: AbortController
7 | ): Promise {
8 | const result = await Promise.race([promise, timeoutAfter(timeoutMs)]);
9 | if (isTimeoutResult(result)) {
10 | if (abortController) {
11 | abortController.abort();
12 | }
13 |
14 | return undefined;
15 | }
16 |
17 | return result;
18 | }
19 |
--------------------------------------------------------------------------------
/packages/shared/src/async/retryOnFailure.test.ts:
--------------------------------------------------------------------------------
1 | import { retryWithExponentialBackoff, retryWithLinearBackoff } from "./retryOnFailure";
2 |
3 | describe("retryWithExponentialBackoff", () => {
4 | it("retries until it succeeds", async () => {
5 | let i = 0;
6 | const failingFunction = jest.fn(async () => {
7 | i++;
8 | if (i < 3) {
9 | throw Error("ExpectedError");
10 | }
11 | });
12 |
13 | await retryWithExponentialBackoff(failingFunction);
14 |
15 | expect(failingFunction).toHaveBeenCalledTimes(3);
16 | });
17 |
18 | it("throws after max attempts", async () => {
19 | const mockFn = jest.fn();
20 | mockFn.mockRejectedValue(new Error("Expected failure"));
21 |
22 | const maxTries = 3;
23 |
24 | await expect(retryWithExponentialBackoff(mockFn, undefined, maxTries)).rejects.toThrow(
25 | "Expected failure"
26 | );
27 | expect(mockFn).toHaveBeenCalledTimes(maxTries);
28 | });
29 | });
30 |
31 | describe("retryWithLinearBackoff", () => {
32 | it("retries until it succeeds", async () => {
33 | const mockFn = jest.fn();
34 | mockFn
35 | .mockRejectedValueOnce(new Error("Fail 1"))
36 | .mockRejectedValueOnce(new Error("Fail 2"))
37 | .mockResolvedValue("Success");
38 |
39 | await expect(retryWithLinearBackoff(mockFn)).resolves.toEqual("Success");
40 | expect(mockFn).toHaveBeenCalledTimes(3);
41 | });
42 |
43 | it("throws after max attempts", async () => {
44 | const mockFn = jest.fn();
45 | mockFn.mockRejectedValue(new Error("Fail"));
46 |
47 | await expect(retryWithLinearBackoff(mockFn)).rejects.toThrow("Fail");
48 | expect(mockFn).toHaveBeenCalledTimes(5);
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/packages/shared/src/async/retryOnFailure.ts:
--------------------------------------------------------------------------------
1 | import { timeoutAfter } from "./timeoutAfter";
2 |
3 | async function retry(
4 | asyncFunction: () => Promise,
5 | backOffStrategy: (iteration: number) => number,
6 | onFail?: (error: unknown, attemptNumber: number, maxAttempts: number) => void,
7 | maxAttempts: number = 5
8 | ): Promise {
9 | let currentAttempt = 0;
10 | while (currentAttempt <= maxAttempts) {
11 | currentAttempt++;
12 |
13 | try {
14 | return await asyncFunction();
15 | } catch (error) {
16 | if (onFail) {
17 | onFail(error, currentAttempt, maxAttempts);
18 | }
19 |
20 | if (currentAttempt == maxAttempts) {
21 | throw error;
22 | }
23 |
24 | await timeoutAfter(backOffStrategy(currentAttempt));
25 | }
26 | }
27 |
28 | throw Error("ShouldBeUnreachable");
29 | }
30 |
31 | export async function retryWithExponentialBackoff(
32 | asyncFunction: () => Promise,
33 | onFail?: (error: unknown, attemptNumber: number, maxAttempts: number) => void,
34 | maxTries?: number
35 | ): Promise {
36 | const backoff = (iteration: number) => 2 ** iteration * 100 + jitter();
37 |
38 | return retry(asyncFunction, backoff, onFail, maxTries);
39 | }
40 |
41 | export async function retryWithLinearBackoff(
42 | asyncFunction: () => Promise,
43 | onFail?: (error: unknown, attemptNumber: number, maxAttempts: number) => void,
44 | maxTries?: number
45 | ): Promise {
46 | const backoff = () => 100 + jitter();
47 |
48 | return retry(asyncFunction, backoff, onFail, maxTries);
49 | }
50 |
51 | // https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
52 | function jitter(): number {
53 | return Math.random() * 100;
54 | }
55 |
--------------------------------------------------------------------------------
/packages/shared/src/async/timeoutAfter.ts:
--------------------------------------------------------------------------------
1 | type TimeoutResult = { timedOutAfter: number };
2 |
3 | export async function timeoutAfter(
4 | duration: number,
5 | throwOnTimeout: boolean = false
6 | ): Promise {
7 | const startTime = Date.now();
8 | return new Promise((resolve, reject) =>
9 | setTimeout(() => {
10 | const endTime = Date.now();
11 |
12 | if (throwOnTimeout) {
13 | reject(new Error(`Timed out after ${endTime - startTime}ms`));
14 | } else {
15 | resolve({ timedOutAfter: endTime - startTime });
16 | }
17 | }, duration)
18 | );
19 | }
20 |
21 | export function isTimeoutResult(result: unknown): result is TimeoutResult {
22 | return result != null && typeof result === "object" && "timedOutAfter" in result;
23 | }
24 |
--------------------------------------------------------------------------------
/packages/shared/src/authentication/AuthenticationError.ts:
--------------------------------------------------------------------------------
1 | export class AuthenticationError extends Error {
2 | code: string;
3 |
4 | constructor(code: string, message: string) {
5 | super(message);
6 |
7 | this.code = code;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/shared/src/authentication/config.ts:
--------------------------------------------------------------------------------
1 | import { getReplayPath } from "../getReplayPath";
2 |
3 | export const authClientId = process.env.REPLAY_AUTH_CLIENT_ID || "4FvFnJJW4XlnUyrXQF8zOLw6vNAH1MAo";
4 | export const authHost = process.env.REPLAY_AUTH_HOST || "webreplay.us.auth0.com";
5 | export const cachedAuthPath = getReplayPath("profile", "auth.json");
6 |
--------------------------------------------------------------------------------
/packages/shared/src/authentication/getAuthInfo.ts:
--------------------------------------------------------------------------------
1 | import { readFromCache } from "../cache";
2 | import { cachePath } from "../graphql/cachePath";
3 | import { fetchAuthInfoFromGraphQL } from "../graphql/fetchAuthInfoFromGraphQL";
4 | import { updateCachedAuthInfo } from "../graphql/updateCachedAuthInfo";
5 | import { AuthInfo } from "./types";
6 |
7 | export type Cached = {
8 | [accessToken: string]: AuthInfo;
9 | };
10 |
11 | export async function getAuthInfo(accessToken: string): Promise {
12 | const cached = readFromCache(cachePath) ?? {};
13 |
14 | let authInfo = cached[accessToken];
15 | if (!authInfo) {
16 | authInfo = await fetchAuthInfoFromGraphQL(accessToken);
17 |
18 | updateCachedAuthInfo(accessToken, authInfo);
19 | }
20 |
21 | return authInfo;
22 | }
23 |
--------------------------------------------------------------------------------
/packages/shared/src/authentication/logoutIfAuthenticated.ts:
--------------------------------------------------------------------------------
1 | import { readFromCache, writeToCache } from "../cache";
2 | import { updateCachedAuthInfo } from "../graphql/updateCachedAuthInfo";
3 | import { cachedAuthPath } from "./config";
4 | import { CachedAuthDetails } from "./types";
5 |
6 | export async function logoutIfAuthenticated() {
7 | let { accessToken } = readFromCache(cachedAuthPath) ?? {};
8 | if (accessToken) {
9 | updateCachedAuthInfo(accessToken, undefined);
10 | }
11 |
12 | writeToCache(cachedAuthPath, undefined);
13 | }
14 |
--------------------------------------------------------------------------------
/packages/shared/src/authentication/refreshAccessTokenOrThrow.ts:
--------------------------------------------------------------------------------
1 | import { fetch } from "undici";
2 | import { logDebug } from "../logger";
3 | import { AuthenticationError } from "./AuthenticationError";
4 | import { authClientId, authHost } from "./config";
5 |
6 | export async function refreshAccessTokenOrThrow(
7 | refreshToken: string
8 | ): Promise<{ accessToken: string; refreshToken: string }> {
9 | const resp = await fetch(`https://${authHost}/oauth/token`, {
10 | method: "POST",
11 | headers: { "Content-Type": "application/json" },
12 | body: JSON.stringify({
13 | audience: "https://api.replay.io",
14 | scope: "openid profile",
15 | grant_type: "refresh_token",
16 | client_id: authClientId,
17 | refresh_token: refreshToken,
18 | }),
19 | });
20 |
21 | const json: any = await resp.json();
22 |
23 | if (json.error) {
24 | logDebug("OAuth token request failed", json);
25 |
26 | throw new AuthenticationError("auth0-error", json.error);
27 | }
28 |
29 | if (!json.access_token || !json.refresh_token) {
30 | logDebug("OAuth token request was missing access or refresh token", json);
31 |
32 | throw new AuthenticationError(
33 | "no-access-or-refresh-token",
34 | "No access or refresh token in response"
35 | );
36 | }
37 |
38 | return {
39 | accessToken: json.access_token as string,
40 | refreshToken: json.refresh_token as string,
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/packages/shared/src/authentication/types.ts:
--------------------------------------------------------------------------------
1 | export type AuthInfo = {
2 | id: string;
3 | type: "user" | "workspace";
4 | };
5 |
6 | export type CachedAuthDetails = {
7 | accessToken: string;
8 | refreshToken: string;
9 | };
10 |
--------------------------------------------------------------------------------
/packages/shared/src/cache.ts:
--------------------------------------------------------------------------------
1 | import { ensureFileSync, existsSync, readFileSync, removeSync, writeFileSync } from "fs-extra";
2 |
3 | export function readFromCache(path: string): Type | undefined {
4 | if (existsSync(path)) {
5 | try {
6 | const text = readFileSync(path, { encoding: "utf-8" });
7 | return JSON.parse(text) as Type;
8 | } catch (error) {}
9 | }
10 | }
11 |
12 | export function writeToCache(path: string, value: Type | undefined) {
13 | if (value) {
14 | const data = JSON.stringify(
15 | {
16 | "// WARNING": "This file contains sensitive information; do not share!",
17 | ...value,
18 | },
19 | null,
20 | 2
21 | );
22 |
23 | ensureFileSync(path);
24 | writeFileSync(path, data, { encoding: "utf-8" });
25 | } else {
26 | removeSync(path);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/shared/src/config.ts:
--------------------------------------------------------------------------------
1 | export const replayApiServer = process.env.REPLAY_API_SERVER || "https://api.replay.io";
2 | export const replayAppHost = process.env.REPLAY_APP_SERVER || "https://app.replay.io";
3 | export const replayWsServer =
4 | process.env.RECORD_REPLAY_SERVER || process.env.REPLAY_SERVER || "wss://dispatch.replay.io";
5 |
6 | const isCI = !!process.env.CI;
7 | const isDebugging = !!process.env.DEBUG;
8 | const isTTY = process.stdout.isTTY;
9 |
10 | export const disableAnimatedLog = isCI || isDebugging || !isTTY;
11 |
12 | export const disableMixpanel = isCI || process.env.NODE_ENV === "test";
13 | export const mixpanelToken =
14 | process.env.REPLAY_MIXPANEL_TOKEN || "ffaeda9ef8fb976a520ca3a65bba5014";
15 |
--------------------------------------------------------------------------------
/packages/shared/src/date.ts:
--------------------------------------------------------------------------------
1 | import differenceInCalendarDays from "date-fns/differenceInCalendarDays";
2 | import differenceInMinutes from "date-fns/differenceInMinutes";
3 | import differenceInMonths from "date-fns/differenceInMonths";
4 | import differenceInSeconds from "date-fns/differenceInSeconds";
5 | import differenceInWeeks from "date-fns/differenceInWeeks";
6 | import differenceInYears from "date-fns/differenceInYears";
7 | import prettyMilliseconds from "pretty-ms";
8 |
9 | export function formatDuration(ms: number) {
10 | return prettyMilliseconds(ms, { millisecondsDecimalDigits: 1 });
11 | }
12 |
13 | export function formatRelativeDate(date: Date): string {
14 | const seconds = differenceInSeconds(Date.now(), date);
15 | const minutes = differenceInMinutes(Date.now(), date);
16 | const days = differenceInCalendarDays(Date.now(), date);
17 | const weeks = differenceInWeeks(Date.now(), date);
18 | const months = differenceInMonths(Date.now(), date);
19 | const years = differenceInYears(Date.now(), date);
20 |
21 | if (years > 0) {
22 | return `${years}y ago`;
23 | } else if (months > 0) {
24 | return `${months}mo ago`;
25 | } else if (weeks > 0) {
26 | return `${weeks}w ago`;
27 | } else if (days > 0) {
28 | return `${days}d ago`;
29 | } else if (minutes >= 60) {
30 | return `${Math.floor(minutes / 60)}h ago`;
31 | } else if (minutes > 0) {
32 | return `${minutes}m ago`;
33 | } else if (seconds > 0) {
34 | return `${seconds}s ago`;
35 | }
36 |
37 | return "Now";
38 | }
39 |
40 | export function formatTimestamp(ms: number, showHighPrecision: boolean = false) {
41 | const seconds = showHighPrecision ? Math.floor(ms / 1000) : Math.round(ms / 1000.0);
42 | const minutesString = Math.floor(seconds / 60);
43 | const secondsString = String(seconds % 60).padStart(2, "0");
44 | if (showHighPrecision) {
45 | const millisecondsString = `${Math.round(ms) % 1000}`.padStart(3, "0");
46 | return `${minutesString}:${secondsString}.${millisecondsString}`;
47 | } else {
48 | return `${minutesString}:${secondsString}`;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/packages/shared/src/getDeviceId.ts:
--------------------------------------------------------------------------------
1 | import { readFromCache, writeToCache } from "./cache";
2 | import { getObservabilityCachePath } from "./getObservabilityCachePath";
3 | import { randomUUID } from "crypto";
4 |
5 | const cachePath = getObservabilityCachePath("device.json");
6 |
7 | type Cached = {
8 | [id: string]: string;
9 | };
10 |
11 | function getDeviceId(): string {
12 | const cached = readFromCache(cachePath) ?? {};
13 | let deviceId = cached["id"];
14 |
15 | if (!deviceId) {
16 | deviceId = randomUUID();
17 | cached["id"] = deviceId;
18 | writeToCache(cachePath, cached);
19 | }
20 |
21 | return deviceId;
22 | }
23 |
24 | export { getDeviceId };
25 |
--------------------------------------------------------------------------------
/packages/shared/src/getObservabilityCachePath.ts:
--------------------------------------------------------------------------------
1 | import { getReplayPath } from "./getReplayPath";
2 |
3 | export function getObservabilityCachePath(...path: string[]) {
4 | return getReplayPath("observability-profile", ...path);
5 | }
6 |
--------------------------------------------------------------------------------
/packages/shared/src/getReplayPath.ts:
--------------------------------------------------------------------------------
1 | import { homedir } from "os";
2 | import { join, resolve } from "path";
3 |
4 | export function getReplayPath(...path: string[]) {
5 | let basePath;
6 | if (process.env.RECORD_REPLAY_DIRECTORY) {
7 | basePath = process.env.RECORD_REPLAY_DIRECTORY;
8 | } else {
9 | basePath = join(homedir(), ".replay");
10 | }
11 |
12 | return resolve(join(basePath, ...path));
13 | }
14 |
--------------------------------------------------------------------------------
/packages/shared/src/graphql/GraphQLError.ts:
--------------------------------------------------------------------------------
1 | export class GraphQLError extends Error {
2 | errors: unknown[];
3 |
4 | constructor(message: string, errors: unknown[]) {
5 | super(message);
6 | this.errors = errors;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/shared/src/graphql/cachePath.ts:
--------------------------------------------------------------------------------
1 | import { getObservabilityCachePath } from "../getObservabilityCachePath";
2 |
3 | export const cachePath = getObservabilityCachePath("graphql.json");
4 |
--------------------------------------------------------------------------------
/packages/shared/src/graphql/fetchAuthInfoFromGraphQL.ts:
--------------------------------------------------------------------------------
1 | import { AuthInfo } from "../authentication/types";
2 | import { logDebug } from "../logger";
3 | import { base64Decode } from "../strings/decode";
4 | import { GraphQLError } from "./GraphQLError";
5 | import { queryGraphQL } from "./queryGraphQL";
6 |
7 | export async function fetchAuthInfoFromGraphQL(accessToken: string): Promise {
8 | logDebug("Fetching auth info from GraphQL");
9 |
10 | const { data, errors } = await queryGraphQL(
11 | "AuthInfo",
12 | `
13 | query AuthInfo {
14 | viewer {
15 | user {
16 | id
17 | }
18 | }
19 | auth {
20 | workspaces {
21 | edges {
22 | node {
23 | id
24 | }
25 | }
26 | }
27 | }
28 | }
29 | `,
30 | {},
31 | accessToken
32 | );
33 |
34 | if (errors) {
35 | throw new GraphQLError("Failed to fetch auth info", errors);
36 | }
37 |
38 | const response = data as {
39 | viewer: {
40 | user: {
41 | id: string | null;
42 | } | null;
43 | };
44 | auth: {
45 | workspaces: {
46 | edges: {
47 | node: {
48 | id: string;
49 | };
50 | }[];
51 | };
52 | };
53 | };
54 |
55 | const { viewer, auth } = response;
56 |
57 | const userId = viewer?.user?.id;
58 | const workspaceId = auth?.workspaces?.edges?.[0]?.node?.id;
59 |
60 | if (userId) {
61 | return { id: decodeId(userId), type: "user" };
62 | } else if (workspaceId) {
63 | return { id: decodeId(workspaceId), type: "workspace" };
64 | } else {
65 | throw new Error("Unrecognized type of an API key: Missing both user ID and workspace ID.");
66 | }
67 | }
68 |
69 | function decodeId(base64EncodedId: string) {
70 | const decoded = base64Decode(base64EncodedId); // The expected format is "w:0000-0000-0000" or "u:0000-0000-0000"
71 | const [_, id] = decoded.split(":");
72 |
73 | if (typeof id !== "string") {
74 | throw new Error(`Unrecognized ID format: ${base64EncodedId}`);
75 | }
76 |
77 | return id;
78 | }
79 |
--------------------------------------------------------------------------------
/packages/shared/src/graphql/queryGraphQL.ts:
--------------------------------------------------------------------------------
1 | import { fetch } from "undici";
2 | import { replayApiServer } from "../config";
3 | import { logDebug } from "../logger";
4 | import { getUserAgent } from "../session/getUserAgent";
5 |
6 | export async function queryGraphQL(name: string, query: string, variables = {}, apiKey?: string) {
7 | const userAgent = await getUserAgent();
8 |
9 | const options = {
10 | method: "POST",
11 | headers: {
12 | "Content-Type": "application/json",
13 | "User-Agent": userAgent,
14 | } as Record,
15 | body: JSON.stringify({
16 | query,
17 | name,
18 | variables,
19 | }),
20 | };
21 |
22 | if (apiKey) {
23 | options.headers.Authorization = `Bearer ${apiKey.trim()}`;
24 | }
25 |
26 | logDebug("Querying graphql endpoint", { name, replayApiServer });
27 | const result = await fetch(`${replayApiServer}/v1/graphql`, options);
28 |
29 | const json: any = await result.json();
30 | logDebug("GraphQL Response", { json });
31 |
32 | return json;
33 | }
34 |
--------------------------------------------------------------------------------
/packages/shared/src/graphql/updateCachedAuthInfo.ts:
--------------------------------------------------------------------------------
1 | import { readFromCache, writeToCache } from "../cache";
2 | import { cachePath } from "./cachePath";
3 | import { Cached } from "../authentication/getAuthInfo";
4 | import { AuthInfo } from "../authentication/types";
5 |
6 | export function updateCachedAuthInfo(accessToken: string, authInfo: AuthInfo | undefined) {
7 | const cached = readFromCache(cachePath) ?? {};
8 | const newCached = {
9 | ...cached,
10 | };
11 |
12 | if (authInfo) {
13 | newCached[accessToken] = authInfo;
14 | } else {
15 | delete newCached[accessToken];
16 | }
17 |
18 | writeToCache(cachePath, newCached);
19 | }
20 |
--------------------------------------------------------------------------------
/packages/shared/src/hashValue.ts:
--------------------------------------------------------------------------------
1 | import { createHash } from "crypto";
2 |
3 | export function hashValue(value: string) {
4 | const hash = createHash("sha256");
5 | hash.write(value);
6 | return hash.digest("hex").toString();
7 | }
8 |
--------------------------------------------------------------------------------
/packages/shared/src/launchDarklylient.ts:
--------------------------------------------------------------------------------
1 | import {
2 | initialize as initializeLDClient,
3 | LDClient,
4 | LDContext,
5 | LDSingleKindContext,
6 | } from "launchdarkly-node-client-sdk";
7 | import { createDeferred } from "./async/createDeferred";
8 | import { getReplayPath } from "./getReplayPath";
9 | import { createTaskQueue } from "./session/createTaskQueue";
10 |
11 | let clientDeferred = createDeferred();
12 |
13 | const taskQueue = createTaskQueue({
14 | onDestroy: async () => {
15 | const client = clientDeferred.resolution;
16 | if (client) {
17 | await client.close();
18 | }
19 | },
20 | onInitialize: ({ authInfo }) => {
21 | let context: LDContext = {
22 | anonymous: true,
23 | };
24 | if (authInfo) {
25 | context = {
26 | anonymous: false,
27 | key: authInfo.id,
28 | kind: authInfo.type,
29 | } satisfies LDSingleKindContext;
30 | }
31 |
32 | const client = initializeLDClient("60ca05fb43d6f10d234bb3cf", context, {
33 | localStoragePath: getReplayPath("launchdarkly-user-cache"),
34 | logger: {
35 | debug() {},
36 | error() {},
37 | info() {},
38 | warn() {},
39 | },
40 | });
41 |
42 | clientDeferred.resolve(client);
43 | },
44 | });
45 |
46 | export async function close() {
47 | taskQueue.flushAndClose();
48 | }
49 |
50 | export async function getFeatureFlagValue(flag: string, defaultValue: Type) {
51 | await clientDeferred.promise;
52 |
53 | const client = clientDeferred.resolution;
54 | if (client) {
55 | await client.waitForInitialization();
56 |
57 | const value = await client.variation(flag, defaultValue);
58 |
59 | return value as Type;
60 | }
61 |
62 | return defaultValue;
63 | }
64 |
--------------------------------------------------------------------------------
/packages/shared/src/logUpdate.ts:
--------------------------------------------------------------------------------
1 | import logUpdateExternal, { LogUpdate } from "log-update";
2 | import { disableAnimatedLog } from "./config";
3 |
4 | function logUpdateDebugging(...text: string[]) {
5 | console.log(...text);
6 | }
7 | logUpdateDebugging.clear = (() => {}) satisfies LogUpdate["clear"];
8 | logUpdateDebugging.done = (() => {}) satisfies LogUpdate["done"];
9 |
10 | // log-update interferes with verbose DEBUG output
11 | export const logUpdate = disableAnimatedLog ? (logUpdateDebugging as LogUpdate) : logUpdateExternal;
12 |
--------------------------------------------------------------------------------
/packages/shared/src/maskString.test.ts:
--------------------------------------------------------------------------------
1 | import { maskString } from "./maskString";
2 |
3 | describe("maskString", () => {
4 | it("should filter alpha-numeric characters", () => {
5 | expect(maskString("abc-ABC-123")).toBe("***-***-***");
6 | });
7 | });
8 |
--------------------------------------------------------------------------------
/packages/shared/src/maskString.ts:
--------------------------------------------------------------------------------
1 | export function maskString(value: string): string {
2 | return value.replace(/[a-zA-Z0-9]/g, "*");
3 | }
4 |
--------------------------------------------------------------------------------
/packages/shared/src/printTable.ts:
--------------------------------------------------------------------------------
1 | import { table } from "table";
2 | import { emphasize } from "./theme";
3 |
4 | export function printTable({
5 | headers,
6 | rows,
7 | }: {
8 | headers?: string[] | undefined;
9 | rows: unknown[][];
10 | }) {
11 | const data = headers ? [headers.map(text => emphasize(text)), ...rows] : rows;
12 |
13 | return table(data, {
14 | drawHorizontalLine: () => false,
15 | drawVerticalLine: () => false,
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/packages/shared/src/process/exitProcess.ts:
--------------------------------------------------------------------------------
1 | import { waitForExitTasks } from "./waitForExitTasks";
2 |
3 | export async function exitProcess(code?: number): Promise {
4 | await waitForExitTasks();
5 |
6 | process.exit(code);
7 | }
8 |
--------------------------------------------------------------------------------
/packages/shared/src/process/exitTasks.ts:
--------------------------------------------------------------------------------
1 | import { close as closeLaunchDarklyClient } from "../launchDarklylient";
2 | import { flushLog } from "../logger";
3 | import { closeMixpanel } from "../mixpanelClient";
4 | import { ExitTask } from "./types";
5 |
6 | export const exitTasks: ExitTask[] = [closeLaunchDarklyClient, closeMixpanel, flushLog];
7 |
--------------------------------------------------------------------------------
/packages/shared/src/process/killProcess.ts:
--------------------------------------------------------------------------------
1 | import findProcess from "find-process";
2 | import { kill } from "process";
3 | import { createDeferred } from "../async/createDeferred";
4 | import { timeoutAfter } from "../async/timeoutAfter";
5 |
6 | export async function killProcess(
7 | pid: number,
8 | signal?: string | number | undefined,
9 | options: { retryIntervalMs?: number; timeoutMs?: number } = {}
10 | ): Promise {
11 | const { retryIntervalMs = 100, timeoutMs = 1_000 } = options;
12 |
13 | const deferred = createDeferred();
14 |
15 | let timeout: NodeJS.Timeout | undefined;
16 |
17 | const tryToKill = async () => {
18 | timeout = undefined;
19 |
20 | const process = await findProcess("pid", pid);
21 | if (process.length === 0) {
22 | deferred.resolve(true);
23 | } else {
24 | kill(pid, signal);
25 |
26 | timeout = setTimeout(tryToKill, retryIntervalMs);
27 | }
28 | };
29 |
30 | tryToKill();
31 |
32 | return Promise.race([
33 | deferred.promise.then(() => {
34 | clearTimeout(timeout);
35 |
36 | return true;
37 | }),
38 | timeoutAfter(timeoutMs).then(() => false),
39 | ]);
40 | }
41 |
--------------------------------------------------------------------------------
/packages/shared/src/process/registerExitTask.ts:
--------------------------------------------------------------------------------
1 | import { exitTasks } from "./exitTasks";
2 | import { ExitTask } from "./types";
3 |
4 | export function registerExitTask(exitTask: ExitTask) {
5 | exitTasks.push(exitTask);
6 | }
7 |
--------------------------------------------------------------------------------
/packages/shared/src/process/types.ts:
--------------------------------------------------------------------------------
1 | export type ExitTask = () => Promise;
2 |
--------------------------------------------------------------------------------
/packages/shared/src/process/waitForExitTasks.ts:
--------------------------------------------------------------------------------
1 | import { exitTasks } from "./exitTasks";
2 |
3 | export async function waitForExitTasks() {
4 | await Promise.all(exitTasks.map(task => task()));
5 | }
6 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/ProtocolError.ts:
--------------------------------------------------------------------------------
1 | type ErrorDataValue = string | number | boolean | null;
2 | type ErrorData = Record;
3 | type ProtocolErrorBase = {
4 | code: number;
5 | message: string;
6 | data: ErrorData;
7 | };
8 |
9 | export const AUTHENTICATION_REQUIRED_ERROR_CODE = 49;
10 |
11 | export class ProtocolError extends Error {
12 | readonly protocolCode: number;
13 | readonly protocolMessage: string;
14 | readonly protocolData: unknown;
15 |
16 | constructor(error: ProtocolErrorBase) {
17 | super(`protocol error ${error.code}: ${error.message}`);
18 |
19 | this.protocolCode = error.code;
20 | this.protocolMessage = error.message;
21 | this.protocolData = error.data ?? {};
22 | }
23 |
24 | toString() {
25 | return `Protocol error ${this.protocolCode}: ${this.protocolMessage}`;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/addOriginalSource.ts:
--------------------------------------------------------------------------------
1 | import { addOriginalSourceParameters, addOriginalSourceResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function addOriginalSource(
5 | client: ProtocolClient,
6 | params: addOriginalSourceParameters
7 | ) {
8 | await client.waitUntilAuthenticated();
9 |
10 | return await client.sendCommand({
11 | method: "Recording.addOriginalSource",
12 | params,
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/addSourceMap.ts:
--------------------------------------------------------------------------------
1 | import { addSourceMapParameters, addSourceMapResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function addSourceMap(client: ProtocolClient, params: addSourceMapParameters) {
5 | await client.waitUntilAuthenticated();
6 |
7 | return await client.sendCommand({
8 | method: "Recording.addSourceMap",
9 | params,
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/beginRecordingMultipartUpload.ts:
--------------------------------------------------------------------------------
1 | import {
2 | beginRecordingMultipartUploadParameters,
3 | beginRecordingMultipartUploadResult,
4 | } from "@replayio/protocol";
5 | import ProtocolClient from "../ProtocolClient";
6 |
7 | export async function beginRecordingMultipartUpload(
8 | client: ProtocolClient,
9 | {
10 | maxChunkSize,
11 | ...params
12 | }: Omit & {
13 | maxChunkSize?: number;
14 | }
15 | ) {
16 | await client.waitUntilAuthenticated();
17 |
18 | return await client.sendCommand<
19 | beginRecordingMultipartUploadParameters,
20 | beginRecordingMultipartUploadResult
21 | >({
22 | method: "Internal.beginRecordingMultipartUpload",
23 | params: {
24 | ...params,
25 | chunkSize: maxChunkSize,
26 | },
27 | });
28 | }
29 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/beginRecordingUpload.ts:
--------------------------------------------------------------------------------
1 | import { beginRecordingUploadParameters, beginRecordingUploadResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function beginRecordingUpload(
5 | client: ProtocolClient,
6 | params: beginRecordingUploadParameters
7 | ) {
8 | await client.waitUntilAuthenticated();
9 |
10 | return await client.sendCommand({
11 | method: "Internal.beginRecordingUpload",
12 | params,
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/checkIfResourceExists.ts:
--------------------------------------------------------------------------------
1 | import { existsParameters, existsResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function checkIfResourceExists(client: ProtocolClient, params: existsParameters) {
5 | await client.waitUntilAuthenticated();
6 |
7 | return await client.sendCommand({
8 | method: "Resource.exists",
9 | params,
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/createResource.ts:
--------------------------------------------------------------------------------
1 | import { createParameters, createResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function createResource(client: ProtocolClient, params: createParameters) {
5 | await client.waitUntilAuthenticated();
6 |
7 | return await client.sendCommand({
8 | method: "Resource.create",
9 | params,
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/createSession.ts:
--------------------------------------------------------------------------------
1 | import { createSessionParameters, createSessionResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function createSession(client: ProtocolClient, params: createSessionParameters) {
5 | await client.waitUntilAuthenticated();
6 |
7 | return await client.sendCommand({
8 | method: "Recording.createSession",
9 | params,
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/endRecordingMultipartUpload.ts:
--------------------------------------------------------------------------------
1 | import {
2 | endRecordingMultipartUploadParameters,
3 | endRecordingMultipartUploadResult,
4 | } from "@replayio/protocol";
5 | import ProtocolClient from "../ProtocolClient";
6 |
7 | export async function endRecordingMultipartUpload(
8 | client: ProtocolClient,
9 | params: endRecordingMultipartUploadParameters
10 | ) {
11 | await client.waitUntilAuthenticated();
12 |
13 | return await client.sendCommand<
14 | endRecordingMultipartUploadParameters,
15 | endRecordingMultipartUploadResult
16 | >({
17 | method: "Internal.endRecordingMultipartUpload",
18 | params,
19 | });
20 | }
21 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/endRecordingUpload.ts:
--------------------------------------------------------------------------------
1 | import { endRecordingUploadParameters, endRecordingUploadResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function endRecordingUpload(
5 | client: ProtocolClient,
6 | params: endRecordingUploadParameters
7 | ) {
8 | await client.waitUntilAuthenticated();
9 |
10 | return await client.sendCommand({
11 | method: "Internal.endRecordingUpload",
12 | params,
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/ensureProcessed.ts:
--------------------------------------------------------------------------------
1 | import { ensureProcessedParameters, ensureProcessedResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function ensureProcessed(client: ProtocolClient, sessionId: string) {
5 | await client.waitUntilAuthenticated();
6 |
7 | return await client.sendCommand({
8 | method: "Session.ensureProcessed",
9 | params: {},
10 | sessionId,
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/getResourceToken.ts:
--------------------------------------------------------------------------------
1 | import { tokenParameters, tokenResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function getResourceToken(client: ProtocolClient, params: tokenParameters) {
5 | await client.waitUntilAuthenticated();
6 |
7 | return await client.sendCommand({
8 | method: "Resource.token",
9 | params,
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/processRecording.ts:
--------------------------------------------------------------------------------
1 | import { processRecordingParameters, processRecordingResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function processRecording(client: ProtocolClient, params: processRecordingParameters) {
5 | await client.waitUntilAuthenticated();
6 |
7 | return await client.sendCommand({
8 | method: "Recording.processRecording",
9 | params,
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/releaseSession.ts:
--------------------------------------------------------------------------------
1 | import { releaseSessionParameters, releaseSessionResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function releaseSession(client: ProtocolClient, params: releaseSessionParameters) {
5 | await client.waitUntilAuthenticated();
6 |
7 | return await client.sendCommand({
8 | method: "Recording.releaseSession",
9 | params,
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/reportCrash.ts:
--------------------------------------------------------------------------------
1 | import { reportCrashParameters, reportCrashResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function reportCrash(client: ProtocolClient, params: reportCrashParameters) {
5 | await client.waitUntilAuthenticated();
6 |
7 | return await client.sendCommand({
8 | method: "Internal.reportCrash",
9 | params,
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/setAccessToken.ts:
--------------------------------------------------------------------------------
1 | import { setAccessTokenParameters, setAccessTokenResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function setAccessToken(client: ProtocolClient, params: setAccessTokenParameters) {
5 | return await client.sendCommand({
6 | method: "Authentication.setAccessToken",
7 | params,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/api/setRecordingMetadata.ts:
--------------------------------------------------------------------------------
1 | import { setRecordingMetadataParameters, setRecordingMetadataResult } from "@replayio/protocol";
2 | import ProtocolClient from "../ProtocolClient";
3 |
4 | export async function setRecordingMetadata(
5 | client: ProtocolClient,
6 | params: setRecordingMetadataParameters
7 | ) {
8 | await client.waitUntilAuthenticated();
9 |
10 | return await client.sendCommand({
11 | method: "Internal.setRecordingMetadata",
12 | params,
13 | });
14 | }
15 |
--------------------------------------------------------------------------------
/packages/shared/src/protocol/types.ts:
--------------------------------------------------------------------------------
1 | import { WebSocket } from "ws";
2 |
3 | export type Callbacks = {
4 | onOpen: (socket: WebSocket) => void;
5 | onClose: (socket: WebSocket) => void;
6 | onError: (socket: WebSocket) => void;
7 | };
8 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/canUpload.ts:
--------------------------------------------------------------------------------
1 | import { LocalRecording } from "./types";
2 |
3 | export function canUpload(recording: LocalRecording) {
4 | return (
5 | recording.path &&
6 | recording.uploadStatus === undefined &&
7 | (recording.recordingStatus === "crashed" || recording.recordingStatus === "finished")
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/config.ts:
--------------------------------------------------------------------------------
1 | import { getReplayPath } from "../getReplayPath";
2 |
3 | export const multiPartChunkSize = process.env.REPLAY_MULTIPART_UPLOAD_CHUNK
4 | ? parseInt(process.env.REPLAY_MULTIPART_UPLOAD_CHUNK, 10)
5 | : undefined;
6 | export const multiPartMinSizeThreshold = 5 * 1024 * 1024;
7 |
8 | export const recordingLogPath = getReplayPath("recordings.log");
9 |
10 | export const recordingsPath = getReplayPath();
11 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/createSettledDeferred.ts:
--------------------------------------------------------------------------------
1 | import { createDeferred } from "../async/createDeferred";
2 | import { logDebug } from "../logger";
3 |
4 | export function createSettledDeferred(data: Data, task: () => Promise) {
5 | const deferred = createDeferred(data);
6 |
7 | task().then(
8 | () => {
9 | deferred.resolve(true);
10 | },
11 | error => {
12 | logDebug("Deferred action failed", { data, error });
13 |
14 | deferred.resolve(false);
15 | }
16 | );
17 |
18 | return deferred;
19 | }
20 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/findRecordingsWithShortIds.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import { LocalRecording } from "./types";
3 |
4 | export function findRecordingsWithShortIds(
5 | recordings: LocalRecording[],
6 | shortIds: string[]
7 | ): LocalRecording[] {
8 | // TODO [PRO-*] Log a warning for any ids that couldn't be found?
9 |
10 | return shortIds.map(shortId => {
11 | const recording = recordings.find(recording => recording.id.startsWith(shortId));
12 | assert(recording, `Recording with ID "${shortId}" not found`);
13 | return recording;
14 | });
15 | }
16 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/metadata/addMetadata.ts:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 | import { appendFileSync } from "node:fs";
3 | import { UnstructuredMetadata } from "../types";
4 | import { getReplayPath } from "../../getReplayPath";
5 |
6 | /**
7 | * Adds unstructured metadata to the local recordings database.
8 | *
9 | * New metadata will be merged with existing data. If the same key is used by
10 | * multiple entries, the most recent entry's value will be used.
11 | *
12 | * Metadata is not validated until the recording is uploaded so arbitrary keys
13 | * may be used here to manage recordings before upload.
14 | *
15 | * @param recordingId UUID of the recording
16 | * @param metadata Recording metadata
17 | */
18 | export function addMetadata(recordingId: string, metadata: UnstructuredMetadata) {
19 | const entry = {
20 | id: recordingId,
21 | kind: "addMetadata",
22 | metadata,
23 | timestamp: Date.now(),
24 | };
25 |
26 | appendFileSync(path.join(getReplayPath(), "recordings.log"), `\n${JSON.stringify(entry)}\n`);
27 | }
28 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/metadata/legacy/README.md:
--------------------------------------------------------------------------------
1 | The contents of this module (`utils/recordings/metadata/legacy`) were copied from the legacy [`replay/src/metadata`](https://github.com/replayio/replay-cli/tree/main/packages/replay/src/metadata) module with minimal modification.
2 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/metadata/legacy/env.ts:
--------------------------------------------------------------------------------
1 | import { defaulted, string } from "superstruct";
2 |
3 | type Resolver = string | ((env: NodeJS.ProcessEnv) => string | undefined);
4 |
5 | const firstEnvValueOf =
6 | (...envKeys: Resolver[]) =>
7 | () =>
8 | envKeys.reduce(
9 | (a, k) => a || (typeof k === "function" ? k(process.env) : process.env[k]),
10 | undefined
11 | );
12 |
13 | const envString = (...envKeys: Resolver[]) => defaulted(string(), firstEnvValueOf(...envKeys));
14 |
15 | export { firstEnvValueOf, envString };
16 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/metadata/legacy/source.test.ts:
--------------------------------------------------------------------------------
1 | import { init } from "./source";
2 |
3 | describe("source", () => {
4 | describe("init", () => {
5 | describe("buildkite", () => {
6 | it("omits merge.id when BUILDKITE_PULL_REQUEST is false", async () => {
7 | process.env.BUILDKITE_COMMIT = "abc";
8 | process.env.BUILDKITE_PULL_REQUEST = "false";
9 |
10 | const source = await init();
11 | expect(source).not.toHaveProperty("source.merge.id");
12 | });
13 |
14 | it("includes merge.id when BUILDKITE_PULL_REQUEST is valued", async () => {
15 | process.env.BUILDKITE_COMMIT = "abc";
16 | process.env.BUILDKITE_PULL_REQUEST = "123";
17 |
18 | const source = await init();
19 | expect(source).toHaveProperty("source.merge.id", "123");
20 | });
21 | });
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/metadata/legacy/test/index.ts:
--------------------------------------------------------------------------------
1 | import { Struct, any, create } from "superstruct";
2 | import { UnstructuredMetadata } from "../../../types";
3 | import v1, { TestMetadataV1 } from "./v1";
4 | import v2, { TestMetadataV2 } from "./v2";
5 |
6 | const VERSION = "2.1.0";
7 |
8 | export type { TestMetadataV1, TestMetadataV2 };
9 | export type UserActionEvent = TestMetadataV1.UserActionEvent | TestMetadataV2.UserActionEvent;
10 | export type Test = TestMetadataV1.Test | TestMetadataV2.Test;
11 | export type TestResult = TestMetadataV1.TestResult | TestMetadataV2.TestResult;
12 | export type TestRun = TestMetadataV1.TestRun | TestMetadataV2.TestRun;
13 | export type TestError = TestMetadataV1.TestError | TestMetadataV2.TestError;
14 |
15 | const versions = {
16 | ...v1,
17 | ...v2,
18 | };
19 |
20 | export function validate(test?: UnstructuredMetadata) {
21 | if (!test) {
22 | throw new Error("Test metadata does not exist");
23 | }
24 |
25 | return init(test);
26 | }
27 |
28 | type Metadata = (typeof versions)[keyof typeof versions];
29 |
30 | function getVersion(k: string): Struct {
31 | const v: Struct | undefined = (versions as any)[k];
32 | if (!v) {
33 | console.warn(`Unable to validate unknown version of test metadata:${k} `);
34 | return any();
35 | }
36 |
37 | return v;
38 | }
39 |
40 | export function init(data: Metadata | UnstructuredMetadata = {}) {
41 | let version = VERSION;
42 |
43 | if ("version" in data && typeof data.version === "number") {
44 | // explicitly adapt the pre-semver scheme
45 | version = "1.0.0";
46 | } else if ("schemaVersion" in data && typeof data.schemaVersion === "string") {
47 | version = data.schemaVersion;
48 | }
49 |
50 | let schema: Struct;
51 | try {
52 | schema = getVersion(version);
53 | } catch {
54 | console.warn(
55 | `Unable to validate unknown version of test metadata: ${version || "Unspecified"}`
56 | );
57 |
58 | return {
59 | test: data,
60 | };
61 | }
62 |
63 | try {
64 | return {
65 | test: create(data, schema),
66 | };
67 | } catch (e) {
68 | console.error(e);
69 | console.error("Metadata:");
70 | console.error(JSON.stringify(data, undefined, 2));
71 |
72 | return {};
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/metadata/sanitizeMetadata.ts:
--------------------------------------------------------------------------------
1 | import { logDebug, logInfo } from "../../logger";
2 | import { UnstructuredMetadata } from "../types";
3 | import { validate as validateSource } from "./legacy/source";
4 | import { validate as validateTest } from "./legacy/test";
5 |
6 | type Options = {
7 | verbose?: boolean;
8 | };
9 |
10 | export async function sanitizeMetadata(metadata: UnstructuredMetadata, opts: Options = {}) {
11 | const updated: UnstructuredMetadata = {};
12 | for (const [key, value] of Object.entries(metadata)) {
13 | if (typeof value !== "object") {
14 | if (opts.verbose) {
15 | console.log(
16 | `Ignoring metadata key "${key}". Expected an object but received ${typeof value}`
17 | );
18 | }
19 | logInfo("SanitizeMetadata:UnexpectedKeyType", { key, keyType: typeof value });
20 | continue;
21 | }
22 |
23 | if (value === null || key.startsWith("x-")) {
24 | updated[key] = value;
25 | } else {
26 | switch (key) {
27 | case "source": {
28 | try {
29 | const validated = await validateSource(value as UnstructuredMetadata | undefined);
30 | Object.assign(updated, validated);
31 | } catch (error) {
32 | logDebug("Source validation failed", { error });
33 | }
34 | break;
35 | }
36 | case "test": {
37 | try {
38 | const validated = await validateTest(value as UnstructuredMetadata | undefined);
39 | Object.assign(updated, validated);
40 | } catch (error) {
41 | logDebug("Test validation failed", { error });
42 | }
43 | break;
44 | }
45 | default: {
46 | if (opts.verbose) {
47 | console.log(
48 | `Ignoring metadata key "${key}". Custom metadata blocks must be prefixed by "x-". Try "x-${key}" instead.`
49 | );
50 | }
51 | logInfo("SanitizeMetadata:IgnoringKey", { key });
52 | }
53 | }
54 | }
55 | }
56 |
57 | return updated;
58 | }
59 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/printRecordings.ts:
--------------------------------------------------------------------------------
1 | import { printTable } from "../printTable";
2 | import { formatRecording } from "./formatRecording";
3 | import { LocalRecording } from "./types";
4 |
5 | export function printRecordings(
6 | recordings: LocalRecording[],
7 | formattingOptions: {
8 | showHeaderRow?: boolean;
9 | } = {}
10 | ) {
11 | const { showHeaderRow = true } = formattingOptions;
12 |
13 | let text = printTable({
14 | headers: showHeaderRow ? ["ID", "Title", "Process", "Date", "Duration", "Status"] : undefined,
15 | rows: recordings.map(recording => {
16 | const { date, duration, id, processType, status, title } = formatRecording(recording);
17 |
18 | return [id, title, processType, date, duration, status];
19 | }),
20 | });
21 |
22 | return text;
23 | }
24 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/readRecordingLog.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from "fs-extra";
2 | import { insert } from "../array";
3 | import { logDebug } from "../logger";
4 | import { recordingLogPath } from "./config";
5 | import { LogEntry, RECORDING_LOG_KIND } from "./types";
6 |
7 | const RECORDING_LOG_KINDS = [
8 | RECORDING_LOG_KIND.createRecording,
9 | RECORDING_LOG_KIND.addMetadata,
10 | RECORDING_LOG_KIND.writeStarted,
11 | RECORDING_LOG_KIND.sourcemapAdded,
12 | RECORDING_LOG_KIND.originalSourceAdded,
13 | RECORDING_LOG_KIND.writeFinished,
14 | RECORDING_LOG_KIND.uploadStarted,
15 | RECORDING_LOG_KIND.uploadFinished,
16 | RECORDING_LOG_KIND.uploadFailed,
17 | RECORDING_LOG_KIND.recordingUnusable,
18 | RECORDING_LOG_KIND.crashed,
19 | RECORDING_LOG_KIND.crashData,
20 | RECORDING_LOG_KIND.crashUploaded,
21 | RECORDING_LOG_KIND.processingStarted,
22 | RECORDING_LOG_KIND.processingFinished,
23 | RECORDING_LOG_KIND.processingFailed,
24 | ];
25 |
26 | export function readRecordingLog() {
27 | const logEntries: LogEntry[] = [];
28 |
29 | const processLine = (line: string) => {
30 | line = line.trim();
31 | if (!line) {
32 | return;
33 | }
34 |
35 | const logEntry = JSON.parse(line) as LogEntry;
36 |
37 | insert(
38 | logEntries,
39 | logEntry,
40 | (a, b) => RECORDING_LOG_KINDS.indexOf(a.kind) - RECORDING_LOG_KINDS.indexOf(b.kind)
41 | );
42 | };
43 |
44 | const rawText = readFileSync(recordingLogPath, "utf8");
45 | rawText.split(/[\n\r]+/).forEach(line => {
46 | try {
47 | processLine(line);
48 | } catch {
49 | logDebug("Error parsing line", { line });
50 |
51 | // Early versions of `replayio` could remove the trailing \n from recordings.log,
52 | // so the next entry would be appended to the last line, creating a line with two entries.
53 | // This workaround lets us read these corrupted entries but it should be removed eventually.
54 | const splitLines = line.replace(/\}\{/g, "}\n{");
55 | if (splitLines.length === line.length) {
56 | return;
57 | }
58 |
59 | return splitLines.split(/[\n\r]+/).map(line => {
60 | try {
61 | processLine(line);
62 | } catch (error) {
63 | logDebug("Error parsing split line", { line });
64 | }
65 | });
66 | }
67 | });
68 |
69 | return logEntries;
70 | }
71 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/updateRecordingLog.ts:
--------------------------------------------------------------------------------
1 | import { appendFileSync } from "fs-extra";
2 | import { logDebug } from "../logger";
3 | import { recordingLogPath } from "./config";
4 | import { LocalRecording, LogEntry } from "./types";
5 |
6 | export function updateRecordingLog(
7 | recording: LocalRecording,
8 | partialEntry: Omit
9 | ) {
10 | logDebug(`Updating recording log ${recordingLogPath}`);
11 | logDebug(`Appending entry for recording ${recording.id}`, { partialEntry, recording });
12 |
13 | const entry: LogEntry = {
14 | ...partialEntry,
15 | id: recording.id,
16 | recordingId: recording.id,
17 | timestamp: Date.now(),
18 | };
19 |
20 | appendFileSync(recordingLogPath, `\n${JSON.stringify(entry)}\n`, {
21 | encoding: "utf8",
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/upload/types.ts:
--------------------------------------------------------------------------------
1 | export type ProcessingBehavior =
2 | | "do-not-process"
3 | | "start-processing"
4 | | "wait-for-processing-to-finish";
5 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/upload/uploadCrashData.ts:
--------------------------------------------------------------------------------
1 | import { replayWsServer } from "../../config";
2 | import { logError, logInfo } from "../../logger";
3 | import ProtocolClient from "../../protocol/ProtocolClient";
4 | import { reportCrash } from "../../protocol/api/reportCrash";
5 | import { LocalRecording, RECORDING_LOG_KIND } from "../types";
6 | import { updateRecordingLog } from "../updateRecordingLog";
7 |
8 | export async function uploadCrashedData(client: ProtocolClient, recording: LocalRecording) {
9 | logInfo("UploadCrashedData:Started", { recordingId: recording.id });
10 |
11 | const crashData = recording.crashData?.slice() ?? [];
12 | crashData.push({
13 | kind: "recordingMetadata",
14 | recordingId: recording.id,
15 | });
16 |
17 | try {
18 | await Promise.all(crashData.map(async data => reportCrash(client, { data })));
19 |
20 | updateRecordingLog(recording, {
21 | kind: RECORDING_LOG_KIND.crashUploaded,
22 | server: replayWsServer,
23 | });
24 |
25 | logInfo("UploadCrashedData:Succeeded", { recording: recording.id });
26 | recording.uploadStatus = "uploaded";
27 | } catch (error) {
28 | logError("UploadCrashedData:Failed", {
29 | error,
30 | recordingId: recording.id,
31 | buildId: recording.buildId,
32 | });
33 | recording.uploadStatus = "failed";
34 | recording.uploadError = error as Error;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/packages/shared/src/recording/upload/validateRecordingMetadata.ts:
--------------------------------------------------------------------------------
1 | import { RecordingData } from "@replayio/protocol";
2 | import { sanitizeMetadata } from "../metadata/sanitizeMetadata";
3 | import { LocalRecording } from "../types";
4 |
5 | export async function validateRecordingMetadata(recording: LocalRecording): Promise<{
6 | metadata: Object;
7 | recordingData: RecordingData;
8 | }> {
9 | const {
10 | duration,
11 | id,
12 | metadata: { host, uri, ...rest },
13 | } = recording;
14 |
15 | const metadata = await sanitizeMetadata(rest);
16 |
17 | return {
18 | metadata,
19 | recordingData: {
20 | duration: duration ?? 0,
21 | id,
22 | url: uri ?? "",
23 | title: host ?? "",
24 | // This info is only set for Gecko recordings
25 | // github.com/replayio/replay-cli/commit/6d9b8b95a3a55eb9a0aa0721199242cfaf319356#r140608348
26 | operations: {
27 | scriptDomains: [],
28 | },
29 | lastScreenData: "",
30 | lastScreenMimeType: "",
31 | },
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/packages/shared/src/runtime/getLatestRuntimeRelease.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert/strict";
2 | import { fetch } from "undici";
3 | import { replayAppHost } from "../config";
4 | import { logDebug } from "../logger";
5 | import { runtimeMetadata } from "./config";
6 | import { Release } from "./types";
7 |
8 | const { architecture, platform, runtime } = runtimeMetadata;
9 |
10 | export async function getLatestRuntimeRelease() {
11 | logDebug("Fetching release metadata");
12 |
13 | const response = await fetch(`${replayAppHost}/api/releases`);
14 | const json = (await response.json()) as Release[];
15 | const latestRelease = json.find(
16 | release =>
17 | release.platform === platform &&
18 | release.runtime === runtime &&
19 | (release.architecture === architecture || release.architecture === "unknown")
20 | );
21 |
22 | logDebug("Latest release", { latestRelease });
23 | assert(latestRelease, `No release found for ${platform}:${runtime}`);
24 |
25 | return latestRelease;
26 | }
27 |
--------------------------------------------------------------------------------
/packages/shared/src/runtime/getRuntimePath.ts:
--------------------------------------------------------------------------------
1 | import { getReplayPath } from "../getReplayPath";
2 | import { logDebug } from "../logger";
3 | import { runtimeMetadata } from "./config";
4 |
5 | export function getRuntimePath() {
6 | const overridePathKey = `REPLAY_CHROMIUM_EXECUTABLE_PATH`;
7 | const overridePath = process.env[overridePathKey];
8 | if (overridePath) {
9 | logDebug(`Using executable override for chromium: ${overridePath}`);
10 | return overridePath;
11 | }
12 |
13 | return getReplayPath("runtimes", ...runtimeMetadata.path);
14 | }
15 |
--------------------------------------------------------------------------------
/packages/shared/src/runtime/parseBuildId.ts:
--------------------------------------------------------------------------------
1 | export function parseBuildId(buildId: string) {
2 | const [os, runtime, releaseDateString] = buildId.split("-");
3 |
4 | const releaseDate = new Date(
5 | parseInt(releaseDateString.slice(0, 4)),
6 | parseInt(releaseDateString.slice(4, 6)) - 1,
7 | parseInt(releaseDateString.slice(6, 8))
8 | );
9 |
10 | return { os, runtime, releaseDate };
11 | }
12 |
--------------------------------------------------------------------------------
/packages/shared/src/runtime/types.ts:
--------------------------------------------------------------------------------
1 | export type Executable = "darwin:chromium" | "linux:chromium" | "win32:chromium";
2 | export type Platform = "macOS" | "linux" | "windows";
3 |
4 | // This CLI only supports Chromium for the time being
5 | export type Runtime = "chromium" | "node";
6 |
7 | export type Architecture = "arm" | "x86_64" | "unknown";
8 |
9 | export type Release = {
10 | architecture: Architecture;
11 | buildFile: string;
12 | buildId: string;
13 | platform: Platform;
14 | releaseFile: string;
15 | runtime: Runtime;
16 | time: string;
17 |
18 | // Gecko releases don't have a version string
19 | version: string | null;
20 | };
21 |
22 | export type MetadataJSON = {
23 | [Key in Runtime]?: {
24 | buildId: string;
25 | forkedVersion: string | null;
26 | installDate: string;
27 | };
28 | };
29 |
--------------------------------------------------------------------------------
/packages/shared/src/session/getUserAgent.ts:
--------------------------------------------------------------------------------
1 | import { waitForPackageInfo } from "./waitForPackageInfo";
2 |
3 | export async function getUserAgent() {
4 | const { packageName, packageVersion } = await waitForPackageInfo();
5 |
6 | return `${packageName}/${packageVersion}`;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/shared/src/session/initializeAuthInfo.ts:
--------------------------------------------------------------------------------
1 | import { STATUS_RESOLVED } from "../async/createDeferred";
2 | import { getAuthInfo } from "../authentication/getAuthInfo";
3 | import { AuthInfo } from "../authentication/types";
4 | import { logError } from "../logger";
5 | import { deferredAuthInfo } from "./waitForAuthInfo";
6 |
7 | export async function initializeAuthInfo({ accessToken }: { accessToken: string | undefined }) {
8 | if (deferredAuthInfo.status !== STATUS_RESOLVED) {
9 | let authInfo: AuthInfo | undefined = undefined;
10 | if (accessToken) {
11 | try {
12 | authInfo = await getAuthInfo(accessToken);
13 | } catch (error) {
14 | logError("InitializeSession:AuthInfoQueryFailed", { error });
15 | }
16 | }
17 |
18 | deferredAuthInfo.resolve(authInfo);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/shared/src/session/initializePackageInfo.ts:
--------------------------------------------------------------------------------
1 | import { STATUS_RESOLVED } from "../async/createDeferred";
2 | import { deferredPackageInfo } from "./waitForPackageInfo";
3 |
4 | export async function initializePackageInfo({
5 | packageName,
6 | packageVersion,
7 | }: {
8 | packageName: string;
9 | packageVersion: string;
10 | }) {
11 | if (deferredPackageInfo.status !== STATUS_RESOLVED) {
12 | deferredPackageInfo.resolve({ packageName, packageVersion });
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/shared/src/session/initializeSession.ts:
--------------------------------------------------------------------------------
1 | import { initializeAuthInfo } from "./initializeAuthInfo";
2 | import { initializePackageInfo } from "./initializePackageInfo";
3 |
4 | export async function initializeSession({
5 | accessToken,
6 | packageName,
7 | packageVersion,
8 | }: {
9 | accessToken: string | undefined;
10 | packageName: string;
11 | packageVersion: string;
12 | }) {
13 | await initializePackageInfo({ packageName, packageVersion });
14 | await initializeAuthInfo({ accessToken });
15 | }
16 |
--------------------------------------------------------------------------------
/packages/shared/src/session/types.ts:
--------------------------------------------------------------------------------
1 | export type PackageInfo = {
2 | packageName: string;
3 | packageVersion: string;
4 | };
5 |
--------------------------------------------------------------------------------
/packages/shared/src/session/waitForAuthInfo.ts:
--------------------------------------------------------------------------------
1 | import { createDeferred } from "../async/createDeferred";
2 | import { AuthInfo } from "../authentication/types";
3 |
4 | export const deferredAuthInfo = createDeferred();
5 |
6 | export async function waitForAuthInfo() {
7 | return await deferredAuthInfo.promise;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/shared/src/session/waitForPackageInfo.ts:
--------------------------------------------------------------------------------
1 | import { createDeferred } from "../async/createDeferred";
2 | import { PackageInfo } from "./types";
3 |
4 | export const deferredPackageInfo = createDeferred();
5 |
6 | export async function waitForPackageInfo() {
7 | return await deferredPackageInfo.promise;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/shared/src/spawnProcess.ts:
--------------------------------------------------------------------------------
1 | import { ChildProcess, spawn, SpawnOptions } from "child_process";
2 | import { createDeferred, Deferred } from "./async/createDeferred";
3 | import { ProcessError } from "./ProcessError";
4 |
5 | export function spawnProcess(
6 | executablePath: string,
7 | args: string[] = [],
8 | options: SpawnOptions = {},
9 | {
10 | onSpawn,
11 | printStderr,
12 | printStdout,
13 | }: {
14 | onSpawn?: () => void;
15 | printStderr?: (text: string) => void;
16 | printStdout?: (text: string) => void;
17 | } = {}
18 | ): Deferred {
19 | const spawned = spawn(executablePath, args, {
20 | stdio: "inherit",
21 | ...options,
22 | env: {
23 | ...process.env,
24 | ...options.env,
25 | },
26 | });
27 |
28 | const deferred = createDeferred(spawned);
29 |
30 | if (options?.detached) {
31 | // TODO [PRO-*] Properly handle detached processes
32 | // github.com/replayio/replay-cli/pull/344#discussion_r1553258356
33 | spawned.unref();
34 | } else {
35 | spawned.on("error", error => {
36 | deferred.rejectIfPending(error);
37 | });
38 |
39 | spawned.on("spawn", () => {
40 | onSpawn?.();
41 | });
42 |
43 | let stderr = "";
44 | spawned.stderr?.setEncoding("utf8");
45 | spawned.stderr?.on("data", (data: string) => {
46 | stderr += data;
47 | printStderr?.(data);
48 | });
49 |
50 | if (printStdout) {
51 | spawned.stdout?.setEncoding("utf8");
52 | spawned.stdout?.on("data", printStdout);
53 | }
54 |
55 | spawned.on("exit", (code, signal) => {
56 | if (code || signal) {
57 | // Don't fail on manual closing
58 | if (signal === "SIGTERM") {
59 | deferred.resolveIfPending();
60 | return;
61 | }
62 | const message = `Process failed (${code ? `code: ${code}` : `signal: ${signal}`})`;
63 |
64 | deferred.rejectIfPending(new ProcessError(message, stderr));
65 | } else {
66 | deferred.resolveIfPending();
67 | }
68 | });
69 | }
70 |
71 | return deferred;
72 | }
73 |
--------------------------------------------------------------------------------
/packages/shared/src/strings/decode.ts:
--------------------------------------------------------------------------------
1 | export function base64Decode(string: string) {
2 | return Buffer.from(string, "base64").toString();
3 | }
4 |
--------------------------------------------------------------------------------
/packages/shared/src/theme.ts:
--------------------------------------------------------------------------------
1 | import chalk from "chalk";
2 |
3 | export const dim = chalk.gray;
4 | export const emphasize = chalk.bold;
5 | export const highlight = chalk.yellowBright;
6 | export const highlightAlternate = chalk.blueBright;
7 | export const link = chalk.blueBright;
8 | export const select = chalk.bold;
9 | export const statusPending = chalk.yellowBright;
10 | export const statusFailed = chalk.redBright;
11 | export const statusSuccess = chalk.greenBright;
12 | export const stderrPrefix = chalk.bgRedBright;
13 | export const stdoutPrefix = chalk.bgWhite.black;
14 | export const transparent = chalk.dim;
15 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | "target": "es2022",
5 | "lib": ["es2023"]
6 | },
7 | "include": ["src/**/*.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload-webpack-plugin/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "env": {
4 | "es6": true,
5 | "node": true
6 | },
7 | "extends": [
8 | "plugin:prettier/recommended",
9 | "eslint:recommended",
10 | "plugin:@typescript-eslint/recommended"
11 | ],
12 | "parserOptions": {
13 | "ecmaVersion": 2020,
14 | "sourceType": "module"
15 | },
16 | "plugins": ["prettier", "@typescript-eslint"],
17 | "rules": {}
18 | }
19 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload-webpack-plugin/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /lib/
--------------------------------------------------------------------------------
/packages/sourcemap-upload-webpack-plugin/.prettierrc:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload-webpack-plugin/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @replayio/sourcemap-upload-webpack-plugin
2 |
3 | ## 2.0.3
4 |
5 | ### Patch Changes
6 |
7 | - [#549](https://github.com/replayio/replay-cli/pull/549) [`ac7aa52`](https://github.com/replayio/replay-cli/commit/ac7aa52) Thanks [@Andarist](https://github.com/Andarist)! - Fixed the generated `.js` output to correctly reference `glob` package
8 |
9 | - Updated dependencies [[`e7bd234`](https://github.com/replayio/replay-cli/commit/e7bd234980e9dfc7ab9584d47ebaf1812712f291)]:
10 | - @replayio/sourcemap-upload@2.0.6
11 |
12 | ## 2.0.2
13 |
14 | ### Patch Changes
15 |
16 | - [#556](https://github.com/replayio/replay-cli/pull/556) [`89c5082`](https://github.com/replayio/replay-cli/commit/89c5082a06265255ffdc8b4f1e87dcb1d3d9c2d2) Thanks [@markerikson](https://github.com/markerikson)! - Updated glob version to fix nested dependency deprecation warning
17 |
18 | - Updated dependencies [[`89c5082`](https://github.com/replayio/replay-cli/commit/89c5082a06265255ffdc8b4f1e87dcb1d3d9c2d2)]:
19 | - @replayio/sourcemap-upload@2.0.5
20 |
21 | ## 2.0.1
22 |
23 | ### Patch Changes
24 |
25 | - [#518](https://github.com/replayio/replay-cli/pull/518) [`75d475a`](https://github.com/replayio/replay-cli/commit/75d475ad5aed0c331cfc3b36bdcd8e7822b58c39) Thanks [@markerikson](https://github.com/markerikson)! - Add support for deleting sourcemaps after they are uploaded, and correlating sourcemaps by filename + `.map` extension if no `sourceMappingURL` exists in the generated file.
26 |
27 | - Updated dependencies [[`75d475a`](https://github.com/replayio/replay-cli/commit/75d475ad5aed0c331cfc3b36bdcd8e7822b58c39)]:
28 | - @replayio/sourcemap-upload@2.0.4
29 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload-webpack-plugin/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2022 Record Replay Inc.
2 |
3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4 |
5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6 |
7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8 |
9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10 |
11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
12 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload-webpack-plugin/index.js:
--------------------------------------------------------------------------------
1 | /* Copyright 2020 Record Replay Inc. */
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-var-requires
4 | const ReplaySourcemapUploadWebpackPluginOptions = require("./dist/index.js");
5 |
6 | // To make life easier for people not using Babel/TS ESM interop and who
7 | // don't want to have to do `.default` on the required value, wrap the
8 | // default export to make it work for both types.
9 | module.exports =
10 | ReplaySourcemapUploadWebpackPluginOptions.default.bind(undefined);
11 | Object.defineProperties(
12 | module.exports,
13 | Object.getOwnPropertyDescriptors(ReplaySourcemapUploadWebpackPluginOptions)
14 | );
15 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload-webpack-plugin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replayio/sourcemap-upload-webpack-plugin",
3 | "version": "2.0.3",
4 | "description": "A webpack plugin to run Replay's sourcemap-upload logic when Webpack compilation finishes.",
5 | "main": "./index.js",
6 | "exports": {
7 | ".": "./index.js",
8 | "./package.json": "./package.json"
9 | },
10 | "types": "./dist/index.d.ts",
11 | "engines": {
12 | "node": ">=10.13"
13 | },
14 | "scripts": {
15 | "build": "pkg-build",
16 | "lint": "eslint",
17 | "lint:fix": "eslint --fix",
18 | "prepare": "yarn run build",
19 | "test": "echo \"Error: no test specified\"",
20 | "typecheck": "tsc --noEmit"
21 | },
22 | "files": [
23 | "dist",
24 | "*.js",
25 | "*.d.ts"
26 | ],
27 | "repository": {
28 | "type": "git",
29 | "url": "git+https://github.com/replayio/replay-cli.git"
30 | },
31 | "author": "Logan Smyth ",
32 | "license": "BSD-3-Clause",
33 | "bugs": {
34 | "url": "https://github.com/replayio/replay-cli/issues"
35 | },
36 | "homepage": "https://github.com/replayio/replay-cli/blob/main/packages/sourcemap-upload-webpack-plugin/README.md",
37 | "devDependencies": {
38 | "@replay-cli/pkg-build": "workspace:^",
39 | "@replay-cli/tsconfig": "workspace:^",
40 | "@types/debug": "^4.1.5",
41 | "@types/glob": "^7.1.3",
42 | "@types/node": "^20.11.27",
43 | "@types/node-fetch": "^2.5.10",
44 | "@typescript-eslint/eslint-plugin": "^4.22.0",
45 | "@typescript-eslint/parser": "^4.22.0",
46 | "eslint": "^7.25.0",
47 | "eslint-config-prettier": "^8.3.0",
48 | "eslint-plugin-prettier": "^3.4.0",
49 | "prettier": "^2.2.1",
50 | "turbo": "^2.0.5",
51 | "typescript": "^5.5.2",
52 | "webpack": "^5.90.3"
53 | },
54 | "dependencies": {
55 | "@replayio/sourcemap-upload": "workspace:^"
56 | },
57 | "peerDependencies": {
58 | "webpack": "^4.0.0 || ^5.76.0"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload-webpack-plugin/src/index.ts:
--------------------------------------------------------------------------------
1 | /* Copyright 2020 Record Replay Inc. */
2 |
3 | import type { Compiler } from "webpack";
4 | import {
5 | uploadSourceMaps,
6 | UploadOptions,
7 | LogCallback,
8 | } from "@replayio/sourcemap-upload";
9 | import assert from "node:assert/strict";
10 |
11 | export interface PluginOptions extends UploadOptions {
12 | logLevel?: "quiet" | "normal" | "verbose";
13 | warnOnFailure?: boolean;
14 | }
15 |
16 | export default class ReplaySourceMapUploadWebpackPlugin {
17 | options: UploadOptions;
18 | warnOnFailure: boolean;
19 |
20 | constructor(opts: PluginOptions) {
21 | assert(opts, "ReplaySourceMapUploadWebpackPlugin requires options");
22 | const { logLevel = "normal", warnOnFailure, ...restOpts } = opts;
23 | assert(
24 | typeof warnOnFailure === "boolean" ||
25 | typeof warnOnFailure === "undefined",
26 | "ReplaySourceMapUploadWebpackPlugin's 'warnOnFailure' must be a boolean or undefined."
27 | );
28 |
29 | let log: LogCallback | undefined;
30 | if (logLevel === "normal") {
31 | log = (level, message) => {
32 | if (level === "normal") {
33 | console.log(message);
34 | }
35 | };
36 | } else if (logLevel === "verbose") {
37 | log = (level, message) => {
38 | console.log(message);
39 | };
40 | }
41 |
42 | this.warnOnFailure = !!warnOnFailure;
43 | this.options = {
44 | ...restOpts,
45 | log,
46 | userAgentAddition: getNameAndVersion(),
47 | };
48 | }
49 |
50 | async afterAssetEmit(): Promise {
51 | await uploadSourceMaps(this.options);
52 | }
53 |
54 | apply(compiler: Compiler): void {
55 | const logger =
56 | compiler.getInfrastructureLogger?.(
57 | "ReplaySourceMapUploadWebpackPlugin"
58 | ) || console;
59 | compiler.hooks.afterEmit.tapPromise("ReplayWebpackPlugin", async () => {
60 | try {
61 | await this.afterAssetEmit();
62 | } catch (err) {
63 | if (!this.warnOnFailure) {
64 | throw err;
65 | }
66 |
67 | logger.warn("ReplaySourceMapUploadWebpackPlugin upload failure", err);
68 | }
69 | });
70 | }
71 | }
72 |
73 | function getNameAndVersion() {
74 | const pkg = require("@replayio/sourcemap-upload-webpack-plugin/package.json");
75 | return `${pkg.name}/${pkg.version}`;
76 | }
77 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload-webpack-plugin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | // some clients use 10.x everywhere to be consistent with it.
5 | "target": "es2018",
6 | "lib": ["es2018", "dom"]
7 | },
8 | "include": ["src/**/*.ts"],
9 | "references": [
10 | {
11 | "path": "../sourcemap-upload"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "env": {
4 | "es6": true,
5 | "node": true
6 | },
7 | "extends": [
8 | "plugin:prettier/recommended",
9 | "eslint:recommended",
10 | "plugin:@typescript-eslint/recommended"
11 | ],
12 | "parserOptions": {
13 | "ecmaVersion": 2020,
14 | "sourceType": "module"
15 | },
16 | "plugins": ["prettier", "@typescript-eslint"],
17 | "rules": {}
18 | }
19 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /lib/
--------------------------------------------------------------------------------
/packages/sourcemap-upload/.npmignore:
--------------------------------------------------------------------------------
1 | /src/
--------------------------------------------------------------------------------
/packages/sourcemap-upload/.prettierrc:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @replayio/sourcemap-upload
2 |
3 | ## 2.0.6
4 |
5 | ### Patch Changes
6 |
7 | - [#549](https://github.com/replayio/replay-cli/pull/549) [`ac7aa52`](https://github.com/replayio/replay-cli/commit/ac7aa52) Thanks [@Andarist](https://github.com/Andarist)! - Fixed the generated `.js` output to correctly reference `glob` package
8 |
9 | ## 2.0.5
10 |
11 | ### Patch Changes
12 |
13 | - [#556](https://github.com/replayio/replay-cli/pull/556) [`89c5082`](https://github.com/replayio/replay-cli/commit/89c5082a06265255ffdc8b4f1e87dcb1d3d9c2d2) Thanks [@markerikson](https://github.com/markerikson)! - Updated glob version to fix nested dependency deprecation warning
14 |
15 | ## 2.0.4
16 |
17 | ### Patch Changes
18 |
19 | - [#518](https://github.com/replayio/replay-cli/pull/518) [`75d475a`](https://github.com/replayio/replay-cli/commit/75d475ad5aed0c331cfc3b36bdcd8e7822b58c39) Thanks [@markerikson](https://github.com/markerikson)! - Add support for deleting sourcemaps after they are uploaded, and correlating sourcemaps by filename + `.map` extension if no `sourceMappingURL` exists in the generated file.
20 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2022 Record Replay Inc.
2 |
3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4 |
5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6 |
7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8 |
9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10 |
11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
12 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replayio/sourcemap-upload",
3 | "version": "2.0.6",
4 | "description": "A utility library for uploading sourcemaps to Replay for use while replaying recordings.",
5 | "engines": {
6 | "node": ">=10.13"
7 | },
8 | "scripts": {
9 | "build": "pkg-build",
10 | "lint": "eslint",
11 | "lint:fix": "eslint --fix",
12 | "prepare": "yarn run build",
13 | "test": "echo \"Error: no test specified\"",
14 | "typecheck": "tsc --noEmit"
15 | },
16 | "files": [
17 | "dist",
18 | "*.js",
19 | "*.d.ts"
20 | ],
21 | "main": "./dist/index.js",
22 | "exports": {
23 | ".": "./dist/index.js",
24 | "./package.json": "./package.json"
25 | },
26 | "repository": {
27 | "type": "git",
28 | "url": "git+https://github.com/replayio/replay-cli.git"
29 | },
30 | "author": "Logan Smyth ",
31 | "license": "BSD-3-Clause",
32 | "bugs": {
33 | "url": "https://github.com/replayio/replay-cli/issues"
34 | },
35 | "homepage": "https://github.com/replayio/replay-cli/blob/main/packages/sourcemap-upload/README.md",
36 | "devDependencies": {
37 | "@replay-cli/pkg-build": "workspace:^",
38 | "@replay-cli/tsconfig": "workspace:^",
39 | "@types/debug": "^4.1.5",
40 | "@types/glob": "^7.1.3",
41 | "@types/node": "^20.11.27",
42 | "@types/node-fetch": "^2.5.10",
43 | "@types/string.prototype.matchall": "^4.0.0",
44 | "@typescript-eslint/eslint-plugin": "^4.22.0",
45 | "@typescript-eslint/parser": "^4.22.0",
46 | "eslint": "^7.25.0",
47 | "eslint-config-prettier": "^8.3.0",
48 | "eslint-plugin-prettier": "^3.4.0",
49 | "prettier": "^2.2.1",
50 | "turbo": "^2.0.5",
51 | "typescript": "^5.5.2"
52 | },
53 | "dependencies": {
54 | "chalk": "^4.1.2",
55 | "commander": "^7.2.0",
56 | "debug": "^4.3.1",
57 | "glob": "^10",
58 | "node-fetch": "^2.6.1",
59 | "p-map": "^4.0.0",
60 | "string.prototype.matchall": "^4.0.5"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/packages/sourcemap-upload/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | // Target Node > 10 since that is still in use in Google Firestore and
5 | // some clients use 10.x everywhere to be consistent with it.
6 | "target": "es2018",
7 | "lib": ["es2018", "dom"]
8 | },
9 | "include": ["src/**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/test-utils/README.md:
--------------------------------------------------------------------------------
1 | # @replayio/test-utils
2 |
3 | Shared capabilities for integrating replay.io with testing systems
4 |
5 | ## Installation
6 |
7 | `npm i @replayio/test-utils`
8 |
--------------------------------------------------------------------------------
/packages/test-utils/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replayio/test-utils",
3 | "private": true,
4 | "version": "0.0.0",
5 | "description": "Utilities for recording tests with replay.io",
6 | "main": "./dist/index.js",
7 | "exports": {
8 | ".": "./dist/index.js",
9 | "./testId": "./dist/testId.js",
10 | "./package.json": "./package.json"
11 | },
12 | "engines": {
13 | "node": ">=18"
14 | },
15 | "scripts": {
16 | "prepare": "yarn run build",
17 | "build": "pkg-build",
18 | "test": "echo \"Error: no test specified\"",
19 | "typecheck": "tsc --noEmit"
20 | },
21 | "repository": {
22 | "type": "git",
23 | "url": "git+https://github.com/replayio/replay-cli.git"
24 | },
25 | "author": "",
26 | "license": "BSD-3-Clause",
27 | "bugs": {
28 | "url": "https://github.com/replayio/replay-cli/issues"
29 | },
30 | "homepage": "https://github.com/replayio/replay-cli#readme",
31 | "dependencies": {
32 | "debug": "^4.3.4",
33 | "fs-extra": "^11.2.0",
34 | "jsonata": "^1.8.6",
35 | "launchdarkly-node-client-sdk": "^3.2.1",
36 | "mixpanel": "^0.18.0",
37 | "node-fetch": "^2.6.7",
38 | "p-map": "^4.0.0",
39 | "query-registry": "^2.6.0",
40 | "semver": "^7.5.4",
41 | "sha-1": "^1.0.0",
42 | "stack-utils": "^2.0.6",
43 | "superstruct": "^1.0.4",
44 | "undici": "^5.28.4",
45 | "uuid": "^8.3.2",
46 | "winston": "^3.13.0",
47 | "winston-loki": "^6.1.2",
48 | "ws": "^7.5.0"
49 | },
50 | "devDependencies": {
51 | "@replay-cli/pkg-build": "workspace:^",
52 | "@replay-cli/shared": "workspace:^",
53 | "@replay-cli/tsconfig": "workspace:^",
54 | "@types/debug": "^4.1.7",
55 | "@types/node-fetch": "^2.6.2",
56 | "@types/stack-utils": "^2.0.3",
57 | "turbo": "^2.0.5",
58 | "typescript": "^5.5.2"
59 | },
60 | "@replay-cli/pkg-build": {
61 | "entrypoints": [
62 | "./src/index.ts",
63 | "./src/testId.ts"
64 | ]
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/packages/test-utils/src/getAccessToken.ts:
--------------------------------------------------------------------------------
1 | import { logDebug } from "@replay-cli/shared/logger";
2 | import { ReplayReporterConfig } from "./types";
3 |
4 | export function getAccessToken(config?: ReplayReporterConfig) {
5 | if (config?.apiKey) {
6 | logDebug("Using token from reporter config (config.apiKey)");
7 | return config.apiKey;
8 | } else if (process.env.REPLAY_API_KEY) {
9 | logDebug("Using token from env (REPLAY_API_KEY)");
10 | return process.env.REPLAY_API_KEY;
11 | } else if (process.env.RECORD_REPLAY_API_KEY) {
12 | logDebug("Using token from env (RECORD_REPLAY_API_KEY)");
13 | return process.env.RECORD_REPLAY_API_KEY;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/test-utils/src/index.ts:
--------------------------------------------------------------------------------
1 | export type { TestMetadataV1 } from "@replay-cli/shared/recording/metadata/legacy/test/v1";
2 | export type { TestMetadataV2 } from "@replay-cli/shared/recording/metadata/legacy/test/v2";
3 | export { fetchWorkspaceConfig } from "./config";
4 | export { getAccessToken } from "./getAccessToken";
5 | export * from "./logging";
6 | export { getMetadataFilePath, initMetadataFile } from "./metadata";
7 | export { pingTestMetrics } from "./metrics";
8 | export { ReporterError } from "./reporter";
9 | export type { PendingWork } from "./reporter";
10 | export { removeAnsiCodes } from "./terminal";
11 | export { buildTestId } from "./testId";
12 | export type { RecordingEntry, ReplayReporterConfig, UploadOptions } from "./types";
13 | export { ReplayReporter };
14 | import ReplayReporter from "./reporter";
15 |
--------------------------------------------------------------------------------
/packages/test-utils/src/legacy-cli/README.md:
--------------------------------------------------------------------------------
1 | The code in this directory has been forked from `@replayio/replay`
2 |
3 | It should be removed eventually and replaced with code in the `shared` package
4 |
--------------------------------------------------------------------------------
/packages/test-utils/src/legacy-cli/error.ts:
--------------------------------------------------------------------------------
1 | export function getErrorMessage(e: unknown) {
2 | return e && typeof e === "object" && "message" in e ? (e.message as string) : "Unknown Error";
3 | }
4 |
--------------------------------------------------------------------------------
/packages/test-utils/src/legacy-cli/generateDefaultTitle.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 |
3 | export function generateDefaultTitle(metadata: Record) {
4 | let host = metadata.uri;
5 | if (host && typeof host === "string") {
6 | try {
7 | const url = new URL(host);
8 | host = url.host;
9 | } finally {
10 | return `Replay of ${host}`;
11 | }
12 | }
13 |
14 | if (Array.isArray(metadata.argv) && typeof metadata.argv[0] === "string") {
15 | return `Replay of ${path.basename(metadata.argv[0])}`;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/test-utils/src/legacy-cli/utils.ts:
--------------------------------------------------------------------------------
1 | function maybeLogToConsole(verbose: boolean | undefined, str: string) {
2 | if (verbose) {
3 | console.log(str);
4 | }
5 | }
6 |
7 | export { maybeLogToConsole };
8 |
--------------------------------------------------------------------------------
/packages/test-utils/src/logging.ts:
--------------------------------------------------------------------------------
1 | export function log(message: string) {
2 | message.split("\n").forEach(m => console.log("[replay.io]:", m));
3 | }
4 |
5 | export function warn(message: string, e?: unknown) {
6 | console.warn("[replay.io]:", message);
7 | if (e && e instanceof Error) {
8 | console.warn("[replay.io]: Error:", e.message);
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/test-utils/src/metadata.ts:
--------------------------------------------------------------------------------
1 | import { getReplayPath } from "@replay-cli/shared/getReplayPath";
2 | import { logError } from "@replay-cli/shared/logger";
3 | import { existsSync, writeFileSync } from "fs";
4 | import { warn } from "./logging";
5 |
6 | export function getMetadataFilePath(base: string, workerIndex = 0) {
7 | return (
8 | process.env.RECORD_REPLAY_METADATA_FILE ||
9 | getReplayPath(`${base.toUpperCase()}_METADATA_${workerIndex}`)
10 | );
11 | }
12 |
13 | export function initMetadataFile(path: string) {
14 | try {
15 | if (!existsSync(path)) {
16 | writeFileSync(path, "{}");
17 | }
18 |
19 | return path;
20 | } catch (error) {
21 | warn(`Failed to initialize metadata file${path ? ` at ${path}` : ""}`, error);
22 | logError("InitMetadataFile:Failed", {
23 | path,
24 | error,
25 | });
26 | }
27 |
28 | return undefined;
29 | }
30 |
--------------------------------------------------------------------------------
/packages/test-utils/src/metrics.ts:
--------------------------------------------------------------------------------
1 | import { logError, logInfo } from "@replay-cli/shared/logger";
2 | import { TestMetadataV2 } from "@replay-cli/shared/recording/metadata/legacy/test/v2";
3 | import fetch from "node-fetch";
4 | import os from "node:os";
5 |
6 | function shouldReportTestMetrics() {
7 | const optOut = process.env.RECORD_REPLAY_TEST_METRICS?.toLowerCase();
8 |
9 | return !optOut || !(optOut === "0" || optOut === "false");
10 | }
11 |
12 | async function pingTestMetrics(
13 | recordingId: string | undefined,
14 | runId: string,
15 | test: {
16 | id: string;
17 | approximateDuration: number;
18 | recorded: boolean;
19 | source: TestMetadataV2.TestRun["source"];
20 | runtime?: string;
21 | runner?: string;
22 | result?: string;
23 | },
24 | apiKey?: string
25 | ) {
26 | logInfo("PingTestMetrics:Started");
27 |
28 | if (!shouldReportTestMetrics()) return;
29 |
30 | const webhookUrl = process.env.RECORD_REPLAY_WEBHOOK_URL || "https://webhooks.replay.io";
31 |
32 | const body = JSON.stringify({
33 | type: "test.finished",
34 | recordingId,
35 | test: {
36 | ...test,
37 | platform: os.platform(),
38 | runId,
39 | env: {
40 | disableAsserts: !!process.env.RECORD_REPLAY_DISABLE_ASSERTS,
41 | disableSourcemapCollection: !!process.env.RECORD_REPLAY_DISABLE_SOURCEMAP_COLLECTION,
42 | disableFeatures: process.env.RECORD_REPLAY_DISABLE_FEATURES || "none",
43 | },
44 | },
45 | });
46 |
47 | logInfo("PingTestMetrics", { body });
48 |
49 | const headers: Record = { "Content-Type": "application/json" };
50 |
51 | if (apiKey) {
52 | headers.Authorization = `Bearer ${apiKey}`;
53 | }
54 |
55 | fetch(`${webhookUrl}/api/metrics`, {
56 | method: "POST",
57 | headers,
58 | body,
59 | })
60 | .then(() => logInfo("PingTestMetrics:Succeeded"))
61 | .catch(error => logError("PingTestMetrics:Failed", { body, error }));
62 | }
63 |
64 | export { pingTestMetrics };
65 |
--------------------------------------------------------------------------------
/packages/test-utils/src/sha-1.d.ts:
--------------------------------------------------------------------------------
1 | declare module "sha-1";
2 |
--------------------------------------------------------------------------------
/packages/test-utils/src/terminal.ts:
--------------------------------------------------------------------------------
1 | export const removeAnsiCodes = (message?: string) =>
2 | message?.replace(
3 | /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
4 | ""
5 | );
6 |
--------------------------------------------------------------------------------
/packages/test-utils/src/testId.ts:
--------------------------------------------------------------------------------
1 | import type { Test } from "./reporter";
2 | import sha1 from "sha-1";
3 |
4 | export function buildTestId(sourcePath: string, test: Pick): string {
5 | const id = generateOpaqueId(
6 | [sourcePath, test.id, ...test.source.scope, test.source.title].join("-")
7 | );
8 |
9 | return id;
10 | }
11 |
12 | const gIdCache = new Map();
13 |
14 | export function generateOpaqueId(contents: string): string {
15 | if (gIdCache.has(contents)) {
16 | return gIdCache.get(contents)!;
17 | }
18 |
19 | let id: string;
20 | if ("window" in globalThis) {
21 | // in the browser, we're using this sync sha-1 lib because the built-in
22 | // crypto is async which causes problems with Cypress
23 | id = sha1(contents);
24 | } else {
25 | // In node, rely on the crypto package instead
26 | id = require("crypto").createHash("sha1").update(contents).digest("hex");
27 | }
28 |
29 | gIdCache.set(contents, id);
30 | return id;
31 | }
32 |
--------------------------------------------------------------------------------
/packages/test-utils/src/types.ts:
--------------------------------------------------------------------------------
1 | import { SourceMap, UnstructuredMetadata } from "@replay-cli/shared/recording/types";
2 |
3 | // TODO [PRO-720] Delete this type.
4 | export type RecordingEntry = {
5 | id: string;
6 | createTime: Date;
7 | runtime: string;
8 | metadata: TMetadata;
9 | sourcemaps: SourceMap[];
10 | buildId?: string;
11 | status:
12 | | "onDisk"
13 | | "unknown"
14 | | "uploaded"
15 | | "crashed"
16 | | "startedWrite"
17 | | "startedUpload"
18 | | "crashUploaded"
19 | | "unusable";
20 | path?: string;
21 | server?: string;
22 | recordingId?: string;
23 | crashData?: any[];
24 | unusableReason?: string;
25 | };
26 |
27 | export type UploadStatusThreshold = "all" | "failed-and-flaky" | "failed";
28 |
29 | export type UploadOptions = {
30 | /**
31 | * Minimize the number of recordings uploaded for a test attempt (within a shard).
32 | * e.g. Only one recording would be uploaded for a failing test attempt, regardless of retries.
33 | * e.g. Two recordings would be uploaded for a flaky test attempt (the passing test and one of the failures).
34 | */
35 | minimizeUploads?: boolean;
36 | /**
37 | * Only upload recordings that meet the specified status threshold.
38 | * e.g. "all" (default) will upload all recordings
39 | * e.g. "failed-and-flaky" will only upload recordings for failed or flaky tests
40 | * e.g. "failed" will only upload recordings for failed tests
41 | */
42 | statusThreshold?: UploadStatusThreshold;
43 | };
44 |
45 | export interface ReplayReporterConfig<
46 | TRecordingMetadata extends UnstructuredMetadata = UnstructuredMetadata
47 | > {
48 | runTitle?: string;
49 | metadata?: Record | string;
50 | metadataKey?: string;
51 | upload?: UploadOptions | boolean;
52 | apiKey?: string;
53 | /** @deprecated Use `upload.minimizeUploads` and `upload.statusThreshold` instead */
54 | filter?: (r: RecordingEntry) => boolean;
55 | }
56 |
--------------------------------------------------------------------------------
/packages/test-utils/testId.d.ts:
--------------------------------------------------------------------------------
1 | export * from "./dist/testId.js";
2 |
--------------------------------------------------------------------------------
/packages/test-utils/testId.js:
--------------------------------------------------------------------------------
1 | module.exports = require("./dist/testId.js");
2 |
--------------------------------------------------------------------------------
/packages/test-utils/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | "resolveJsonModule": true,
5 | "target": "es2022",
6 | "lib": ["es2023"]
7 | },
8 | "include": ["src/**/*.ts"],
9 | "references": [
10 | {
11 | "path": "../shared"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/scripts/pkg-build/bin.js:
--------------------------------------------------------------------------------
1 | require("tsx/cjs");
2 | require("./src/bin.ts");
3 |
--------------------------------------------------------------------------------
/scripts/pkg-build/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replay-cli/pkg-build",
3 | "private": true,
4 | "version": "0.0.0",
5 | "bin": "./bin.js",
6 | "dependencies": {
7 | "@manypkg/get-packages": "^2.2.1",
8 | "@rollup/plugin-json": "^6.1.0",
9 | "@rollup/plugin-node-resolve": "^15.2.3",
10 | "@typescript/vfs": "^1.5.3",
11 | "builtin-modules": "^3.3.0",
12 | "chalk": "^4.1.2",
13 | "esbuild": "^0.21.5",
14 | "fast-glob": "^3.3.2",
15 | "normalize-path": "^3.0.0",
16 | "rollup": "^4.18.0",
17 | "tsx": "^4.15.7"
18 | },
19 | "devDependencies": {
20 | "@replay-cli/tsconfig": "workspace:^",
21 | "@types/normalize-path": "^3.0.2",
22 | "typescript": "^5.5.2"
23 | },
24 | "scripts": {
25 | "typecheck": "tsc --noEmit"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/scripts/pkg-build/src/makePackagePredicate.ts:
--------------------------------------------------------------------------------
1 | export type PackagePredicate = (id: string) => boolean;
2 |
3 | export function makePackagePredicate(names: string[]): PackagePredicate {
4 | if (names.length === 0) {
5 | return () => false;
6 | }
7 | // this makes sure nested imports of external packages are external
8 | const pattern = new RegExp(`^(${names.join("|")})($|/)`);
9 | return (id: string) => pattern.test(id);
10 | }
11 |
--------------------------------------------------------------------------------
/scripts/pkg-build/src/plugins/esbuild.ts:
--------------------------------------------------------------------------------
1 | import { transform } from "esbuild";
2 | import { Plugin } from "rollup";
3 |
4 | export function esbuild(): Plugin {
5 | return {
6 | name: "esbuild",
7 | async transform(code, id) {
8 | if (!/\.(mts|cts|ts|tsx)$/.test(id)) {
9 | return null;
10 | }
11 | const result = await transform(code, {
12 | loader: "ts",
13 | });
14 | return result.code;
15 | },
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/scripts/pkg-build/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@replay-cli/tsconfig/base.json",
3 | "compilerOptions": {
4 | "target": "es2022",
5 | "lib": ["es2023"],
6 | "noEmit": true
7 | },
8 | "include": ["src/**/*.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/scripts/tsconfig/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "incremental": true,
5 | "target": "ES6",
6 | "module": "Node16",
7 | "moduleResolution": "Node16",
8 | "declaration": true,
9 | "declarationMap": true,
10 | "sourceMap": false,
11 | "isolatedModules": true,
12 | "strict": true,
13 | "esModuleInterop": true,
14 | "skipLibCheck": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "useDefineForClassFields": true,
17 | "rootDir": "${configDir}/src",
18 | "outDir": "${configDir}/dist"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/scripts/tsconfig/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@replay-cli/tsconfig",
3 | "private": true,
4 | "version": "0.0.0"
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./packages/cypress" },
5 | { "path": "./packages/jest" },
6 | { "path": "./packages/playwright" },
7 | { "path": "./packages/puppeteer" },
8 | { "path": "./packages/replayio" },
9 | { "path": "./packages/shared" },
10 | { "path": "./packages/sourcemap-upload-webpack-plugin" },
11 | { "path": "./packages/sourcemap-upload" },
12 | { "path": "./packages/test-utils" }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "tasks": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "inputs": ["$TURBO_DEFAULT$", "src/**", "tsconfig.json"],
7 | "outputs": ["dist/**"]
8 | },
9 | "typecheck": {
10 | "dependsOn": ["^build"],
11 | "inputs": ["$TURBO_DEFAULT$", "src/**", "tsconfig.json"],
12 | "outputs": [".tsbuildinfo"]
13 | }
14 | },
15 | "globalDependencies": ["tsconfig.json"]
16 | }
17 |
--------------------------------------------------------------------------------