├── .commitlintrc.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ ├── quality.yml │ ├── release.yml │ ├── user-flow-gh-integration.yml │ └── user-flow-md-report-test.yml ├── .gitignore ├── .husky └── commit-msg ├── .prettierignore ├── .prettierrc ├── .verdaccio └── config.yml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── babel.config.json ├── code-pushup.config.ts ├── e2e └── cli-e2e │ ├── .eslintrc.json │ ├── mocks │ └── user-flows │ │ └── basic-navigation.uf.mts │ ├── project.json │ ├── setup.e2e.ts │ ├── tests │ ├── collect │ │ ├── config.e2e.test.ts │ │ ├── dry-run.e2e.test.ts │ │ ├── format.e2e.test.ts │ │ └── ufPath.e2e.test.ts │ └── init │ │ ├── init.e2e.test.ts │ │ └── rc-config.e2e.test.ts │ ├── tsconfig.json │ ├── tsconfig.test.json │ ├── utils │ └── setup.ts │ └── vite.config.e2e.mts ├── examples └── github-report │ ├── .user-flowrc.json │ ├── README.md │ ├── assets │ └── github-report-comment.png │ ├── measures │ └── .gitkeep │ ├── tools │ └── md-report-rename.mts │ ├── tsconfig.json │ └── user-flows │ └── order-coffee.uf.mts ├── global-setup.e2e.ts ├── jest.config.ts ├── jest.preset.js ├── nx.json ├── package-lock.json ├── package.json ├── packages ├── cli │ ├── .eslintrc.json │ ├── CHANGELOG.md │ ├── docs │ │ ├── command-collect.md │ │ ├── command-init.md │ │ ├── general-cli-features.md │ │ ├── github-workflow-integration.md │ │ ├── images │ │ │ ├── 164581870-3534f8b0-b7c1-4252-9f44-f07febaa7359.png │ │ │ ├── budgets-json-validation.PNG │ │ │ ├── budgets-lh.PNG │ │ │ ├── budgets-mode-support.PNG │ │ │ ├── budgets-timing-metrics.PNG │ │ │ ├── chrome-recorder-export-json.png │ │ │ ├── chrome-recorder-start-new.png │ │ │ ├── getting-started-resulting-navigation-report.PNG │ │ │ ├── img-budgets-mode-support.PNG │ │ │ ├── img-lhr-budgets-explainer-network.PNG │ │ │ ├── img-lhr-budgets-explainer-timing.PNG │ │ │ ├── lhr-budgets-explainer-path.PNG │ │ │ ├── lhr-budgets-explainer-timing.PNG │ │ │ ├── lhr-budgets-explainer.PNG │ │ │ ├── lhr-budgets.PNG │ │ │ ├── lhr-replay-example-results-1.png │ │ │ ├── lhr-replay-example-results-2.png │ │ │ ├── lhr-replay-example-results-3.png │ │ │ ├── lhr-viewer.PNG │ │ │ ├── order-coffee.uf.report.PNG │ │ │ ├── performance-budget--devtools-network-tab.png │ │ │ ├── performance-budget--devtools-performance-tab.png │ │ │ ├── setup-in-existing-project.gif │ │ │ ├── user-flow_navigation-icon.PNG │ │ │ ├── user-flow_snapshot-icon.PNG │ │ │ └── user-flow_timespan-icon.PNG │ │ ├── lh-configuraton.md │ │ ├── old-main-readme.md │ │ ├── raw │ │ │ ├── lh-inc-budget.json │ │ │ ├── lh-no-budget.json │ │ │ ├── order-coffee-lh.json │ │ │ ├── order-coffee.html │ │ │ ├── order-coffee.json │ │ │ ├── order-coffee.md │ │ │ ├── order-coffee.recording.js │ │ │ ├── order-coffee.uf.json │ │ │ ├── uf-inc-budget.json │ │ │ ├── uf-no-budget.html │ │ │ └── uf-no-budget.json │ │ ├── recorder-exports.md │ │ ├── ufo-architecture.md │ │ └── writing-basic-user-flows.md │ ├── package.json │ ├── project.json │ ├── src │ │ ├── cli.mjs │ │ ├── index.ts │ │ └── lib │ │ │ ├── boot-cli.ts │ │ │ ├── commands │ │ │ ├── assert │ │ │ │ └── utils │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── md-report.unit.test.ts.snap │ │ │ │ │ ├── md-report.ts │ │ │ │ │ ├── md-report.unit.test.ts │ │ │ │ │ └── mocks │ │ │ │ │ ├── lhr-8.json │ │ │ │ │ ├── lhr-9-ex-2.json │ │ │ │ │ ├── lhr-9.html │ │ │ │ │ ├── lhr-9.json │ │ │ │ │ ├── lhr-9_reduced-baseline.json │ │ │ │ │ └── lhr-9_reduced.json │ │ │ ├── collect │ │ │ │ ├── command-impl.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── index.ts │ │ │ │ ├── options │ │ │ │ │ ├── awaitServeStdout.ts │ │ │ │ │ ├── config.ts │ │ │ │ │ ├── configPath.constant.ts │ │ │ │ │ ├── configPath.ts │ │ │ │ │ ├── dryRun.ts │ │ │ │ │ ├── format.constant.ts │ │ │ │ │ ├── format.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── openReport.ts │ │ │ │ │ ├── outPath.constant.ts │ │ │ │ │ ├── outPath.ts │ │ │ │ │ ├── serveCommand.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ufPath.constant.ts │ │ │ │ │ ├── ufPath.ts │ │ │ │ │ ├── url.constant.ts │ │ │ │ │ └── url.ts │ │ │ │ ├── processes │ │ │ │ │ └── collect-reports.ts │ │ │ │ └── utils │ │ │ │ │ ├── config │ │ │ │ │ └── index.ts │ │ │ │ │ ├── params.ts │ │ │ │ │ ├── persist │ │ │ │ │ ├── open-report.ts │ │ │ │ │ ├── open-report.unit.test.ts │ │ │ │ │ ├── persist-flow.ts │ │ │ │ │ ├── persist-flow.unit.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── replay │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── parse.test.ts │ │ │ │ │ ├── parse.ts │ │ │ │ │ ├── replay.mocks.ts │ │ │ │ │ ├── runner-extension.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── report │ │ │ │ │ ├── lh-utils.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── serve-command.test.ts │ │ │ │ │ ├── serve-command.ts │ │ │ │ │ └── user-flow │ │ │ │ │ ├── collect-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── load-flow.ts │ │ │ │ │ ├── load-flow.unit.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── user-flow.mock.ts │ │ │ ├── commands.ts │ │ │ └── init │ │ │ │ ├── command-impl.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── index.ts │ │ │ │ ├── options.ts │ │ │ │ ├── processes │ │ │ │ ├── collect-rc-json.ts │ │ │ │ ├── format.setup.ts │ │ │ │ ├── generate-userflow.ts │ │ │ │ ├── generate-userflow.unit.test.ts │ │ │ │ ├── generate-workflow.test.ts │ │ │ │ ├── generate-workflow.ts │ │ │ │ ├── outPath.setup.ts │ │ │ │ ├── ufPath.setup.ts │ │ │ │ ├── update-rc-json.ts │ │ │ │ └── url.setup.ts │ │ │ │ ├── static │ │ │ │ ├── basic-navigation.uf.ts │ │ │ │ └── user-flow-ci.yml │ │ │ │ └── utils.ts │ │ │ ├── config.middleware.ts │ │ │ ├── constants.ts │ │ │ ├── core │ │ │ ├── file │ │ │ │ ├── index.ts │ │ │ │ ├── to-file-name.test.ts │ │ │ │ ├── to-file-name.ts │ │ │ │ └── types.ts │ │ │ ├── loggin │ │ │ │ └── index.ts │ │ │ ├── md │ │ │ │ ├── code.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── details.ts │ │ │ │ ├── font-style.ts │ │ │ │ ├── headline.ts │ │ │ │ └── table.ts │ │ │ ├── prettier │ │ │ │ ├── constants.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── processing │ │ │ │ ├── behaviors.ts │ │ │ │ └── types.ts │ │ │ ├── prompt │ │ │ │ ├── confirm-to-process.ts │ │ │ │ ├── confirm-to-process.unit.test.ts │ │ │ │ └── prompt.ts │ │ │ ├── types.ts │ │ │ ├── validation │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ └── yargs │ │ │ │ ├── index.ts │ │ │ │ ├── instance.ts │ │ │ │ └── types.ts │ │ │ ├── global │ │ │ ├── cli-mode │ │ │ │ ├── cli-mode.ts │ │ │ │ ├── cli.mode.test.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── options │ │ │ │ ├── index.ts │ │ │ │ ├── interactive.model.ts │ │ │ │ ├── interactive.ts │ │ │ │ ├── types.ts │ │ │ │ ├── verbose.model.ts │ │ │ │ └── verbose.ts │ │ │ ├── rc-json │ │ │ │ ├── index.ts │ │ │ │ └── options │ │ │ │ │ ├── rc.model.ts │ │ │ │ │ └── rc.ts │ │ │ └── utils.ts │ │ │ ├── index.ts │ │ │ ├── pre-set.test.ts │ │ │ ├── pre-set.ts │ │ │ ├── types.ts │ │ │ └── ufo │ │ │ ├── index.ts │ │ │ └── ufo.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ └── vite.config.mts ├── nx-plugin-integration │ ├── .babelrc │ ├── .eslintrc.json │ ├── .user-flowrc.json │ ├── project.json │ ├── src │ │ ├── app │ │ │ ├── app.element.css │ │ │ └── app.element.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ └── styles.css │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── user-flows │ │ └── basic-navigation.uf.mts │ └── webpack.config.js ├── nx-plugin │ ├── .eslintrc.json │ ├── CHANGELOG.md │ ├── README.md │ ├── executors.json │ ├── generators.json │ ├── jest.config.ts │ ├── package.json │ ├── project.json │ ├── src │ │ ├── executors │ │ │ └── user-flow │ │ │ │ ├── executor.spec.ts │ │ │ │ ├── executor.ts │ │ │ │ ├── schema.d.ts │ │ │ │ └── schema.json │ │ ├── generators │ │ │ ├── constants.ts │ │ │ ├── init │ │ │ │ ├── generator.spec.ts │ │ │ │ ├── generator.ts │ │ │ │ ├── schema.d.ts │ │ │ │ ├── schema.json │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ └── target │ │ │ │ ├── constants.ts │ │ │ │ ├── generator.spec.ts │ │ │ │ ├── generator.ts │ │ │ │ ├── schema.d.ts │ │ │ │ ├── schema.json │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.spec.json └── user-flow-example │ ├── .eslintrc.json │ ├── .user-flowrc.json │ ├── README.md │ ├── data │ └── checkout.data.ts │ ├── fixtures │ ├── checkout.fixture.ts │ └── coffee.fixture.ts │ ├── measures │ └── order-coffee.uf.html │ ├── project.json │ ├── recordings │ ├── order-coffee-1.replay.json │ ├── order-coffee-2.replay.json │ └── order-coffee-3.replay.json │ ├── replay-examples │ ├── order-coffee-1-replay.uf.ts │ └── order-coffee-2-replay.uf.ts │ ├── tsconfig.json │ ├── ufo │ ├── checkout.form.ts │ ├── coffee.ufo.ts │ └── snack-bar.ts │ └── user-flows │ ├── order-coffee-1.uf.ts │ ├── order-coffee-2.uf.ts │ ├── order-coffee-3.uf.ts │ ├── order-coffee-4.uf.ts │ └── order-coffee-5.uf.ts ├── project.json ├── tools ├── scripts │ ├── publish.mjs │ ├── start-local-registry.ts │ └── stop-local-registry.ts └── tsconfig.tools.json └── tsconfig.base.json /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-angular" 4 | ], 5 | "rules": { 6 | "type-enum": [ 7 | 2, 8 | "always", 9 | [ 10 | "build", 11 | "ci", 12 | "docs", 13 | "feat", 14 | "fix", 15 | "perf", 16 | "refactor", 17 | "revert", 18 | "style", 19 | "test", 20 | "release" 21 | ] 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nx"], 5 | "extends": [ 6 | "@code-pushup/eslint-config/typescript", 7 | "@code-pushup/eslint-config/node", 8 | "@code-pushup/eslint-config/jest", 9 | "@code-pushup/eslint-config/vitest" 10 | ], 11 | "settings": { 12 | "import/resolver": { 13 | "typescript": { 14 | "alwaysTryTypes": true, 15 | "project": "tsconfig.base.json" 16 | } 17 | } 18 | }, 19 | "overrides": [ 20 | { 21 | "files": [ 22 | "*.ts", 23 | "*.tsx", 24 | "*.js", 25 | "*.jsx" 26 | ], 27 | "rules": { 28 | "@nx/enforce-module-boundaries": [ 29 | "error", 30 | { 31 | "enforceBuildableLibDependency": true, 32 | "allow": [], 33 | "depConstraints": [ 34 | { "sourceTag": "*", "onlyDependOnLibsWithTags": ["*"] } 35 | ] 36 | } 37 | ] 38 | } 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [push-based, BioPhoton] 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '*.md' 7 | 8 | jobs: 9 | build_and_test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [20.x] 14 | name: Node ${{ matrix.node }} 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Setup node ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | cache: 'npm' 24 | node-version: ${{ matrix.node-version }} 25 | - name: install 26 | run: npm ci 27 | - name: build 28 | run: npm run nx -- run-many -t build --skip-nx-cache --parallel=false 29 | - name: test 30 | run: npm run nx -- run-many -t test --skip-nx-cache --parallel=false 31 | 32 | # Code PushUp command 33 | - name: Collect and upload Code PushUp report 34 | run: npx code-pushup autorun 35 | env: 36 | CP_API_KEY: ${{ secrets.CP_API_KEY }} 37 | NODE_OPTIONS: --max-old-space-size=8192 38 | - name: Save report files as workflow artifact 39 | uses: actions/upload-artifact@v3 40 | with: 41 | name: code-pushup-report 42 | path: .code-pushup/ 43 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: quality 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '*.md' 9 | 10 | jobs: 11 | quality: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [ 20.x ] 16 | name: Node ${{ matrix.node }} 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup node ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | cache: 'npm' 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - name: Clean Install 30 | run: npm clean-install 31 | 32 | - name: Collect and upload Code PushUp report 33 | run: npx code-pushup autorun 34 | env: 35 | CP_API_KEY: ${{ secrets.CP_API_KEY }} 36 | NODE_OPTIONS: --max-old-space-size=8192 37 | 38 | - name: Save report files as workflow artifact 39 | uses: actions/upload-artifact@v3 40 | with: 41 | name: code-pushup-report 42 | path: .code-pushup/ 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '*.md' 9 | 10 | jobs: 11 | release: 12 | # This line is critical for copy paste issues 13 | if: github.repository == 'push-based/user-flow' 14 | timeout-minutes: 5 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node-version: [20.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: Setup git user to "push-based.io - bot" 25 | shell: bash 26 | run: git config user.email "opensource@push-based.io" && git config user.name "push-based.io - bot" 27 | - name: use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v3 29 | with: 30 | cache: 'npm' 31 | node-version: ${{ matrix.node-version }} 32 | registry-url: https://registry.npmjs.org 33 | - name: install 34 | run: npm ci 35 | - name: build 36 | run: npm run nx -- affected:build 37 | - name: test 38 | run: npm run nx -- affected:test 39 | - name: release CLI 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | run: npm run nx -- run cli:version 44 | - name: release Nx plugin 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 48 | run: npm run nx -- run nx-plugin:version 49 | -------------------------------------------------------------------------------- /.github/workflows/user-flow-gh-integration.yml: -------------------------------------------------------------------------------- 1 | name: user-flow-gh-integration 2 | on: 3 | pull_request: 4 | jobs: 5 | user-flow-integrated-in-ci: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node-version: [20.x] 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: use Node.js ${{ matrix.node-version }} 13 | uses: actions/setup-node@v3 14 | with: 15 | cache: 'npm' 16 | node-version: ${{ matrix.node-version }} 17 | - name: install 18 | run: npm ci 19 | - name: run:user-flow-action 20 | uses: push-based/user-flow-gh-action@v0.3.2 21 | with: 22 | rcPath: examples/github-report/.user-flowrc.json 23 | -------------------------------------------------------------------------------- /.github/workflows/user-flow-md-report-test.yml: -------------------------------------------------------------------------------- 1 | name: user-flow-md-report-test 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '*.md' 7 | 8 | jobs: 9 | build_and_test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [20.x] 14 | name: Node ${{ matrix.node }} 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Setup node ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | cache: 'npm' 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Clean Install 28 | run: npm ci 29 | 30 | - name: Collect Report 31 | run: npm run @push-based/user-flow -- --rcPath ./examples/github-report/.user-flowrc.json --openReport false 32 | 33 | - name: Rename Report 34 | run: npx tsx --tsconfig ./examples/github-report/tsconfig.json ./examples/github-report/tools/md-report-rename.mts 35 | 36 | - name: Add reduced report as comment to the PR 37 | uses: marocchino/sticky-pull-request-comment@v2 38 | with: 39 | hide_and_recreate: true 40 | header: md-report-test 41 | path: ./examples/github-report/measures/md-report.md 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | .idea 3 | 4 | # compiled output 5 | /dist 6 | /tmp 7 | /out-tsc 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | 42 | .nx 43 | 44 | .env 45 | .code-pushup 46 | 47 | examples/**/measures/* 48 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | 6 | /.nx/cache 7 | /.nx/workspace-data -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.verdaccio/config.yml: -------------------------------------------------------------------------------- 1 | # path to a directory with all packages 2 | storage: ../tmp/local-registry/storage 3 | 4 | # a list of other known repositories we can talk to 5 | uplinks: 6 | npmjs: 7 | url: https://registry.npmjs.org/ 8 | maxage: 60m 9 | 10 | packages: 11 | '**': 12 | # give all users (including non-authenticated users) full access 13 | # because it is a local registry 14 | access: $all 15 | publish: $all 16 | unpublish: $all 17 | 18 | # if package is not available locally, proxy requests to npm registry 19 | proxy: npmjs 20 | 21 | # log settings 22 | log: 23 | type: stdout 24 | format: pretty 25 | level: warn 26 | 27 | publish: 28 | allow_offline: true # set offline to true to allow publish offline 29 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint", 6 | "firsttris.vscode-jest-runner" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": ["json"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Push Based 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "babelrcRoots": [ 3 | "*" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /code-pushup.config.ts: -------------------------------------------------------------------------------- 1 | import eslintPlugin, { eslintConfigFromNxProjects } from '@code-pushup/eslint-plugin'; 2 | import type { CoreConfig } from '@code-pushup/models'; 3 | import 'dotenv/config'; 4 | 5 | const config: CoreConfig = { 6 | upload: { 7 | server: 'https://portal-api-r6nh2xm7mq-ez.a.run.app/graphql', 8 | apiKey: process.env.CP_API_KEY, 9 | organization: 'push-based', 10 | project: 'user-flow', 11 | }, 12 | plugins: [ 13 | await eslintPlugin(await eslintConfigFromNxProjects()), 14 | ], 15 | categories: [ 16 | { 17 | slug: 'bug-prevention', 18 | title: 'Bug prevention', 19 | refs: [{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 100 }], 20 | }, 21 | { 22 | slug: 'code-style', 23 | title: 'Code style', 24 | refs: [ 25 | { type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 75 }, 26 | { type: 'group', plugin: 'eslint', slug: 'formatting', weight: 25 }, 27 | ], 28 | }, 29 | ], 30 | }; 31 | 32 | export default config; 33 | -------------------------------------------------------------------------------- /e2e/cli-e2e/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "parserOptions": { 5 | "project": ["packages/cli/tsconfig.*?.json"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /e2e/cli-e2e/mocks/user-flows/basic-navigation.uf.mts: -------------------------------------------------------------------------------- 1 | // @ts-ignore // This is a mock file! 2 | import { UserFlowContext, UserFlowInteractionsFn, UserFlowProvider } from '@push-based/user-flow'; 3 | 4 | const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise => { 5 | const { flow, collectOptions } = ctx; 6 | const { url } = collectOptions; 7 | 8 | await flow.navigate(url, { 9 | name: `Navigate to ${url}`, 10 | }); 11 | 12 | // ℹ Tip: 13 | // Read more about the other measurement modes here: 14 | // https://github.com/push-based/user-flow/blob/main/packages/cli/docs/writing-basic-user-flows.md 15 | 16 | }; 17 | 18 | export default { 19 | flowOptions: {name: 'Basic Navigation Example'}, 20 | interactions 21 | } satisfies UserFlowProvider; 22 | -------------------------------------------------------------------------------- /e2e/cli-e2e/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cli-e2e", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "e2e/cli-e2e/src", 5 | "projectType": "application", 6 | "targets": { 7 | "lint": { 8 | "executor": "@nx/linter:eslint", 9 | "outputs": ["{options.outputFile}"], 10 | "options": { 11 | "lintFilePatterns": ["e2e/cli-e2e/**/*.ts"] 12 | } 13 | }, 14 | "test": { 15 | "executor": "@nx/vite:test", 16 | "options": { 17 | "config": "e2e/cli-e2e/vite.config.e2e.mts" 18 | } 19 | } 20 | }, 21 | "implicitDependencies": [ 22 | "cli" 23 | ], 24 | "tags": ["type:e2e"] 25 | } 26 | -------------------------------------------------------------------------------- /e2e/cli-e2e/tests/collect/config.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect } from 'vitest'; 2 | import { CliTest, DEFAULT_RC, USER_FLOW_MOCKS } from '../../utils/setup'; 3 | import { writeFileSync } from 'node:fs'; 4 | import { join } from 'node:path'; 5 | 6 | describe('collect config', () => { 7 | 8 | it('should log LH config from a user flow', async ({ setupFns, cli }) => { 9 | setupFns.setupRcJson(DEFAULT_RC); 10 | setupFns.setupUserFlows(USER_FLOW_MOCKS.BASIC); 11 | 12 | const { code, stdout, stderr } = await cli.run('user-flow', ['collect', '--verbose', '--dry-run']) 13 | 14 | expect(stdout).toContain('LH Configuration is used from a user flow file'); 15 | expect(stderr).toBe(''); 16 | expect(code).toBe(0); 17 | }); 18 | 19 | it('should throw is invalid path to LH config', async ({ setupFns, cli }) => { 20 | setupFns.setupRcJson(DEFAULT_RC); 21 | setupFns.setupUserFlows(USER_FLOW_MOCKS.BASIC); 22 | 23 | const { code, stdout, stderr } = await cli.run('user-flow', [ 24 | 'collect', 25 | '--configPath ./invalid/path/to/config.json', 26 | '--verbose', 27 | '--dry-run' 28 | ]); 29 | 30 | expect(stderr).toContain('Error: ./invalid/path/to/config.json does not exist.'); 31 | expect(code).toBe(1); 32 | }); 33 | 34 | it('should load configPath from RC file', async ({ root, setupFns, cli }) => { 35 | const rc = structuredClone(DEFAULT_RC); 36 | // @ts-ignore 37 | rc.collect['configPath'] = './lh-config.json'; 38 | setupFns.setupRcJson(rc); 39 | setupFns.setupUserFlows(USER_FLOW_MOCKS.BASIC); 40 | 41 | writeFileSync(join(root, './lh-config.json'), JSON.stringify({ 42 | extends: 'lighthouse:default', 43 | settings: { onlyAudits: ['lcp-lazy-loaded'] } 44 | })); 45 | 46 | const { code, stdout, stderr } = await cli.run('user-flow', ['collect', '--verbose']); 47 | 48 | expect(stdout).toContain('LH Configuration ./lh-config.json is used from CLI param or .user-flowrc.json'); 49 | expect(stderr).toBe(''); 50 | expect(code).toBe(0); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /e2e/cli-e2e/tests/collect/dry-run.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { CliTest, DEFAULT_RC, USER_FLOW_MOCKS } from '../../utils/setup'; 4 | 5 | describe('collect --dry-run', () => { 6 | 7 | it('should run user-flow', async ({ cli, setupFns }) => { 8 | setupFns.setupRcJson(DEFAULT_RC); 9 | setupFns.setupUserFlows(USER_FLOW_MOCKS.BASIC); 10 | 11 | const { code, stdout, stderr } = await cli.run('user-flow', ['collect', '--dry-run']); 12 | 13 | expect(stderr).toBe(''); 14 | expect(stdout).toBe(''); 15 | expect(code).toBe(0); 16 | }); 17 | }); 18 | 19 | describe('collect --dry-run --verbose', () => { 20 | 21 | it('should run user-flow and log', async ({ cli, setupFns }) => { 22 | setupFns.setupRcJson(DEFAULT_RC); 23 | setupFns.setupUserFlows(USER_FLOW_MOCKS.BASIC); 24 | 25 | const { code, stdout, stderr } = await cli.run('user-flow', ['collect', '--dry-run', '--verbose']); 26 | 27 | expect(stderr).toBe(''); 28 | expect(stdout).not.toBe(''); 29 | expect(code).toBe(0); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /e2e/cli-e2e/tests/collect/ufPath.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { mkdirSync } from 'node:fs'; 3 | 4 | import { CliTest, DEFAULT_RC } from '../../utils/setup'; 5 | import { join } from 'node:path'; 6 | 7 | describe('collect ufPath', () => { 8 | 9 | it('should throw if no user-flows directory does not exist', async ({ cli, setupFns }) => { 10 | setupFns.setupRcJson(DEFAULT_RC) 11 | 12 | const { code, stderr} = await cli.run('user-flow', ['collect']); 13 | 14 | expect(code).toBe(1); 15 | expect(stderr).toContain('Error: ufPath: user-flows is neither a file nor a directory'); 16 | }); 17 | 18 | it('should throw if no user-flow is found in ufPath', async ({ root, cli, setupFns }) => { 19 | setupFns.setupRcJson(DEFAULT_RC) 20 | mkdirSync(join(root, DEFAULT_RC.collect.ufPath), { recursive: true }); 21 | 22 | const { code, stderr, stdout } = await cli.run('user-flow', ['collect']); 23 | 24 | expect(code).toBe(1); 25 | expect(stderr).toContain(`No user flows found in ${DEFAULT_RC.collect.ufPath}`); 26 | }); 27 | }) 28 | -------------------------------------------------------------------------------- /e2e/cli-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "types": ["vitest"] 12 | }, 13 | "files": [], 14 | "include": [], 15 | "references": [ 16 | { 17 | "path": "./tsconfig.test.json" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /e2e/cli-e2e/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] 6 | }, 7 | "include": [ 8 | "vite.config.e2e.ts", 9 | "tests/**/*.e2e.test.ts", 10 | "tests/**/*.d.ts", 11 | "mocks/**/*.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /e2e/cli-e2e/utils/setup.ts: -------------------------------------------------------------------------------- 1 | import { normalize } from 'node:path'; 2 | import { ChildProcessWithoutNullStreams } from 'node:child_process'; 3 | 4 | export const E2E_DIR = 'tmp/e2e'; 5 | 6 | export const USER_FLOW_MOCKS = { 7 | BASIC: 'e2e/cli-e2e/mocks/user-flows/basic-navigation.uf.mts', 8 | } as const; 9 | 10 | export const DEFAULT_RC = { 11 | collect: { 12 | url: "https://coffee-cart.netlify.app/", 13 | ufPath: "./user-flows", 14 | }, 15 | persist: { 16 | outPath: "./measures", 17 | format: ["html"] 18 | }, 19 | }; 20 | 21 | export const KEYBOARD = { 22 | ENTER: '\r', 23 | DOWN: '\u001B[B', 24 | SPACE: ' ', 25 | DECLINE_BOOLEAN: 'n', 26 | ACCEPT_BOOLEAN: 'y' 27 | } 28 | 29 | export function normalizePath(path: string) { 30 | return normalize(path.replaceAll(' ', '_')) 31 | } 32 | 33 | export type CliProcessResult = { 34 | stdout: string; 35 | stderr: string; 36 | code: number | null; 37 | } 38 | 39 | export type CliTest = { 40 | root: string; 41 | setupFns: { 42 | setupRcJson: (rc: {}, rcName?: string) => void; 43 | setupUserFlows: (mockUserFlow: string, userFlowDir?: string) => void; 44 | } 45 | cli: { 46 | run: (command: string, args?: string[], waitForClose?: boolean) => Promise; 47 | waitForStdout: (stdOut: string) => Promise; 48 | waitForClose: () => Promise; 49 | type: (inputs: string) => void; 50 | process?: ChildProcessWithoutNullStreams; 51 | verbose: boolean; 52 | stdout: string; 53 | stderr: string; 54 | code: number | null; 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /e2e/cli-e2e/vite.config.e2e.mts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite'; 3 | import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; 4 | 5 | export default defineConfig({ 6 | cacheDir: '../../node_modules/.vite/cli-e2e', 7 | 8 | plugins: [nxViteTsPaths()], 9 | 10 | test: { 11 | globals: true, 12 | reporters: ['basic'], 13 | testTimeout: 30_000, 14 | 15 | pool: 'threads', 16 | poolOptions: { threads: { singleThread: true } }, 17 | 18 | environment: 'node', 19 | include: ['tests/**/*.e2e.test.ts'], 20 | setupFiles: ['setup.e2e.ts'], 21 | globalSetup: ['../../global-setup.e2e.ts'], 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /examples/github-report/.user-flowrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "collect": { 3 | "url": "https://coffee-cart.netlify.app/", 4 | "ufPath": "./examples/github-report/user-flows" 5 | }, 6 | "persist": { 7 | "outPath": "./examples/github-report/measures", 8 | "format": [ "md" ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/github-report/README.md: -------------------------------------------------------------------------------- 1 | ## Github Comment Report Example 2 | 3 | This example demonstrates how you can easily take you current user-flow reports and add them as comments in a GITHUB PR 4 | 5 | ### Description 6 | 7 | The example has a minimal user-flow and is executed in a github workflow. 8 | 9 | You can find the related workflow under `.github/workflows/user-flow-md-report-test.yml`. 10 | 11 | The workflow executes user-flow then with a script transforms and renames the report. 12 | 13 | Then using a GITHUB action it adds it to the pull request as a comment. If there is already a report present it will minimize the previous one when adding a new one. 14 | 15 | ![github-report-comment](assets/github-report-comment.png) 16 | -------------------------------------------------------------------------------- /examples/github-report/assets/github-report-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/examples/github-report/assets/github-report-comment.png -------------------------------------------------------------------------------- /examples/github-report/measures/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/examples/github-report/measures/.gitkeep -------------------------------------------------------------------------------- /examples/github-report/tools/md-report-rename.mts: -------------------------------------------------------------------------------- 1 | import {readdirSync, readFileSync, writeFileSync} from 'node:fs'; 2 | import {join} from 'node:path'; 3 | 4 | console.log(`Rename results for comment action`); 5 | 6 | const path = './examples/github-report/measures'; 7 | 8 | const reportPath = readdirSync(path).find((p) => p.endsWith('.md')); 9 | 10 | if (!reportPath) { 11 | throw new Error('Report file not found'); 12 | } 13 | 14 | const targetPath = join(path, reportPath); 15 | const destPath = join(path, 'md-report.md'); 16 | 17 | let report = readFileSync(targetPath, {encoding: 'utf8'}); 18 | 19 | report = ` 20 | ❗❗❗ **report generated by this PR** ❗❗❗ 21 | --- 22 | 23 | ` + report; 24 | 25 | writeFileSync(destPath, report); 26 | console.log(`Report ${targetPath} renamed to ${destPath}`); 27 | -------------------------------------------------------------------------------- /examples/github-report/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": {}, 4 | "files": [], 5 | "include": ["**/*"], 6 | } 7 | -------------------------------------------------------------------------------- /examples/github-report/user-flows/order-coffee.uf.mts: -------------------------------------------------------------------------------- 1 | import {UserFlowContext, UserFlowInteractionsFn, UserFlowProvider} from '@push-based/user-flow'; 2 | 3 | const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise => { 4 | const { page, flow, browser, collectOptions } = ctx; 5 | const { url } = collectOptions; 6 | 7 | // Navigate to coffee order site 8 | await flow.navigate(url, { 9 | name: '🧭 Navigate to coffee cart', 10 | }); 11 | 12 | await flow.startTimespan({ name: '☕ Select coffee' }); 13 | 14 | // Select coffee 15 | const cappuccinoItem = '.cup:nth-child(1)'; 16 | await page.waitForSelector(cappuccinoItem); 17 | await page.click(cappuccinoItem); 18 | 19 | await flow.endTimespan(); 20 | 21 | await flow.snapshot({ name: '✔ Coffee selected' }); 22 | 23 | 24 | await flow.startTimespan({ name: '🛒 Checkout order' }); 25 | 26 | // Checkout order 27 | const checkoutBtn = '[data-test=checkout]'; 28 | await page.waitForSelector(checkoutBtn); 29 | await page.click(checkoutBtn); 30 | 31 | const nameInputSelector = '#name'; 32 | await page.waitForSelector(nameInputSelector); 33 | await page.type(nameInputSelector, 'nina'); 34 | 35 | const emailInputSelector = '#email'; 36 | await page.waitForSelector(emailInputSelector); 37 | await page.type(emailInputSelector, 'nina@gmail.com'); 38 | 39 | await flow.endTimespan(); 40 | 41 | await flow.snapshot({ name: '🧾 Order checked out' }); 42 | 43 | await flow.startTimespan({ name: '💌 Submit order' }); 44 | 45 | // Submit order 46 | const submitBtn = '#submit-payment'; 47 | await page.click(submitBtn); 48 | await page.waitForSelector(submitBtn); 49 | const successMsg = '.snackbar.success'; 50 | await page.waitForSelector(successMsg); 51 | 52 | await flow.endTimespan(); 53 | 54 | await flow.snapshot({ name: '📧 Order submitted' }); 55 | 56 | // Navigate to github info site 57 | await flow.navigate(url+'github', { 58 | name: '🧭 Navigate to github' 59 | }); 60 | }; 61 | 62 | export default { 63 | flowOptions: { name: '☕ Order Coffee ☕' }, 64 | interactions, 65 | } satisfies UserFlowProvider; 66 | -------------------------------------------------------------------------------- /global-setup.e2e.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import startLocalRegistry from './tools/scripts/start-local-registry'; 3 | import stopLocalRegistry from './tools/scripts/stop-local-registry'; 4 | 5 | export async function setup() { 6 | console.log('Setup e2e test'); 7 | await startLocalRegistry(); 8 | execSync('npm install -D @push-based/user-flow@1.0.0'); 9 | } 10 | 11 | export async function teardown() { 12 | console.log('Teardown e2e test') 13 | stopLocalRegistry(); 14 | execSync('npm uninstall @push-based/user-flow'); 15 | } 16 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | const { getJestProjects } = require('@nx/jest'); 2 | 3 | export default { 4 | projects: getJestProjects(), 5 | }; 6 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nx/jest/preset').default; 2 | 3 | module.exports = { 4 | ...nxPreset, 5 | /* TODO: Update to latest Jest snapshotFormat 6 | * By default Nx has kept the older style of Jest Snapshot formats 7 | * to prevent breaking of any existing tests with snapshots. 8 | * It's recommend you update to the latest format. 9 | * You can do this by removing snapshotFormat property 10 | * and running tests with --update-snapshot flag. 11 | * Example: "nx affected --targets=test,sandbox --update-snapshot" 12 | * More info: https://jestjs.io/docs/upgrading-to-jest29#snapshot-format 13 | */ 14 | snapshotFormat: { escapeString: true, printBasicPrototype: true }, 15 | }; 16 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "nx/presets/core.json", 3 | "pluginsConfig": { 4 | "@nx/js": { 5 | "analyzeSourceFiles": true 6 | } 7 | }, 8 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 9 | "namedInputs": { 10 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 11 | "sharedGlobals": ["{workspaceRoot}/babel.config.json"], 12 | "production": [ 13 | "default", 14 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", 15 | "!{projectRoot}/tsconfig.spec.json", 16 | "!{projectRoot}/jest.config.[jt]s", 17 | "!{projectRoot}/.eslintrc.json", 18 | "!{projectRoot}/src/test-setup.[jt]s" 19 | ] 20 | }, 21 | "targetDefaults": { 22 | "build": { 23 | "inputs": ["production", "^production"] 24 | }, 25 | "test": { 26 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"] 27 | }, 28 | "lint": { 29 | "inputs": ["default", "{workspaceRoot}/.eslintrc.json"] 30 | }, 31 | "@nx/jest:jest": { 32 | "cache": true, 33 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], 34 | "options": { 35 | "passWithNoTests": true 36 | }, 37 | "configurations": { 38 | "ci": { 39 | "ci": true, 40 | "codeCoverage": true 41 | } 42 | } 43 | }, 44 | "@nx/vite:test": { 45 | "cache": true, 46 | "inputs": ["default", "^production"] 47 | }, 48 | "@nx/eslint:lint": { 49 | "inputs": ["default", "{workspaceRoot}/.eslintrc.json"], 50 | "cache": true 51 | } 52 | }, 53 | "generators": { 54 | "@nx/web:application": { 55 | "style": "css", 56 | "linter": "eslint", 57 | "unitTestRunner": "jest", 58 | "e2eTestRunner": "cypress" 59 | }, 60 | "@push-based/user-flow-nx-plugin:target": { 61 | "targetName": "user-flow" 62 | } 63 | }, 64 | "nxCloudAccessToken": "MDlmODg4OTQtYWVlZC00OGVkLTgyMjktMDc3OWQ5ZjU1ZTAxfHJlYWQtd3JpdGU=", 65 | "useInferencePlugins": false, 66 | "defaultBase": "origin/main" 67 | } 68 | -------------------------------------------------------------------------------- /packages/cli/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "parserOptions": { 5 | "project": ["packages/cli/tsconfig.*?.json"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/cli/docs/github-workflow-integration.md: -------------------------------------------------------------------------------- 1 | # GitHub workflow integration of lighthouse user flows in your PR 2 | 3 | In this section you will learn how to run @push-based/user-flow in your CI as [GitHub action](https://github.com/marketplace/actions/lighthouse-user-flow-ci-action). 4 | 5 | If you are not familiar with GitHub actions please read the following content: 6 | 7 | - [GitHub action features](https://github.com/features/actions) 8 | - [GitHub action marketplace](https://github.com/marketplace?type=actions&query=user+flow+) 9 | 10 | In this document we will learn: 11 | - How to setup user flow for CI 12 | - How to setup a `workflow.yml` 13 | - How to test the setup 14 | 15 | ## Setup user flow for the CI 16 | 17 | As pre-condition we assume you have a correct setup of the CLI as descried in [basic setup](writing-basic-user-flows.md). 18 | This means you have a `user-flowrc.json` to point to as well as a `flow-name.mts` to execute. 19 | 20 | To test if you flow is working quickly run the CLI in 'dry run' and print it to the console to see the test passes: 21 | `user-flow collect --dryRun --format stdout` optionally use `--rcPath /path/to/user-flowrc.json` if the rc file is not located in root. 22 | 23 | If everything works you are good to go! 24 | 25 | ## How to set up a `workflow.yml` 26 | 27 | 1. Create a file called `user-flow-ci.yml` in `./.github/workflows`. 28 | 29 | This can be done by using the `init`: 30 | `npx user-flow --generateGhWorkflow` 31 | 32 | 2. The generated `user-flow-ci.yml` file should have the following content: 33 | 34 | ```yml 35 | name: user-flow-ci 36 | on: 37 | pull_request: 38 | jobs: 39 | user-flow-integrated-in-ci: 40 | runs-on: ubuntu-latest 41 | strategy: 42 | matrix: 43 | node-version: [18.x] 44 | steps: 45 | - uses: actions/checkout@v2 46 | - name: Executing user-flow CLI 47 | # without any parameters the rcPath defaults to `.user-flowrc.json` 48 | uses: push-based/user-flow-gh-action@v0.0.0-alpha.20 49 | ``` 50 | 51 | # How to test the setup 52 | 53 | 1. If you open a new PR in your repository you should see the runner execution your user-flow in the CI 54 | 55 | gh-ci-running 56 | gh-ci-complete 57 | 58 | 2. After the user flow executed you should see a mark down report as comment attached to your PR 59 | 60 | gh-ci-comment 61 | 62 | --- 63 | 64 | made with ❤ by [push-based.io](https://www.push-based.io) 65 | -------------------------------------------------------------------------------- /packages/cli/docs/images/164581870-3534f8b0-b7c1-4252-9f44-f07febaa7359.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/164581870-3534f8b0-b7c1-4252-9f44-f07febaa7359.png -------------------------------------------------------------------------------- /packages/cli/docs/images/budgets-json-validation.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/budgets-json-validation.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/budgets-lh.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/budgets-lh.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/budgets-mode-support.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/budgets-mode-support.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/budgets-timing-metrics.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/budgets-timing-metrics.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/chrome-recorder-export-json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/chrome-recorder-export-json.png -------------------------------------------------------------------------------- /packages/cli/docs/images/chrome-recorder-start-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/chrome-recorder-start-new.png -------------------------------------------------------------------------------- /packages/cli/docs/images/getting-started-resulting-navigation-report.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/getting-started-resulting-navigation-report.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/img-budgets-mode-support.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/img-budgets-mode-support.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/img-lhr-budgets-explainer-network.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/img-lhr-budgets-explainer-network.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/img-lhr-budgets-explainer-timing.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/img-lhr-budgets-explainer-timing.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/lhr-budgets-explainer-path.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/lhr-budgets-explainer-path.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/lhr-budgets-explainer-timing.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/lhr-budgets-explainer-timing.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/lhr-budgets-explainer.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/lhr-budgets-explainer.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/lhr-budgets.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/lhr-budgets.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/lhr-replay-example-results-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/lhr-replay-example-results-1.png -------------------------------------------------------------------------------- /packages/cli/docs/images/lhr-replay-example-results-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/lhr-replay-example-results-2.png -------------------------------------------------------------------------------- /packages/cli/docs/images/lhr-replay-example-results-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/lhr-replay-example-results-3.png -------------------------------------------------------------------------------- /packages/cli/docs/images/lhr-viewer.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/lhr-viewer.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/order-coffee.uf.report.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/order-coffee.uf.report.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/performance-budget--devtools-network-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/performance-budget--devtools-network-tab.png -------------------------------------------------------------------------------- /packages/cli/docs/images/performance-budget--devtools-performance-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/performance-budget--devtools-performance-tab.png -------------------------------------------------------------------------------- /packages/cli/docs/images/setup-in-existing-project.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/setup-in-existing-project.gif -------------------------------------------------------------------------------- /packages/cli/docs/images/user-flow_navigation-icon.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/user-flow_navigation-icon.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/user-flow_snapshot-icon.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/user-flow_snapshot-icon.PNG -------------------------------------------------------------------------------- /packages/cli/docs/images/user-flow_timespan-icon.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/cli/docs/images/user-flow_timespan-icon.PNG -------------------------------------------------------------------------------- /packages/cli/docs/old-main-readme.md: -------------------------------------------------------------------------------- 1 | # [@push-based/user-flow](https://github.com/push-based/user-flow/blob/main/packages/cli/README.md) 2 | 3 | --- 4 | 5 | [![@user-flow-logo-square](https://user-images.githubusercontent.com/10064416/156830071-efaa19e3-dbc7-41ca-b3fa-3f9f4d71e213.png)](https://github.com/push-based/user-flow/blob/main/packages/cli/README.md) 6 | 7 | [![npm](https://img.shields.io/npm/v/%40push-based%2Fuser-flow.svg)](https://www.npmjs.com/package/%40push-based%2Fuser-flow) 8 | # See docs [@push-based/user-flow](https://github.com/push-based/user-flow/blob/main/packages/cli/README.md) 9 | 10 | [![user-flow--example](https://user-images.githubusercontent.com/10064416/166849157-f1d799f5-1f05-481b-8234-ec6645827791.PNG)](https://github.com/push-based/user-flow/blob/main/packages/cli/README.md) 11 | 12 | ![user-flow-workflow](https://user-images.githubusercontent.com/95690470/158705707-9eeb9ed0-f317-4ee1-bcab-0a3601957d5b.png) 13 | 14 | --- 15 | 16 | made with ❤ by [push-based.io](https://www.push-based.io) 17 | -------------------------------------------------------------------------------- /packages/cli/docs/raw/order-coffee.md: -------------------------------------------------------------------------------- 1 | # ☕ Order Coffee ☕ 2 | 3 | Date/Time: **2023-02-14 18:32** 4 | 5 | | Step Name | Gather Mode | Performance | Accessibility | Best Practices | Seo | Pwa | 6 | |:---------------------------|:-----------:|:-----------:|:-------------:|:--------------:|:---:|:---:| 7 | | 🧭 Navigate to coffee cart | navigation | - | - | - | - | - | 8 | | ☕ Select coffee | timespan | 22/23 | - | 7/7 | - | - | 9 | | ✔ Coffee selected | snapshot | Ø 3/3 | 15/16 | 5/5 | 7/9 | - | 10 | | 🛒 Checkout order | timespan | 11/13 | - | 7/7 | - | - | 11 | | 🧾 Order checked out | snapshot | Ø 3/3 | 17/18 | 5/5 | 7/9 | - | 12 | | 💌 Submit order | timespan | 13/13 | - | 7/7 | - | - | 13 | | 📧 Order submitted | snapshot | Ø 3/3 | 15/16 | 5/5 | 7/9 | - | 14 | | 🧭 Navigate to github | navigation | 100 | 96 | 100 | 83 | 30 | 15 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@push-based/user-flow", 3 | "version": "0.21.2", 4 | "type": "module", 5 | "main": "src/index.js", 6 | "bin": { 7 | "user-flow": "src/cli.mjs" 8 | }, 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/push-based/user-flow.git" 13 | }, 14 | "keywords": [ 15 | "web performance", 16 | "performance", 17 | "lighthouse", 18 | "UX", 19 | "user flow", 20 | "CI", 21 | "CWV" 22 | ], 23 | "peerDdependencies": {}, 24 | "dependencies": { 25 | "tsx": "^4.18.0", 26 | "tslib": "^2.3.1", 27 | "yargs": "^17.7.2", 28 | "lighthouse": "^12.2.0", 29 | "puppeteer": "^23.1.1", 30 | "@puppeteer/replay": "^3.1.1", 31 | "prettier": "^3.2.5", 32 | "enquirer": "^2.3.6", 33 | "concurrently": "^7.1.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/cli/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cli", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/cli/src", 5 | "projectType": "library", 6 | "tags": [], 7 | "targets": { 8 | "lint": { 9 | "executor": "@nx/eslint:lint" 10 | }, 11 | "build": { 12 | "executor": "@nx/js:tsc", 13 | "outputs": ["{options.outputPath}"], 14 | "options": { 15 | "outputPath": "dist/packages/cli", 16 | "main": "packages/cli/src/index.ts", 17 | "tsConfig": "packages/cli/tsconfig.lib.json", 18 | "assets": [ 19 | "README.md", 20 | "packages/cli/*.md", 21 | "packages/cli/src/cli.mjs", 22 | "packages/cli/*.schema.json", 23 | "packages/cli/**/static/**" 24 | ] 25 | } 26 | }, 27 | "test": { 28 | "executor": "@nx/vite:test", 29 | "outputs": ["{workspaceRoot}/coverage/packages/cli"], 30 | "options": { 31 | "config": "{workspaceRoot}/packages/cli/vite.config.mts" 32 | } 33 | }, 34 | "version": { 35 | "executor": "@jscutlery/semver:version", 36 | "dependsOn": [ 37 | { 38 | "target": "build" 39 | } 40 | ], 41 | "options": { 42 | "postTargets": ["cli:npm", "cli:github"], 43 | "commitMessageFormat": "release(${projectName}): ${version}", 44 | "noVerify": true, 45 | "push": true 46 | } 47 | }, 48 | "github": { 49 | "executor": "@jscutlery/semver:github", 50 | "options": { 51 | "tag": "${tag}", 52 | "notes": "${notes}" 53 | } 54 | }, 55 | "npm": { 56 | "executor": "ngx-deploy-npm:deploy", 57 | "options": { 58 | "access": "public", 59 | "distFolderPath": "dist/packages/cli" 60 | } 61 | }, 62 | "link": { 63 | "executor": "nx:run-commands", 64 | "dependsOn": ["build"], 65 | "options": { 66 | "commands": [ 67 | { 68 | "command": "cd ./dist/packages/cli" 69 | }, 70 | { 71 | "command": "npx cpx \"./dist/packages/cli/**\" ./node_modules/@push-based/user-flow" 72 | } 73 | ] 74 | } 75 | }, 76 | "publish": { 77 | "command": "node tools/scripts/publish.mjs cli {args.ver} {args.tag}", 78 | "dependsOn": ["build"] 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/cli/src/cli.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | import('./lib/boot-cli.js'); 5 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/index.js'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/lib/boot-cli.ts: -------------------------------------------------------------------------------- 1 | import { commands } from './commands/commands.js'; 2 | import { runCli } from './core/yargs/index.js'; 3 | import { getCliOptionsFromRcConfig } from './global/rc-json/index.js'; 4 | import { GLOBAL_OPTIONS_YARGS_CFG } from './global/options/index.js'; 5 | import { getGlobalOptionsFromArgv } from './global/utils.js'; 6 | 7 | /** 8 | * Merges CLI params into rc config 9 | * @param rcPath 10 | */ 11 | function configParser(rcPath?: string): {} { 12 | let rcConfig: any = getCliOptionsFromRcConfig(rcPath); 13 | let globalConfig: any = getGlobalOptionsFromArgv(rcConfig); 14 | return { ...globalConfig, ...rcConfig }; 15 | } 16 | 17 | (async () => runCli({ 18 | commands: commands, 19 | options: {...GLOBAL_OPTIONS_YARGS_CFG}, 20 | configParser }))(); 21 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/assert/utils/__snapshots__/md-report.unit.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`md-table > should print MD table if getStepsTable is called with a reduced result 1`] = ` 4 | "| Step Name | Gather Mode | Performance | Accessibility | Best Practices | Seo | Pwa | 5 | | :----------------------------- | :---------: | :---------: | :-----------: | :------------: | :-: | :-: | 6 | | Navigation report (127.0.0.1/) | navigation | - | 100 | 92 | 100 | 30 | 7 | | Timespan report (127.0.0.1/) | timespan | 10/11 | - | 5/7 | - | - | 8 | | Snapshot report (127.0.0.1/) | snapshot | Ø 3/4 | 10/10 | 4/5 | 9/9 | - |" 9 | `; 10 | 11 | exports[`md-table > should return a Md table comparing to reports if getStepsTable is passed a baseline report 1`] = ` 12 | "| Step Name | Gather Mode | Performance | Accessibility | Best Practices | Seo | Pwa | 13 | | :----------------------------- | :---------: | :---------: | :-----------: | :------------: | :------: | :------: | 14 | | Navigation report (127.0.0.1/) | navigation | - | 100 | 92 (-7) | 100 | 30 (+32) | 15 | | Timespan report (127.0.0.1/) | timespan | 10/11 | - | 5/7 | - | - | 16 | | Snapshot report (127.0.0.1/) | snapshot | Ø 3/4 | 10/10 (-1) | 4/5 | 9/9 (-1) | - |" 17 | `; 18 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/assert/utils/md-report.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getStepsTable } from './md-report.js'; 3 | import { FlowResult } from 'lighthouse'; 4 | import { ReducedReport } from '../../collect/utils/report/types.js'; 5 | import { createReducedReport, enrichReducedReportWithBaseline } from '../../collect/utils/report/utils.js'; 6 | import { readFileSync } from 'node:fs'; 7 | import { join } from 'node:path'; 8 | 9 | const MOCKS_PATH = 'packages/cli/src/lib/commands/assert/utils/mocks/'; 10 | 11 | function getMock(mock: string): T { 12 | return JSON.parse(readFileSync(join(MOCKS_PATH, mock), { encoding: 'utf-8' })) as T; 13 | } 14 | const lhr8 = getMock('lhr-8.json'); 15 | const lhr9 = getMock('lhr-9.json'); 16 | const lhr9Ex2 = getMock('lhr-9-ex-2.json'); 17 | const lhr9reduced = getMock('lhr-9_reduced.json'); 18 | const lhr9ReducedBaseline = getMock('lhr-9_reduced-baseline.json'); 19 | 20 | describe('md-table', () => { 21 | 22 | it('should throw if version is lower than 9', () => { 23 | expect(lhr8['steps']).toBe(undefined); 24 | expect(parseFloat(lhr8.lhr.lighthouseVersion)).toBeLessThan(9); 25 | }); 26 | 27 | it('should NOT throw if version is greater or equal than 9', () => { 28 | expect(parseFloat(lhr9.steps[0].lhr.lighthouseVersion)).toBeGreaterThan(9); 29 | }); 30 | 31 | it('should generate reduced JSON format for v9 raw JSON result if createReducedReport is called', () => { 32 | const reducedLhr9 = createReducedReport(lhr9); 33 | expect(reducedLhr9).toEqual(lhr9reduced); 34 | }); 35 | 36 | it('should generate reduced JSON with baseline results if enrichReducedReportWithBaseline is called', () => { 37 | const reducedLhr9 = createReducedReport(lhr9); 38 | const enrichedReducedLhr9 = enrichReducedReportWithBaseline(reducedLhr9, lhr9Ex2); 39 | expect(enrichedReducedLhr9).toEqual(lhr9ReducedBaseline); 40 | }); 41 | 42 | it('should print MD table if getStepsTable is called with a reduced result', async () => { 43 | const reducedLhr9 = createReducedReport(lhr9); 44 | const mdTable = await getStepsTable(reducedLhr9); 45 | expect(mdTable).toMatchSnapshot(); 46 | }); 47 | 48 | it('should return a Md table comparing to reports if getStepsTable is passed a baseline report', async () => { 49 | const reducedLhr9 = createReducedReport(lhr9); 50 | const mdTable = await getStepsTable(reducedLhr9, lhr9Ex2); 51 | expect(mdTable).toMatchSnapshot(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/assert/utils/mocks/lhr-9_reduced.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sandbox Setup StaticDist", 3 | "fetchTime": "2022-04-21T21:47:59.033Z", 4 | "steps": [ 5 | { 6 | "name": "Navigation report (127.0.0.1/)", 7 | "gatherMode": "navigation", 8 | "fetchTime": "2022-04-21T21:47:59.033Z", 9 | "results": { 10 | "performance": null, 11 | "accessibility": 1, 12 | "best-practices": 0.92, 13 | "seo": 1, 14 | "pwa": 0.3 15 | } 16 | }, 17 | { 18 | "name": "Timespan report (127.0.0.1/)", 19 | "gatherMode": "timespan", 20 | "fetchTime": "2022-04-21T21:47:59.033Z", 21 | "results": { 22 | "performance": { 23 | "numPassed": 10, 24 | "numPassableAudits": 11, 25 | "numInformative": 1, 26 | "totalWeight": 45 27 | }, 28 | "best-practices": { 29 | "numPassed": 5, 30 | "numPassableAudits": 7, 31 | "numInformative": 0, 32 | "totalWeight": 6 33 | } 34 | } 35 | }, 36 | { 37 | "name": "Snapshot report (127.0.0.1/)", 38 | "gatherMode": "snapshot", 39 | "fetchTime": "2022-04-21T21:47:59.033Z", 40 | "results": { 41 | "performance": { 42 | "numPassed": 3, 43 | "numPassableAudits": 4, 44 | "numInformative": 0, 45 | "totalWeight": 0 46 | }, 47 | "accessibility": { 48 | "numPassed": 10, 49 | "numPassableAudits": 10, 50 | "numInformative": 0, 51 | "totalWeight": 57 52 | }, 53 | "best-practices": { 54 | "numPassed": 4, 55 | "numPassableAudits": 5, 56 | "numInformative": 0, 57 | "totalWeight": 5 58 | }, 59 | "seo": { 60 | "numPassed": 9, 61 | "numPassableAudits": 9, 62 | "numInformative": 0, 63 | "totalWeight": 9 64 | } 65 | } 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/command-impl.ts: -------------------------------------------------------------------------------- 1 | import { RcJson } from '../../types.js'; 2 | import { getCollectCommandOptionsFromArgv } from './utils/params.js'; 3 | import { logVerbose } from '../../core/loggin/index.js'; 4 | import { run } from '../../core/processing/behaviors.js'; 5 | import { collectRcJson } from '../init/processes/collect-rc-json.js'; 6 | import { startServerIfNeededAndExecute } from './utils/serve-command.js'; 7 | import { collectReports } from './processes/collect-reports.js'; 8 | import { CollectCommandOptions } from './options/index.js'; 9 | 10 | export async function runCollectCommand(argv: CollectCommandOptions): Promise { 11 | const cfg = getCollectCommandOptionsFromArgv(argv); 12 | logVerbose('Collect options: ', cfg); 13 | await run([ 14 | collectRcJson, 15 | (cfg: RcJson) => 16 | startServerIfNeededAndExecute( 17 | () => collectReports(cfg, argv), 18 | cfg.collect 19 | ) 20 | ])(cfg); 21 | } 22 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/constants.ts: -------------------------------------------------------------------------------- 1 | // @NOTICE the first value in the array is pre-selected as a default value 2 | import { ReportFormat } from './options/types.js'; 3 | 4 | export const REPORT_FORMAT_OPTIONS = [ 5 | { name: 'HTML', value: 'html', hint: 'default' }, 6 | { name: 'JSON', value: 'json' }, 7 | { name: 'Markdown', value: 'md' }, 8 | { name: 'Stdout', value: 'stdout' } 9 | ]; 10 | export const REPORT_FORMAT_NAMES: string[] = REPORT_FORMAT_OPTIONS.map(v => v.name) as any as string[]; 11 | export const REPORT_FORMAT_VALUES: ReportFormat[] = REPORT_FORMAT_OPTIONS.map(v => v.value) as any as ReportFormat[]; 12 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/index.ts: -------------------------------------------------------------------------------- 1 | import { YargsCommandObject } from '../../core/yargs/types.js'; 2 | import { logVerbose } from '../../core/loggin/index.js'; 3 | import { collectOptions } from './options/index.js'; 4 | import { runCollectCommand } from './command-impl.js'; 5 | 6 | export const collectUserFlowsCommand: YargsCommandObject = { 7 | command: 'collect', 8 | description: 'Run a set of user flows and save the result', 9 | builder: (y) => y.options(collectOptions), 10 | module: { 11 | handler: async (argv: any) => { 12 | logVerbose(`run "collect" as a yargs command with args:`); 13 | await runCollectCommand(argv); 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/awaitServeStdout.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | 3 | export const awaitServeStdout = { 4 | alias: 'w', 5 | type: 'string', 6 | description: 'A string in stdout resulting from serving the app, to be awaited before start running the tests. e.g. "server running..."', 7 | implies: ['w', 'serveCommand'] 8 | } satisfies Options; 9 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/config.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | 3 | export const config = { 4 | alias: 'l', 5 | type: 'string', // TODO This should be type object 6 | description: 'Lighthouse configuration (RC file only)' 7 | } satisfies Options; 8 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/configPath.constant.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_COLLECT_CONFIG_PATH = './config.json'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/configPath.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | 3 | export const configPath = { 4 | alias: 'c', 5 | type: 'string', 6 | description: 'Path to Lighthouse configuration e.g config.json' 7 | } satisfies Options; 8 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/dryRun.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | import { getEnvPreset } from '../../../pre-set.js'; 3 | 4 | export const dryRun = { 5 | alias: 'd', 6 | type: 'boolean', 7 | description: 'Execute commands without effects', 8 | default: getEnvPreset().dryRun as boolean 9 | } satisfies Options; 10 | 11 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/format.constant.ts: -------------------------------------------------------------------------------- 1 | import { REPORT_FORMAT_VALUES } from '../constants.js'; 2 | import { ReportFormat } from './types.js'; 3 | 4 | export const PERSIST_FORMAT_HTML: ReportFormat = 'html'; 5 | /** 6 | * @deprecated 7 | * Use PERSIST_FORMAT_HTML instead and wrap it with an array 8 | */ 9 | export const DEFAULT_PERSIST_FORMAT: ReportFormat[] = [PERSIST_FORMAT_HTML]; 10 | export const PROMPT_PERSIST_FORMAT = 'What is the format of user-flow reports? (use ⬇/⬆ to navigate, and SPACE key to select)'; 11 | export const ERROR_PERSIST_FORMAT_REQUIRED = 'format is required. Either through the console as `--format` or in the `.user-flow.json`'; 12 | export const ERROR_PERSIST_FORMAT_WRONG = (wrongFormat: string) => { 13 | return `Argument: format, Given: "${wrongFormat}", Choices: ${REPORT_FORMAT_VALUES.map(v => '"' + v + '"').join(', ')}`; 14 | // @TODO decide for yergs error handling od custom for choices 15 | // return `Wrong format: "${wrongFormat}". Format has to be one of: ${REPORT_FORMAT_OPTIONS.map(f => f.value ).join(', ')}`; 16 | }; 17 | 18 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/format.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | import { REPORT_FORMAT_VALUES } from '../constants.js'; 3 | 4 | export const format = { 5 | alias: 'f', 6 | type: 'array', 7 | string: true, 8 | description: 'Report output formats e.g. JSON', 9 | choices: REPORT_FORMAT_VALUES, 10 | } satisfies Options; 11 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/index.ts: -------------------------------------------------------------------------------- 1 | import { InferredOptionTypes, Options } from 'yargs'; 2 | import { openReport } from './openReport.js'; 3 | import { ufPath } from './ufPath.js'; 4 | import { configPath } from './configPath.js'; 5 | import { outPath } from './outPath.js'; 6 | import { url } from './url.js'; 7 | import { format } from './format.js'; 8 | import { serveCommand } from './serveCommand.js'; 9 | import { awaitServeStdout } from './awaitServeStdout.js'; 10 | import { dryRun } from './dryRun.js'; 11 | import { config } from './config.js'; 12 | import { GlobalCliOptions } from '../../../global/options/index.js'; 13 | 14 | export const persistOptions = { 15 | outPath, 16 | format, 17 | openReport 18 | } satisfies Record; 19 | 20 | export const collectOptions = { 21 | url, 22 | ufPath, 23 | config, 24 | configPath, 25 | serveCommand, 26 | awaitServeStdout, 27 | dryRun, 28 | ...persistOptions, 29 | } satisfies Record; 30 | export type CollectOptions = InferredOptionTypes; 31 | export type CollectCommandOptions = CollectOptions & GlobalCliOptions; 32 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/openReport.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | import { getEnvPreset } from '../../../pre-set.js'; 3 | 4 | export const openReport = { 5 | alias: 'e', 6 | type: 'boolean', 7 | description: 'Opens browser automatically after the user-flow is collected. (true by default)', 8 | default: getEnvPreset().openReport, 9 | requiresArg: true 10 | } satisfies Options; 11 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/outPath.constant.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_PERSIST_OUT_PATH = './measures'; 2 | export const PROMPT_PERSIST_OUT_PATH = 'What is the directory to store results in?'; 3 | export const ERROR_PERSIST_OUT_PATH_REQUIRED = 'Path to output folder is required. Either through the console as `--outPath` or in the `.user-flowrc.json`'; 4 | 5 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/outPath.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | 3 | export const outPath = { 4 | alias: 'o', 5 | type: 'string', 6 | description: 'output folder for the user-flow reports' 7 | } satisfies Options; 8 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/serveCommand.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | 3 | export const serveCommand = { 4 | alias: 's', 5 | type: 'string', 6 | description: 'The npm command to serve your application. e.g. "npm run serve:prod"', 7 | } satisfies Options; 8 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/types.ts: -------------------------------------------------------------------------------- 1 | import { Config } from 'lighthouse'; 2 | 3 | export type CollectRcOptions = { 4 | url: string, 5 | ufPath: string, 6 | configPath?: string; 7 | config?: Config, 8 | // @TODO get better typing for if serveCommand is given await is required 9 | serveCommand?: string, 10 | awaitServeStdout?: string; 11 | } 12 | export type CollectCliOnlyOptions = { 13 | dryRun?: boolean; 14 | } 15 | export type CollectArgvOptions = CollectRcOptions & CollectCliOnlyOptions; 16 | 17 | export type ReportFormat = 'html' | 'md' | 'json' | 'stdout'; 18 | export type PersistRcOptions = { 19 | outPath: string, 20 | format: ReportFormat[] 21 | } 22 | export type PersistCliOnlyOptions = { 23 | openReport?: boolean; 24 | } 25 | 26 | export type PersistArgvOptions = PersistRcOptions & PersistCliOnlyOptions; 27 | 28 | export type CollectCommandCfg = { 29 | collect: CollectArgvOptions, 30 | persist: PersistArgvOptions, 31 | } 32 | 33 | export type CollectCommandArgv = CollectArgvOptions & PersistArgvOptions; 34 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/ufPath.constant.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_COLLECT_UF_PATH = './user-flows'; 2 | export const PROMPT_COLLECT_UF_PATH = 'Folder of the user flows?'; 3 | export const ERROR_COLLECT_UF_PATH_REQUIRED = 'Path to user flows is required. Either through the console as `--ufPath` or in the `.user-flowrc.json`'; 4 | 5 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/ufPath.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | 3 | export const ufPath = { 4 | alias: 'u', 5 | type: 'string', 6 | description: 'folder containing user-flow files to run. (`*.uf.ts` or `*.uf.js`)' 7 | } satisfies Options; 8 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/url.constant.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_COLLECT_URL = 'https://coffee-cart.netlify.app/'; 2 | export const PROMPT_COLLECT_URL = 'What is the URL to run the user flows for?'; 3 | export const ERROR_COLLECT_URL_REQUIRED = 'URL is required. Either through the console as `--url` or in the `.user-flow.json'; 4 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/options/url.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | 3 | export const url = { 4 | alias: 't', 5 | type: 'string', 6 | description: 'URL to analyze', 7 | } satisfies Options; 8 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/processes/collect-reports.ts: -------------------------------------------------------------------------------- 1 | import { concat } from '../../../core/processing/behaviors.js'; 2 | import { collectFlow, loadFlow } from '../utils/user-flow/index.js'; 3 | import { persistFlow } from '../utils/persist/persist-flow.js'; 4 | import { handleOpenFlowReports } from '../utils/persist/open-report.js'; 5 | import { RcJson } from '../../../types.js'; 6 | import { CollectCommandOptions } from '../options/index.js'; 7 | 8 | export async function collectReports(cfg: RcJson, argv: CollectCommandOptions): Promise { 9 | 10 | const { collect, persist } = cfg; 11 | 12 | const userFlows = await loadFlow(collect); 13 | await concat(userFlows.map(({ exports: provider, path }) => 14 | (_: any) => { 15 | return collectFlow({ ...collect, ...persist, }, { ...provider, path }, argv) 16 | .then((flow) => persistFlow(flow, { ...persist, ...collect })) 17 | .then(handleOpenFlowReports(argv)) 18 | .then(_ => cfg); 19 | }) 20 | )(cfg); 21 | return Promise.resolve(cfg); 22 | } 23 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/config/index.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from '../../../../core/file/index.js'; 2 | import { logVerbose } from '../../../../core/loggin/index.js'; 3 | import { DEFAULT_COLLECT_CONFIG_PATH } from '../../options/configPath.constant.js'; 4 | import { Config } from 'lighthouse'; 5 | import { CollectCommandArgv } from '../../options/types.js'; 6 | 7 | export function readConfig(configPath: string = DEFAULT_COLLECT_CONFIG_PATH): Config { 8 | return JSON.parse(readFile(configPath, { fail: true })); 9 | } 10 | 11 | export function getLhConfigFromArgv(rc: Partial>): Config { 12 | let cfg: Config = {}; 13 | if (!!rc?.configPath && !!rc?.config) { 14 | throw new Error('configPath and config can\'t be used together'); 15 | } 16 | 17 | if (rc?.configPath) { 18 | cfg = readConfig(rc.configPath); 19 | logVerbose(`LH Configuration ${rc.configPath} is used from CLI param or .user-flowrc.json`); 20 | } else if (rc?.config) { 21 | cfg = rc.config; 22 | logVerbose(`LH Configuration is used from config property .user-flowrc.json`); 23 | } 24 | 25 | return cfg; 26 | } 27 | 28 | export function mergeLhConfig(globalCfg: Config = {}, localCfg: Config = {}): Config { 29 | let cfg = { ...globalCfg }; 30 | 31 | if (localCfg) { 32 | cfg = { 33 | ...cfg, 34 | ...localCfg, 35 | settings: { 36 | ...cfg.settings, 37 | ...localCfg?.settings 38 | } 39 | }; 40 | logVerbose(`LH Configuration is used from a user flow file`); 41 | } 42 | 43 | if (Object.keys(cfg).length) { 44 | // Add extends if not given 45 | // @ts-ignore 46 | cfg?.extends || (cfg.extends = 'lighthouse:default'); 47 | } 48 | return cfg; 49 | } 50 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/params.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CollectArgvOptions, 3 | CollectCommandCfg, 4 | CollectRcOptions, 5 | PersistArgvOptions, 6 | PersistRcOptions 7 | } from '../options/types.js'; 8 | import { CollectOptions } from '../options/index.js'; 9 | 10 | 11 | export function getCollectCommandOptionsFromArgv(argv: CollectOptions): CollectCommandCfg { 12 | 13 | const { 14 | url, ufPath, serveCommand, awaitServeStdout, dryRun, openReport, 15 | outPath, format, configPath, config 16 | } = (argv || {}) as any as (keyof CollectRcOptions & keyof PersistRcOptions); 17 | 18 | let collect = {} as CollectArgvOptions; 19 | url && (collect.url = url); 20 | ufPath && (collect.ufPath = ufPath); 21 | // optional 22 | serveCommand && (collect.serveCommand = serveCommand); 23 | awaitServeStdout && (collect.awaitServeStdout = awaitServeStdout); 24 | configPath && (collect.configPath = configPath); 25 | config && (collect.config = config); 26 | // cli only 27 | dryRun !== undefined && (collect.dryRun = Boolean(dryRun)); 28 | 29 | let persist = {} as PersistArgvOptions; 30 | outPath && (persist.outPath = outPath); 31 | format && (persist.format = format); 32 | // cli only 33 | openReport !== undefined && (persist.openReport = Boolean(openReport)); 34 | 35 | return { collect, persist }; 36 | } 37 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/persist/open-report.ts: -------------------------------------------------------------------------------- 1 | import openReport from 'open'; 2 | import { logVerbose } from '../../../../core/loggin/index.js'; 3 | import { CollectCommandOptions } from '../../options/index.js'; 4 | 5 | export async function openFlowReports(fileNames: string[]): Promise { 6 | const htmlReport = fileNames.find(i => i.includes('.html')); 7 | if (htmlReport) { 8 | logVerbose('open HTML report in browser'); 9 | await openReport(htmlReport, { wait: false }); 10 | return Promise.resolve(void 0); 11 | } 12 | 13 | const mdReport = fileNames.find(i => i.includes('.md')); 14 | if (mdReport) { 15 | logVerbose('open Markdown report in browser'); 16 | await openReport(mdReport, { wait: false }); 17 | return Promise.resolve(void 0); 18 | } 19 | 20 | const jsonReport = fileNames.find(i => i.includes('.json')); 21 | if (jsonReport) { 22 | logVerbose('open JSON report in browser'); 23 | // @TODO if JSON is given open the file in https://googlechrome.github.io/lighthouse/viewer/ 24 | await openReport(jsonReport, { wait: false }); 25 | } 26 | return Promise.resolve(void 0); 27 | } 28 | 29 | export function handleOpenFlowReports({ dryRun, openReport, interactive}: CollectCommandOptions) { 30 | if (dryRun || !openReport || !interactive) { 31 | return; 32 | } 33 | return openFlowReports; 34 | } 35 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/persist/open-report.unit.test.ts: -------------------------------------------------------------------------------- 1 | import {vi, describe, expect, beforeEach, it } from 'vitest'; 2 | import openReport from 'open'; 3 | import { handleOpenFlowReports, openFlowReports } from './open-report.js'; 4 | import { logVerbose } from '../../../../core/loggin/index.js'; 5 | import { CollectCommandOptions } from '../../options/index.js'; 6 | 7 | vi.mock('open'); 8 | vi.mock('../../../../core/loggin'); 9 | 10 | describe('handleOpenFlowReport', () => { 11 | 12 | beforeEach(() => { 13 | vi.clearAllMocks(); 14 | }) 15 | 16 | it('should return the openFlowReport function if openReport, interactive and not dryRun', async () => { 17 | const openReportsProcess = handleOpenFlowReports({ 18 | openReport: true, 19 | interactive: true, 20 | dryRun: false, 21 | } as CollectCommandOptions); 22 | expect(openReportsProcess).toEqual(expect.any(Function)); 23 | }); 24 | 25 | it('should return undefined if openReport is false', () => { 26 | const openReportsProcess = handleOpenFlowReports({ 27 | openReport: false, 28 | interactive: true, 29 | dryRun: false, 30 | } as CollectCommandOptions); 31 | expect(openReportsProcess).toBeUndefined(); 32 | }); 33 | 34 | it('should return undefined if dryRun is true', () => { 35 | const openReportsProcess = handleOpenFlowReports({ 36 | openReport: true, 37 | interactive: true, 38 | dryRun: true, 39 | } as CollectCommandOptions); 40 | expect(openReportsProcess).toBeUndefined(); 41 | }); 42 | 43 | it('should return undefined if interactive is false', () => { 44 | const openReportsProcess = handleOpenFlowReports({ 45 | openReport: true, 46 | interactive: false, 47 | dryRun: false, 48 | } as CollectCommandOptions); 49 | expect(openReportsProcess).toBeUndefined(); 50 | }); 51 | }); 52 | 53 | describe('openReports', () => { 54 | 55 | beforeEach(() => { 56 | vi.clearAllMocks(); 57 | }); 58 | 59 | it('should not open the report if no file name is passed', async () => { 60 | await openFlowReports([]); 61 | expect(openReport).not.toHaveBeenCalled(); 62 | }); 63 | 64 | it.each(['html', 'json', 'md'])('should open the %s report', async (format) => { 65 | await openFlowReports([`example.${format}`]); 66 | expect(openReport).toHaveBeenCalled(); 67 | }); 68 | 69 | it('should not logVerbose if no file name is passed', async () => { 70 | await openFlowReports([]); 71 | expect(logVerbose).not.toHaveBeenCalled(); 72 | }); 73 | 74 | it('should only open 1 time report if multiple report formats are passed', async () => { 75 | await openFlowReports(['example.html', 'example.json', 'example.md']); 76 | expect(openReport).toHaveBeenCalledTimes(1); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/persist/persist-flow.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import { existsSync, mkdirSync } from 'node:fs'; 3 | import { UserFlow, FlowResult } from 'lighthouse'; 4 | import { log, logVerbose } from '../../../../core/loggin/index.js'; 5 | import { writeFile } from '../../../../core/file/index.js'; 6 | import { PersistFlowOptions } from './types.js'; 7 | import { generateStdoutReport } from './utils.js'; 8 | import { createReducedReport, toReportName } from '../report/utils.js'; 9 | import { ReducedReport } from '../report/types.js'; 10 | import { generateMdReport } from '../../../assert/utils/md-report.js'; 11 | 12 | export async function persistFlow( 13 | flow: UserFlow, 14 | { outPath, format, url }: PersistFlowOptions 15 | ): Promise { 16 | if (!format.length) { 17 | format = ['stdout']; 18 | } 19 | 20 | const jsonReport: FlowResult = await flow.createFlowResult(); 21 | const reducedReport: ReducedReport = createReducedReport(jsonReport); 22 | const results: { format: string, out: string }[] = []; 23 | if (format.includes('json')) { 24 | results.push({ format: 'json', out: JSON.stringify(jsonReport) }); 25 | } 26 | 27 | let mdReport: string | undefined = undefined; 28 | 29 | if (format.includes('md')) { 30 | mdReport = await generateMdReport(reducedReport); 31 | results.push({ format: 'md', out: mdReport }); 32 | } 33 | 34 | if (format.includes('stdout')) { 35 | if(!mdReport) { 36 | mdReport = await generateStdoutReport(reducedReport); 37 | } 38 | 39 | log(mdReport + ''); 40 | } 41 | if (format.includes('html')) { 42 | const htmlReport = await flow.generateReport(); 43 | results.push({ format: 'html', out: htmlReport }); 44 | } 45 | 46 | if (!existsSync(outPath)) { 47 | try { 48 | mkdirSync(outPath, { recursive: true }); 49 | } catch (e) { 50 | // @TODO use a constant instead of a string e.g. `OUT_PATH_NO_DIR_ERROR(dir)` 51 | throw new Error(`outPath: ${outPath} is no directory`); 52 | } 53 | } 54 | 55 | const fileName = toReportName(url, flow._options?.name || '', reducedReport); 56 | const fileNames = results.map((result) => { 57 | const filePath = join(outPath, `${fileName}.${result.format}`); 58 | writeFile(filePath, result.out); 59 | logVerbose(`Report path: ${filePath}.`); 60 | return filePath; 61 | }); 62 | return fileNames; 63 | } 64 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/persist/types.ts: -------------------------------------------------------------------------------- 1 | import { CollectArgvOptions, PersistArgvOptions } from '../../options/types.js'; 2 | 3 | export type PersistFlowOptions = Pick & Pick; 4 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/persist/utils.ts: -------------------------------------------------------------------------------- 1 | import { ReducedReport } from '../report/types.js'; 2 | import { getStepsTable } from '../../../assert/utils/md-report.js'; 3 | 4 | export async function generateStdoutReport(flowResult: ReducedReport): Promise { 5 | const dateTime = new Date().toISOString().replace('T', ' ').split('.')[0].slice(0, -3); 6 | const mdTable = await getStepsTable(flowResult); 7 | return `# ${flowResult.name}\n\nDate/Time: ${dateTime}\n\n${mdTable}`; 8 | } 9 | 10 | export function isoDateStringToIsoLikeString(isoDate: string): string { 11 | return isoDate.replace(/[\-:]/gm, '').split('.').shift() as string; 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/replay/index.ts: -------------------------------------------------------------------------------- 1 | import { createRunner, Runner, UserFlow as PRUserFlow } from '@puppeteer/replay'; 2 | import { UserFlowContext } from '../../../../index.js'; 3 | import { readFile } from '../../../../core/file/index.js'; 4 | import { UserFlowReportJson } from './types.js'; 5 | import { UserFlowRunnerExtension } from './runner-extension.js'; 6 | import { parse } from './parse.js'; 7 | 8 | export async function createUserFlowRunner(path: string, ctx: UserFlowContext): Promise { 9 | const {browser, page, flow} = ctx; 10 | const runnerExtension = new UserFlowRunnerExtension(browser, page, flow); 11 | const jsonRecording = readFile(path, {ext: 'json'}); 12 | const recording = parse(jsonRecording); 13 | return await createRunner(recording as PRUserFlow, runnerExtension); 14 | } 15 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/replay/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { parse } from './parse.js'; 3 | import { puppeteerReplay, userFlowReplay } from './replay.mocks.js'; 4 | 5 | describe('replay', () => { 6 | 7 | it('should parse original replay script without changes', () => { 8 | expect(puppeteerReplay['steps']).toBeDefined(); 9 | 10 | expect(parse(puppeteerReplay)).toEqual(puppeteerReplay); 11 | }); 12 | 13 | it('should parse user-flow enriched replay script without changes', () => { 14 | expect(userFlowReplay['steps']).toBeDefined(); 15 | 16 | expect(parse(userFlowReplay)).toEqual(userFlowReplay); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/replay/parse.ts: -------------------------------------------------------------------------------- 1 | import { UserFlowRecordingStep, UserFlowReportJson } from './types.js'; 2 | import { parse as puppeteerReplayParse, StepType } from '@puppeteer/replay'; 3 | import { isMeasureType } from './utils.js'; 4 | 5 | export function parse(recordingJson: any): UserFlowReportJson { 6 | // custom events to exclude from the default parser 7 | const ufArr: UserFlowRecordingStep[] = []; 8 | 9 | // filter out user-flow specific actions 10 | const steps = recordingJson.steps.filter( 11 | (value: any, index: number) => { 12 | if (isMeasureType(value?.type)) { 13 | ufArr[index] = value; 14 | return false; 15 | } 16 | return true; 17 | } 18 | ); 19 | 20 | // parse the clean steps 21 | const parsed: UserFlowReportJson = puppeteerReplayParse({ ...recordingJson, steps }); 22 | // add in user-flow specific actions 23 | ufArr.forEach((value, index) => { 24 | value && (parsed.steps.splice(index, 0, value)); 25 | }); 26 | 27 | // parse customEvents from our stringify function 28 | parsed.steps = parsed.steps.map((step) => { 29 | if (step.type === StepType.CustomStep && isMeasureType(step.name)) { 30 | const { name: type, parameters } = step as any; 31 | return { type, parameters } as UserFlowRecordingStep; 32 | } 33 | return step; 34 | }); 35 | 36 | return parsed; 37 | } 38 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/replay/replay.mocks.ts: -------------------------------------------------------------------------------- 1 | export const puppeteerReplay = { 2 | "title": "Order Coffee", 3 | "steps": [ 4 | { 5 | "type": "setViewport", 6 | "width": 953, 7 | "height": 616, 8 | "deviceScaleFactor": 1, 9 | "isMobile": false, 10 | "hasTouch": false, 11 | "isLandscape": false 12 | }, 13 | { 14 | "type": "click", 15 | "target": "main", 16 | "selectors": [ 17 | [ 18 | "aria/Mocha" 19 | ], 20 | [ 21 | "[data-test=Mocha]" 22 | ] 23 | ], 24 | "offsetY": 144.24940490722656, 25 | "offsetX": 186.73519897460938 26 | }, 27 | { 28 | "type": "click", 29 | "target": "main", 30 | "selectors": [ 31 | [ 32 | "aria/Proceed to checkout" 33 | ], 34 | [ 35 | "[data-test=checkout]" 36 | ] 37 | ], 38 | "offsetY": 17.7916259765625, 39 | "offsetX": 146.8541259765625 40 | }, 41 | { 42 | "type": "change", 43 | "value": "test@test.at", 44 | "selectors": [ 45 | [ 46 | "aria/Name" 47 | ], 48 | [ 49 | "#name" 50 | ] 51 | ], 52 | "target": "main" 53 | } 54 | ] 55 | }; 56 | 57 | export const userFlowReplay = { 58 | "title": "Order Coffee", 59 | "steps": [ 60 | { 61 | "type": "setViewport", 62 | "width": 953, 63 | "height": 616, 64 | "deviceScaleFactor": 1, 65 | "isMobile": false, 66 | "hasTouch": false, 67 | "isLandscape": false 68 | }, 69 | { 70 | "type": "startTimespan" 71 | }, 72 | { 73 | "type": "click", 74 | "target": "main", 75 | "selectors": [ 76 | [ 77 | "aria/Mocha" 78 | ], 79 | [ 80 | "[data-test=Mocha]" 81 | ] 82 | ], 83 | "offsetY": 144.24940490722656, 84 | "offsetX": 186.73519897460938 85 | }, 86 | { 87 | "type": "click", 88 | "target": "main", 89 | "selectors": [ 90 | [ 91 | "aria/Proceed to checkout" 92 | ], 93 | [ 94 | "[data-test=checkout]" 95 | ] 96 | ], 97 | "offsetY": 17.7916259765625, 98 | "offsetX": 146.8541259765625 99 | }, 100 | { 101 | "type": "change", 102 | "value": "michael@hladky.at", 103 | "selectors": [ 104 | [ 105 | "aria/Name" 106 | ], 107 | [ 108 | "#name" 109 | ] 110 | ], 111 | "target": "main" 112 | }, 113 | { 114 | "type": "endTimespan" 115 | }, 116 | { 117 | "type": "snapshot" 118 | } 119 | ] 120 | }; 121 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/replay/runner-extension.ts: -------------------------------------------------------------------------------- 1 | import {PuppeteerRunnerExtension, Step, UserFlow as UserFlowRecording} from "@puppeteer/replay"; 2 | import {Browser, Page} from "puppeteer"; 3 | import { MeasurementStep, UserFlowRecordingStep } from './types.js'; 4 | import {isMeasureType} from "./utils.js"; 5 | // @ts-ignore 6 | import {UserFlow} from 'lighthouse'; 7 | 8 | export class UserFlowRunnerExtension extends PuppeteerRunnerExtension { 9 | 10 | constructor(browser: Browser, page: Page, private flow: UserFlow, opts?: { 11 | timeout?: number; 12 | }) { 13 | super(browser, page, opts); 14 | } 15 | 16 | override async runStep(step: UserFlowRecordingStep, flowRecording: UserFlowRecording): Promise { 17 | if (isMeasureType(step.type) && !this.flow?.currentTimespan) { 18 | const userFlowStep = step as MeasurementStep; 19 | const stepOptions = userFlowStep?.stepOptions; 20 | if (userFlowStep.type === 'navigate') { 21 | // TODO fix (Changes to Flow API) 22 | // @ts-ignore 23 | return this.flow[userFlowStep.type](userFlowStep?.url, {...stepOptions}); 24 | } 25 | // TODO fix (Changes to Flow API) 26 | // @ts-ignore 27 | return this.flow[userFlowStep.type]({...stepOptions}); 28 | } else { 29 | return super.runStep(step as Step, flowRecording); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/replay/types.ts: -------------------------------------------------------------------------------- 1 | import { UserFlow, Step } from '@puppeteer/replay'; 2 | import { Modify } from '../../../../core/types.js'; 3 | 4 | /** 5 | * 'navigation' is already covered by `@puppeteer/replay` 6 | */ 7 | export type MeasureModes = 'navigate' |'snapshot' | 'startTimespan' | 'endTimespan'; 8 | 9 | /* 10 | // Consider modify the Step type 11 | | Modify;*/ 14 | export type MeasurementStep = { 15 | type: MeasureModes; 16 | stepOptions?: { name?: string; } 17 | url?: string; 18 | } 19 | 20 | export type UserFlowRecordingStep = MeasurementStep | Step; 21 | 22 | export type UserFlowReportJson = Modify; 25 | 26 | export type ReadFileExtTypes = { json: {}, html: string, text: string }; 27 | export type ReadFileConfig = { fail?: boolean, ext?: keyof ReadFileExtTypes}; 28 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/replay/utils.ts: -------------------------------------------------------------------------------- 1 | import { CustomStep, Step, StepType } from '@puppeteer/replay'; 2 | import { MeasureModes, UserFlowRecordingStep } from './types.js'; 3 | 4 | export function isMeasureType(str: string) { 5 | switch (str as MeasureModes) { 6 | case 'navigate': 7 | case 'snapshot': 8 | case 'startTimespan': 9 | case 'endTimespan': 10 | return true; 11 | default: 12 | return false; 13 | } 14 | } 15 | 16 | export function stringify(enrichedRecordingJson: { title: string, steps: UserFlowRecordingStep[] }): string { 17 | const { title, steps } = enrichedRecordingJson; 18 | const standardizedJson = { 19 | title, 20 | steps: (steps).map( 21 | (step) => { 22 | if (isMeasureType(step.type)) { 23 | return userFlowStepToCustomStep(step as unknown as UserFlowRecordingStep); 24 | } 25 | return step; 26 | } 27 | ) 28 | }; 29 | return JSON.stringify(standardizedJson); 30 | } 31 | 32 | function userFlowStepToCustomStep(step: UserFlowRecordingStep): Step { 33 | const { type: name, parameters } = step as any; 34 | const stdStp: CustomStep = { 35 | type: StepType.CustomStep, 36 | name, 37 | parameters 38 | }; 39 | return stdStp; 40 | } 41 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/report/types.ts: -------------------------------------------------------------------------------- 1 | import { Config, FlowResult } from 'lighthouse'; 2 | import { CLI_MODES } from '../../../../global/cli-mode/index.js'; 3 | import { PickOne } from '../../../../core/types.js'; 4 | 5 | type UfrSlice = PickOne; 6 | type LhrSlice = PickOne; 7 | 8 | export type GatherMode = FlowResult.Step['lhr']['gatherMode']; 9 | /** 10 | * Plucks key value from oroginal LH report 11 | * @example 12 | * 13 | * const t: GatherModeSlice = {gatherMode: 'navigation'}; 14 | * const f1: GatherModeSlice = {gatherMode: 'timespan'}; 15 | * const f2: GatherModeSlice = {gatherMode: 'snapshot'}; 16 | */ 17 | type LhrGatherModeSlice = LhrSlice & { gatherMode: GatherMode }; 18 | type UfrNameSlice = UfrSlice & { name: string }; 19 | 20 | 21 | /** 22 | * This type is the result of `calculateCategoryFraction` https://github.com/GoogleChrome/lighthouse/blob/master/core/util.cjs#L540. 23 | * As there is no typing present ATM we maintain our own. 24 | */ 25 | export type FractionResults = { 26 | numPassed: number; 27 | numPassableAudits: number; 28 | numInformative: number; 29 | totalWeight: number; 30 | } 31 | 32 | export type ReducedFlowStep = 33 | // gatherMode 34 | LhrGatherModeSlice & 35 | { 36 | name: string; 37 | fetchTime: string; 38 | results: ReducedFlowStepResult; 39 | baseline?: ReducedFlowStepResult; 40 | }; 41 | 42 | export type ReducedReport = { 43 | cliMode?: CLI_MODES; 44 | dryRun?: boolean; 45 | headless?: boolean; 46 | name: string; 47 | fetchTime: string; 48 | steps: ReducedFlowStep[]; 49 | config?: Config & { baseline?: any }; 50 | } 51 | 52 | export type ReducedFlowStepResult = Record; 53 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/report/utils.ts: -------------------------------------------------------------------------------- 1 | import { FlowResult } from 'lighthouse'; 2 | import { ReducedReport } from './types.js'; 3 | import { parseSteps } from './lh-utils.js'; 4 | import { toFileName } from '../../../../core/file/index.js'; 5 | import { isoDateStringToIsoLikeString } from '../persist/utils.js'; 6 | 7 | export function createReducedReport(flowResult: FlowResult): ReducedReport { 8 | const steps = parseSteps(flowResult.steps); 9 | return { 10 | name: flowResult.name, 11 | fetchTime: steps[0].fetchTime, 12 | steps 13 | }; 14 | } 15 | 16 | export function enrichReducedReportWithBaseline(reducedReport: ReducedReport, baselineReport: FlowResult): ReducedReport { 17 | const baselineReducedReport = createReducedReport(baselineReport); 18 | const baselineResults = Object.fromEntries(baselineReducedReport.steps.map((step) => [step.name, step.results])); 19 | const steps = reducedReport.steps.map((step) => ({ ...step, 'baseline': baselineResults[step.name] })); 20 | return { 21 | ...reducedReport, 22 | steps 23 | }; 24 | } 25 | 26 | export function toReportName(url: string, flowName: string, report: ReducedReport): string { 27 | const fetchTime = isoDateStringToIsoLikeString(report.steps[0].fetchTime); 28 | return `${toFileName(url)}-${toFileName(flowName)}-${isoDateStringToIsoLikeString(fetchTime)}`; 29 | } 30 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/serve-command.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, vi, expect, it, beforeEach } from 'vitest'; 2 | import { startServerIfNeededAndExecute } from './serve-command.js'; 3 | import { concurrently } from 'concurrently'; 4 | 5 | import { CollectRcOptions } from '../options/types.js'; 6 | 7 | vi.mock('../../../core/loggin'); 8 | vi.mock('concurrently', async () => { 9 | const C = await vi.importActual<{concurrently: typeof concurrently}>('concurrently'); 10 | return {concurrently: vi.fn().mockImplementation(C.concurrently)}; 11 | }); 12 | const userFlowWorkMock = vi.fn().mockResolvedValue({}); 13 | 14 | describe('startServerIfNeeded', () => { 15 | 16 | beforeEach(() => { 17 | vi.clearAllMocks(); 18 | }); 19 | 20 | it('should throw if serveCommand is provided but no await string', async () => { 21 | const fn = async () => await startServerIfNeededAndExecute(userFlowWorkMock, { serveCommand: 'npm run start' } as CollectRcOptions); 22 | expect(fn).rejects.toThrowError('If a serve command is provided awaitServeStdout is also required'); 23 | expect(userFlowWorkMock).not.toHaveBeenCalled(); 24 | }); 25 | 26 | it('should immediately execute work if no serveCommand is provided', async () => { 27 | await startServerIfNeededAndExecute(userFlowWorkMock); 28 | expect(userFlowWorkMock).toHaveBeenCalled(); 29 | }); 30 | 31 | it('should execute serveCommand first if it is provided correctly', async () => { 32 | const concurrentlySpy = vi.mocked(concurrently); 33 | await startServerIfNeededAndExecute(userFlowWorkMock, { serveCommand: 'node --help', awaitServeStdout: 'v' } as CollectRcOptions); 34 | expect(concurrentlySpy.mock.invocationCallOrder < userFlowWorkMock.mock.invocationCallOrder).toBeTruthy(); 35 | }); 36 | 37 | it('should exit with error if serveCommand throws', async () => { 38 | const fn = async () => await startServerIfNeededAndExecute(userFlowWorkMock, { serveCommand: 'Broken Command!', awaitServeStdout: 'v' } as CollectRcOptions); 39 | expect(fn).rejects.toThrowError(expect.stringContaining('Broken Command!')); 40 | }); 41 | 42 | it('should run serveCommand', async () => { 43 | const concurrentlySpy = vi.mocked(concurrently); 44 | await startServerIfNeededAndExecute(userFlowWorkMock, { serveCommand: 'node --help', awaitServeStdout: 'Usage: node' } as CollectRcOptions); 45 | expect(concurrentlySpy).toHaveBeenCalled(); 46 | }); 47 | 48 | it('should run serveCommand and catch error in user-flows', async () => { 49 | userFlowWorkMock.mockRejectedValue('user flow error'); 50 | const fn = async () => await startServerIfNeededAndExecute(userFlowWorkMock, { serveCommand: 'node --help', awaitServeStdout: 'Usage: node' } as CollectRcOptions); 51 | expect(fn).rejects.toThrowError(expect.stringContaining(`user flow error`)); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/serve-command.ts: -------------------------------------------------------------------------------- 1 | import { concurrently } from 'concurrently'; 2 | import { logVerbose } from '../../../core/loggin/index.js'; 3 | import { Subscription } from 'rxjs'; 4 | import { CollectRcOptions } from '../options/types.js'; 5 | import { RcJson } from '../../../types.js'; 6 | 7 | // @TODO as it is quite har to maintain and test the serve command we have to think about a better way to wrap it 8 | // I suggest a single function returning a promise. 9 | // This fn takes the serve options as well ans the run block and makes sure execution is done correctly and errors are forwarded too. 10 | // In there we compose easier to test fn's 11 | 12 | export async function startServerIfNeededAndExecute(workTargetingServer: () => Promise, collectOption: CollectRcOptions = {} as CollectRcOptions): Promise { 13 | 14 | const { serveCommand, awaitServeStdout } = collectOption; 15 | 16 | if (serveCommand && !awaitServeStdout) { 17 | return Promise.reject(new Error('If a serve command is provided awaitServeStdout is also required')); 18 | } 19 | 20 | if (!serveCommand || !awaitServeStdout) { 21 | return workTargetingServer(); 22 | } 23 | 24 | logVerbose('execute serve command'); 25 | return new Promise((resolve, reject) => { 26 | const sub = new Subscription(); 27 | const res = concurrently([serveCommand]); 28 | 29 | const cR = res.commands[0]; 30 | const stopServer = () => { 31 | logVerbose('stop server'); 32 | cR.kill(); 33 | sub.unsubscribe(); 34 | }; 35 | const endRes = res.result 36 | // We resolve when the awaited value arrives 37 | // .then((v) => console.log('concurrently resolve', v)) 38 | .catch(e => { 39 | reject('Error while executing ' + serveCommand); 40 | }).finally(); 41 | 42 | 43 | let isCollecting = false; 44 | sub.add(cR.stdout.subscribe( 45 | stdout => { 46 | const out = stdout.toString(); 47 | logVerbose(out); 48 | // await stdout and start collecting once 49 | if (out.includes(awaitServeStdout) && !isCollecting) { 50 | isCollecting = true; 51 | workTargetingServer() 52 | .then(resolve) 53 | .catch(e => { 54 | reject('Error while running user flows. ' + e); 55 | }).finally(stopServer); 56 | } 57 | })); 58 | }); 59 | 60 | } 61 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/user-flow/collect-flow.ts: -------------------------------------------------------------------------------- 1 | import { UserFlowProvider } from './types.js'; 2 | import { logVerbose } from '../../../../core/loggin/index.js'; 3 | import * as puppeteer from 'puppeteer'; 4 | import { Browser, LaunchOptions, Page } from 'puppeteer'; 5 | import { normalize } from 'path'; 6 | import { startFlow, UserFlow } from 'lighthouse'; 7 | import { UserFlowMock } from './user-flow.mock.js'; 8 | import { detectCliMode } from '../../../../global/cli-mode/cli-mode.js'; 9 | import { CollectArgvOptions, PersistArgvOptions } from '../../options/types.js'; 10 | import { getLhConfigFromArgv, mergeLhConfig } from '../config/index.js'; 11 | import { CollectCommandOptions } from '../../options/index.js'; 12 | 13 | export async function collectFlow( 14 | cliOption: CollectArgvOptions & PersistArgvOptions, 15 | userFlowProvider: UserFlowProvider & { path: string }, 16 | argv: CollectCommandOptions 17 | ) { 18 | let { 19 | path, 20 | flowOptions, 21 | interactions, 22 | launchOptions 23 | } = userFlowProvider; 24 | 25 | let globalLhCfg = getLhConfigFromArgv(cliOption); 26 | const lhConfig = mergeLhConfig(globalLhCfg, flowOptions?.config); 27 | flowOptions.config = lhConfig; 28 | 29 | const browser: Browser = await puppeteer.launch(parseLaunchOptions(launchOptions)); 30 | const page: Page = await browser.newPage(); 31 | 32 | logVerbose(`Collect: ${flowOptions.name} from URL ${cliOption.url}`); 33 | logVerbose(`User-flow path: ${normalize(path)}`); 34 | let start = Date.now(); 35 | 36 | const flow: UserFlow = !argv.dryRun ? await startFlow(page, flowOptions) : new UserFlowMock(page, flowOptions) as unknown as UserFlow; 37 | 38 | // run custom interactions 39 | await interactions({ flow, page, browser, collectOptions: cliOption }); 40 | logVerbose(`Duration: ${flowOptions.name}: ${(Date.now() - start) / 1000}`); 41 | await browser.close(); 42 | 43 | return flow; 44 | } 45 | 46 | function parseLaunchOptions(launchOptions?: LaunchOptions): LaunchOptions { 47 | // object containing the options for puppeteer/chromium 48 | launchOptions = launchOptions || { 49 | // has to be false to run in the CI because of a bug :( 50 | // https://github.com/puppeteer/puppeteer/issues/8148 51 | headless: false, 52 | // hack for dryRun => should get fixed inside user flow in future 53 | defaultViewport: { isMobile: true, isLandscape: false, width: 800, height: 600 } 54 | } as LaunchOptions; 55 | const cliMode = detectCliMode(); 56 | // cli mode is "CI" or "SANDBOX" 57 | if (cliMode !== 'DEFAULT' && launchOptions) { 58 | const headlessMode = 'new'; 59 | logVerbose(`Set options#headless to ${headlessMode} in puppeteer#launch as we are running in ${cliMode} mode`); 60 | (launchOptions as any).headless = headlessMode; 61 | } 62 | 63 | return launchOptions; 64 | } 65 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/user-flow/index.ts: -------------------------------------------------------------------------------- 1 | export {loadFlow} from './load-flow.js'; 2 | export {collectFlow} from './collect-flow.js'; 3 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/user-flow/load-flow.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import { existsSync, lstatSync, readdirSync } from 'node:fs'; 3 | import { UserFlowProvider } from './types.js'; 4 | import { resolveAnyFile } from '../../../../core/file/index.js'; 5 | import { CollectRcOptions } from '../../options/types.js'; 6 | 7 | export async function loadFlow(collect: Pick): Promise<({ 8 | exports: UserFlowProvider, 9 | path: string 10 | })[]> { 11 | const { ufPath } = collect; 12 | const path = join(ufPath); 13 | if (!existsSync(path)) { 14 | throw new Error(`ufPath: ${path} is neither a file nor a directory`); 15 | } 16 | 17 | let files: string[]; 18 | if (lstatSync(path).isDirectory()) { 19 | files = readdirSync(path).map(file => join(path, file)); 20 | } else { 21 | files = [path]; 22 | } 23 | 24 | const flows = await Promise.all(files.filter(f => f.endsWith('js') || f.endsWith('ts')) 25 | .map((file) => resolveAnyFile(file))); 26 | 27 | if (flows.length === 0) { 28 | // @TODO use const for error msg 29 | throw new Error(`No user flows found in ${ufPath}`); 30 | } 31 | return flows; 32 | } 33 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/collect/utils/user-flow/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Browser, 3 | BrowserConnectOptions, 4 | BrowserLaunchArgumentOptions, 5 | LaunchOptions as PPTLaunchOptions, 6 | Page, 7 | SupportedBrowser 8 | } from 'puppeteer'; 9 | 10 | import { Config, UserFlow } from 'lighthouse'; 11 | 12 | export type UserFlowContext = { 13 | browser: Browser; 14 | page: Page; 15 | flow: UserFlow; 16 | collectOptions: { url: string }; 17 | }; 18 | 19 | export type StepOptions = { 20 | name: string; 21 | } & { 22 | /*page: Page,*/ config?: Config /*configContext?: LH.Config.FRContext*/; 23 | }; 24 | 25 | export type UserFlowInteractionsFn = ( 26 | context: UserFlowContext 27 | ) => Promise; 28 | 29 | export type UserFlowOptions = { 30 | name: string; 31 | config?: Config 32 | }; 33 | 34 | // @TODO 35 | // LH setting -> check what can be configured, 36 | // narrow down to smallest possible -> LH will overwrite ppt 37 | export type LaunchOptions = PPTLaunchOptions & 38 | BrowserLaunchArgumentOptions & 39 | BrowserConnectOptions & { 40 | defaultBrowser?: SupportedBrowser; 41 | extraPrefsFirefox?: Record; 42 | }; 43 | 44 | export type UserFlowProvider = { 45 | flowOptions: UserFlowOptions; 46 | interactions: UserFlowInteractionsFn; 47 | launchOptions?: LaunchOptions; 48 | }; 49 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/commands.ts: -------------------------------------------------------------------------------- 1 | import { YargsCommandObject } from '../core/yargs/types.js'; 2 | import { collectUserFlowsCommand } from './collect/index.js'; 3 | import { initCommand } from './init/index.js'; 4 | 5 | export const commands: YargsCommandObject[] = [ 6 | initCommand, 7 | collectUserFlowsCommand, 8 | { 9 | ...collectUserFlowsCommand, 10 | command: '*' 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/command-impl.ts: -------------------------------------------------------------------------------- 1 | import { InitOptions } from './options.js'; 2 | import { log, logVerbose } from '../../core/loggin/index.js'; 3 | import { run } from '../../core/processing/behaviors.js'; 4 | import { collectRcJson } from './processes/collect-rc-json.js'; 5 | import { getGlobalOptionsFromArgv } from '../../global/utils.js'; 6 | import { getInitCommandOptionsFromArgv } from './utils.js'; 7 | import { updateRcJson } from './processes/update-rc-json.js'; 8 | import { handleFlowGeneration } from './processes/generate-userflow.js'; 9 | import { handleGhWorkflowGeneration } from './processes/generate-workflow.js'; 10 | import { SETUP_CONFIRM_MESSAGE } from './constants.js'; 11 | 12 | export async function runInitCommand(argv: InitOptions): Promise { 13 | const { interactive } = getGlobalOptionsFromArgv(argv); 14 | const { generateFlow, generateGhWorkflow, lhr, ...cfg } = getInitCommandOptionsFromArgv(argv); 15 | logVerbose('Init options: ', { interactive, generateFlow, generateGhWorkflow, lhr, ...cfg }); 16 | 17 | await run([ 18 | collectRcJson, 19 | updateRcJson, 20 | handleFlowGeneration({ interactive: !!interactive, generateFlow }), 21 | handleGhWorkflowGeneration({ generateGhWorkflow }), 22 | ])(cfg); 23 | log(SETUP_CONFIRM_MESSAGE); 24 | // @TODO move to constants 25 | log('To execute a user flow run `npx user-flow` or `npx user-flow collect`'); 26 | } 27 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/constants.ts: -------------------------------------------------------------------------------- 1 | export const SETUP_CONFIRM_MESSAGE = 'user-flow CLI is set up now! 🎉'; 2 | export const FlowExampleMap = { 3 | 'basic-navigation': 'basic-navigation.uf.mts' 4 | } as const; 5 | export const GhWorkflowExampleMap = { 6 | 'basic-workflow': 'user-flow-ci.yml' 7 | } as const; 8 | export const PROMPT_INIT_GENERATE_FLOW = 'Setup user flow'; 9 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/index.ts: -------------------------------------------------------------------------------- 1 | import { YargsCommandObject } from '../../core/yargs/types.js'; 2 | import { logVerbose } from '../../core/loggin/index.js'; 3 | import { initOptions } from './options.js'; 4 | import { runInitCommand } from './command-impl.js'; 5 | 6 | export const initCommand: YargsCommandObject = { 7 | command: 'init', 8 | description: 'Setup .user-flowrc.json', 9 | builder: (y) => y.options(initOptions), 10 | module: { 11 | handler: async (argv: any) => { 12 | logVerbose(`run "init" as a yargs command`); 13 | await runInitCommand(argv); 14 | } 15 | } 16 | }; 17 | 18 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/options.ts: -------------------------------------------------------------------------------- 1 | import { InferredOptionTypes, Options } from 'yargs'; 2 | import { collectOptions } from '../collect/options/index.js'; 3 | 4 | const generateFlow = { 5 | alias: 'h', 6 | type: 'boolean', 7 | description: 'Create as user flow under "ufPath"' 8 | } satisfies Options; 9 | 10 | const generateGhWorkflow = { 11 | alias: 'g', 12 | type: 'boolean', 13 | description: 'Create a workflow using user-flow under .github/workflows' 14 | } satisfies Options; 15 | 16 | 17 | const lhr = { 18 | type: 'string', 19 | description: 'Should derive budget from path', 20 | } satisfies Options; 21 | 22 | export const initOptions = { 23 | generateFlow, 24 | generateGhWorkflow, 25 | lhr, 26 | ...collectOptions, 27 | } satisfies Record; 28 | export type InitOptions = InferredOptionTypes; 29 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/processes/collect-rc-json.ts: -------------------------------------------------------------------------------- 1 | import { setupUrl } from './url.setup.js'; 2 | import { setupUfPath } from './ufPath.setup.js'; 3 | import { setupFormat } from './format.setup.js'; 4 | import { setupOutPath } from './outPath.setup.js'; 5 | import { RcJson } from '../../../types.js'; 6 | 7 | export async function collectRcJson(cliCfg: RcJson): Promise { 8 | 9 | const config = { 10 | ...cliCfg, 11 | ...(await setupUrl(cliCfg) 12 | .then(setupUfPath) 13 | .then(setupFormat) 14 | .then(setupOutPath) 15 | // initial static defaults should be last as it takes user settings 16 | ) 17 | }; 18 | return config; 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/processes/format.setup.ts: -------------------------------------------------------------------------------- 1 | import Enquirer from 'enquirer'; 2 | import { get as interactive } from '../../../global/options/interactive.js'; 3 | import { 4 | ERROR_PERSIST_FORMAT_REQUIRED, 5 | ERROR_PERSIST_FORMAT_WRONG, 6 | PROMPT_PERSIST_FORMAT 7 | } from '../../collect/options/format.constant.js'; 8 | import { applyValidations, hasError, VALIDATORS } from '../../../core/validation/index.js'; 9 | import { REPORT_FORMAT_NAMES, REPORT_FORMAT_OPTIONS, REPORT_FORMAT_VALUES } from '../../collect/constants.js'; 10 | import { RcJson } from '../../../types.js'; 11 | import { ReportFormat } from '../../collect/options/types.js'; 12 | import { getEnvPreset } from '../../../pre-set.js'; 13 | 14 | export async function setupFormat( 15 | config: RcJson 16 | ): Promise { 17 | let format: ReportFormat[] = Array.isArray(config?.persist?.format) ? config.persist.format : []; 18 | 19 | if (interactive()) { 20 | const { f }: { f: ReportFormat[] } = format.length ? { f: format } : await Enquirer.prompt<{ f: ReportFormat[] }>([ 21 | { 22 | type: 'multiselect', 23 | name: 'f', 24 | message: PROMPT_PERSIST_FORMAT, 25 | choices: REPORT_FORMAT_OPTIONS, 26 | initial: REPORT_FORMAT_VALUES.indexOf((getEnvPreset() as any).format[0]), 27 | // @NOTICE typing is broken here 28 | result(value: string) { 29 | const values = value as any as string[]; 30 | return values.map((name: string) => REPORT_FORMAT_VALUES[REPORT_FORMAT_NAMES.indexOf(name)]) as any as string; 31 | }, 32 | multiple: true 33 | } 34 | ]); 35 | 36 | format = f as ReportFormat[] || []; 37 | 38 | if (format?.length === 0) { 39 | return setupFormat(config); 40 | } 41 | } 42 | 43 | // Validate 44 | const allOf = VALIDATORS.allOf(REPORT_FORMAT_VALUES); 45 | const errors = applyValidations(format, [ 46 | VALIDATORS.required, 47 | allOf 48 | ]); 49 | 50 | if (hasError(errors)) { 51 | if (errors.required) { 52 | throw new Error(ERROR_PERSIST_FORMAT_REQUIRED); 53 | } 54 | 55 | if (errors.allOf) { 56 | throw new Error(ERROR_PERSIST_FORMAT_WRONG(errors.allOf.value)); 57 | } 58 | } 59 | 60 | return { 61 | ...config, 62 | persist: { ...config?.persist, format } 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/processes/generate-userflow.ts: -------------------------------------------------------------------------------- 1 | import { join, dirname } from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | import { mkdirSync, readdirSync } from 'node:fs'; 4 | import { RcJson } from '../../../types.js'; 5 | import { readFile, writeFile } from '../../../core/file/index.js'; 6 | import { log, logVerbose } from '../../../core/loggin/index.js'; 7 | import { FlowExampleMap, PROMPT_INIT_GENERATE_FLOW } from '../constants.js'; 8 | import { ifThenElse } from '../../../core/processing/behaviors.js'; 9 | import { CLIProcess } from '../../../core/processing/types.js'; 10 | import { confirmToProcess } from '../../../core/prompt/confirm-to-process.js'; 11 | import { exampleFlow } from '../static/basic-navigation.uf.js'; 12 | 13 | const exampleName = 'basic-navigation'; 14 | 15 | export function getExamplePathDest(folder: string): string { 16 | const fileName = FlowExampleMap[exampleName]; 17 | return join(folder, fileName); 18 | } 19 | 20 | export const userflowIsNotCreated = (cfg: RcJson) => { 21 | return readFile(getExamplePathDest(cfg.collect.ufPath)) === ''; 22 | }; 23 | 24 | async function generateUserFlow(cliCfg: RcJson): Promise { 25 | const ufPath = cliCfg.collect.ufPath; 26 | // DX create directory if it does ot exist 27 | try { 28 | readdirSync(ufPath); 29 | } catch (e) { 30 | mkdirSync(ufPath, { recursive: true }); 31 | } 32 | const tplFileName = FlowExampleMap[exampleName]; 33 | const exampleDestination = join(ufPath, tplFileName); 34 | 35 | if (readFile(exampleDestination) !== '') { 36 | logVerbose(`User flow ${exampleName} already generated under ${exampleDestination}.`); 37 | return Promise.resolve(cliCfg); 38 | } 39 | 40 | await writeFile(exampleDestination, exampleFlow); 41 | 42 | log(`setup user-flow for basic navigation in ${ufPath} successfully`); 43 | return Promise.resolve(cliCfg); 44 | } 45 | 46 | const interactiveAndGenerateFlowNotPassed = (interactive: boolean, generateFlow?: boolean) => { 47 | return () => interactive && generateFlow === undefined; 48 | } 49 | 50 | export function handleFlowGeneration({ generateFlow, interactive }: {interactive: boolean, generateFlow?: boolean}): CLIProcess { 51 | return ifThenElse( 52 | interactiveAndGenerateFlowNotPassed(interactive, generateFlow), 53 | confirmToProcess({ 54 | prompt: PROMPT_INIT_GENERATE_FLOW, 55 | process: generateUserFlow, 56 | precondition: userflowIsNotCreated 57 | }), 58 | // else `withFlow` is used and true 59 | ifThenElse(() => !!generateFlow, 60 | // generate the file => else do nothing 61 | generateUserFlow) 62 | ) 63 | } 64 | 65 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/processes/generate-workflow.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { existsSync, rmSync } from 'fs'; 3 | import { join } from 'path'; 4 | import { handleGhWorkflowGeneration } from './generate-workflow.js'; 5 | 6 | vi.mock('../../../core/loggin'); 7 | 8 | const expectedFilePath = join('.github', 'workflows','user-flow-ci.yml'); 9 | describe('generate GH workflow', () => { 10 | 11 | it('should create flow when --generateGhWorkflow is used', async () => { 12 | expect(existsSync(expectedFilePath)).toBeFalsy(); 13 | await handleGhWorkflowGeneration({generateGhWorkflow: true})({} as any); 14 | expect(existsSync(expectedFilePath)).toBeTruthy(); 15 | rmSync(expectedFilePath); 16 | }); 17 | 18 | it('should not create flow when --no-generateGhWorkflow is used', async () => { 19 | expect(existsSync(expectedFilePath)).toBeFalsy(); 20 | await handleGhWorkflowGeneration({generateGhWorkflow: false})({} as any); 21 | expect(existsSync(expectedFilePath)).toBeFalsy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/processes/generate-workflow.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync } from 'node:fs'; 2 | import { dirname, join } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import { RcJson } from '../../../types.js'; 5 | import { readFile, writeFile } from '../../../core/file/index.js'; 6 | import { log, logVerbose } from '../../../core/loggin/index.js'; 7 | import { GhWorkflowExampleMap } from '../constants.js'; 8 | import { ifThenElse } from '../../../core/processing/behaviors.js'; 9 | import { CLIProcess } from '../../../core/processing/types.js'; 10 | 11 | const exampleName = 'basic-workflow'; 12 | 13 | const destPath = join('.github', 'workflows'); 14 | export function getExamplePathDest(): string { 15 | const fileName = GhWorkflowExampleMap[exampleName]; 16 | if(!fileName) { 17 | throw new Error(`workflowExample ${exampleName} is not registered`); 18 | } 19 | return join(destPath, fileName); 20 | } 21 | 22 | export async function generateGhWorkflowFile(cliCfg: RcJson): Promise { 23 | const tplFileName = GhWorkflowExampleMap[exampleName]; 24 | const exampleSourceLocation = join(dirname(fileURLToPath(import.meta.url)), '..', 'static', tplFileName); 25 | const exampleDestination = getExamplePathDest(); 26 | 27 | if (readFile(exampleDestination) !== '') { 28 | logVerbose(`User flow ${exampleName} already generated under ${exampleDestination}.`); 29 | return Promise.resolve(cliCfg); 30 | } 31 | 32 | const fileContent = readFile(exampleSourceLocation, { fail: true }).toString(); 33 | if(!existsSync(destPath)) { 34 | mkdirSync(destPath, {recursive: true}); 35 | logVerbose(`setup workflow folder ${destPath}`); 36 | } 37 | 38 | await writeFile(exampleDestination, fileContent); 39 | 40 | log(`setup workflow for user-flow integration in the CI in ${exampleDestination} successfully`); 41 | return Promise.resolve(cliCfg); 42 | } 43 | 44 | export function handleGhWorkflowGeneration({ generateGhWorkflow }: { generateGhWorkflow?: boolean }): CLIProcess { 45 | return ifThenElse( 46 | // if `withFlow` is not used in the CLI is in interactive mode 47 | () => generateGhWorkflow === true, 48 | // generate the file => else do nothing 49 | generateGhWorkflowFile 50 | ); 51 | } 52 | 53 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/processes/outPath.setup.ts: -------------------------------------------------------------------------------- 1 | import {mkdirSync, readdirSync} from 'fs'; 2 | import {RcJson} from '../../../types.js'; 3 | import {get as interactive} from '../../../global/options/interactive.js'; 4 | import {promptParam} from '../../../core/prompt/prompt.js'; 5 | import {applyValidations, hasError, VALIDATORS} from '../../../core/validation/index.js'; 6 | import { 7 | DEFAULT_PERSIST_OUT_PATH, 8 | ERROR_PERSIST_OUT_PATH_REQUIRED, 9 | PROMPT_PERSIST_OUT_PATH 10 | } from '../../collect/options/outPath.constant.js'; 11 | 12 | export async function setupOutPath( 13 | config: RcJson 14 | ): Promise { 15 | 16 | let outPath = config?.persist?.outPath; 17 | 18 | if (interactive()) { 19 | outPath = await promptParam({ 20 | message: PROMPT_PERSIST_OUT_PATH, 21 | initial: outPath || DEFAULT_PERSIST_OUT_PATH, 22 | skip: !!outPath 23 | }); 24 | } 25 | 26 | const errors = applyValidations(outPath, [ 27 | VALIDATORS.required, 28 | ]); 29 | if (hasError(errors)) { 30 | throw new Error(ERROR_PERSIST_OUT_PATH_REQUIRED); 31 | } 32 | 33 | // DX create directory if it does ot exist 34 | try { 35 | readdirSync(outPath); 36 | } catch (e) { 37 | mkdirSync(outPath, {recursive: true}); 38 | } 39 | 40 | return { 41 | ...config, 42 | persist: { ...config?.persist, outPath } 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/processes/ufPath.setup.ts: -------------------------------------------------------------------------------- 1 | import { get as interactive } from '../../../global/options/interactive.js'; 2 | import { promptParam } from '../../../core/prompt/prompt.js'; 3 | import { applyValidations, hasError, VALIDATORS } from '../../../core/validation/index.js'; 4 | import { DEFAULT_COLLECT_UF_PATH, ERROR_COLLECT_UF_PATH_REQUIRED, PROMPT_COLLECT_UF_PATH } from '../../collect/options/ufPath.constant.js'; 5 | import { RcJson } from '../../../types.js'; 6 | 7 | export async function setupUfPath( 8 | config: RcJson, 9 | ): Promise { 10 | 11 | let ufPath = config?.collect?.ufPath; 12 | 13 | if (interactive()) { 14 | ufPath = await promptParam({ 15 | message: PROMPT_COLLECT_UF_PATH, 16 | initial: ufPath || DEFAULT_COLLECT_UF_PATH, 17 | skip: !!ufPath 18 | }); 19 | } 20 | 21 | const errors = applyValidations(ufPath, [ 22 | VALIDATORS.required 23 | ]); 24 | if (hasError(errors)) { 25 | throw new Error(ERROR_COLLECT_UF_PATH_REQUIRED); 26 | } 27 | 28 | 29 | return { 30 | ...config, 31 | collect: { ...config?.collect, ufPath } 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/processes/update-rc-json.ts: -------------------------------------------------------------------------------- 1 | import { updateRcConfig } from '../../../global/rc-json/index.js'; 2 | import { logVerbose } from '../../../core/loggin/index.js'; 3 | import { RcJson } from '../../../types.js'; 4 | 5 | export async function updateRcJson(config: RcJson): Promise { 6 | logVerbose(config); 7 | updateRcConfig(config); 8 | return config; 9 | } 10 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/processes/url.setup.ts: -------------------------------------------------------------------------------- 1 | import { get as interactive } from '../../../global/options/interactive.js'; 2 | import { DEFAULT_COLLECT_URL, ERROR_COLLECT_URL_REQUIRED, PROMPT_COLLECT_URL } from '../../collect/options/url.constant.js'; 3 | import { promptParam } from '../../../core/prompt/prompt.js'; 4 | import { applyValidations, hasError, VALIDATORS } from '../../../core/validation/index.js'; 5 | import { RcJson } from '../../../types.js'; 6 | 7 | export async function setupUrl( 8 | config: RcJson 9 | ): Promise { 10 | 11 | let url = config?.collect?.url; 12 | 13 | if (interactive()) { 14 | url = await promptParam({ 15 | message: PROMPT_COLLECT_URL, 16 | initial: url || DEFAULT_COLLECT_URL, 17 | skip: VALIDATORS.required(url) === null 18 | }); 19 | } 20 | 21 | const errors = applyValidations(url, [ 22 | VALIDATORS.required 23 | ]); 24 | if (hasError(errors)) { 25 | throw new Error(ERROR_COLLECT_URL_REQUIRED); 26 | } 27 | 28 | return { 29 | ...config, 30 | collect: { ...config?.collect, url } 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/static/basic-navigation.uf.ts: -------------------------------------------------------------------------------- 1 | export const exampleFlow = ` 2 | // Your custom interactions with the page 3 | import { UserFlowContext, UserFlowInteractionsFn, UserFlowProvider } from '@push-based/user-flow'; 4 | 5 | const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise => { 6 | const { flow, collectOptions } = ctx; 7 | const { url } = collectOptions; 8 | 9 | await flow.navigate(url, { 10 | name: \`Navigate to \${url}\`, 11 | }); 12 | 13 | // ℹ Tip: 14 | // Read more about the other measurement modes here: 15 | // https://github.com/push-based/user-flow/blob/main/packages/cli/docs/writing-basic-user-flows.md 16 | 17 | }; 18 | 19 | export default { 20 | flowOptions: {name: 'Basic Navigation Example'}, 21 | interactions 22 | } satisfies UserFlowProvider; 23 | `; 24 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/static/user-flow-ci.yml: -------------------------------------------------------------------------------- 1 | name: user-flow-ci 2 | on: 3 | pull_request: 4 | jobs: 5 | user-flow: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node-version: [18.x] 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Executing user-flow CLI 13 | uses: push-based/user-flow-gh-action@v0.1.0 14 | -------------------------------------------------------------------------------- /packages/cli/src/lib/commands/init/utils.ts: -------------------------------------------------------------------------------- 1 | import { CollectRcOptions, PersistRcOptions, ReportFormat } from '../collect/options/types.js'; 2 | import { InitOptions } from './options.js'; 3 | import { REPORT_FORMAT_VALUES } from '../collect/constants.js'; 4 | 5 | const isValidFormat = (value: any): value is ReportFormat => REPORT_FORMAT_VALUES.includes(value); 6 | 7 | function sanitizedFormats(formats: string[]) { 8 | const validatedFormats: ReportFormat[] = formats.filter(isValidFormat); 9 | if (validatedFormats.length !== formats.length) { 10 | throw new Error(`${formats} contains invalid format options`); 11 | } 12 | return validatedFormats; 13 | } 14 | 15 | export function getInitCommandOptionsFromArgv(argv: InitOptions) { 16 | let { 17 | generateFlow, generateGhWorkflow, lhr, 18 | url, ufPath, serveCommand, awaitServeStdout, 19 | outPath, format 20 | } = argv; 21 | 22 | let collect = {} as CollectRcOptions; 23 | url && (collect.url = url); 24 | ufPath && (collect.ufPath = ufPath); 25 | // optional 26 | serveCommand && (collect.serveCommand = serveCommand); 27 | awaitServeStdout && (collect.awaitServeStdout = awaitServeStdout); 28 | 29 | let persist = {} as PersistRcOptions; 30 | outPath && (persist.outPath = outPath); 31 | format && (persist.format = sanitizedFormats(format)); 32 | 33 | return { collect, persist, generateFlow, generateGhWorkflow, lhr }; 34 | } 35 | -------------------------------------------------------------------------------- /packages/cli/src/lib/config.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | import yargs from '../lib/core/yargs/instance.js'; 3 | import { GlobalOptionsArgv } from './global/options/types.js'; 4 | import { logVerbose } from './core/loggin/index.js'; 5 | import { detectCliMode } from './global/cli-mode/index.js'; 6 | 7 | export function applyConfigMiddleware(handler: (...args: any) => void, configParser: Options['configParser']) { 8 | return () => { 9 | yargs.config((configParser as any)()); 10 | const { interactive, verbose, rcPath } = yargs.argv as unknown as GlobalOptionsArgv; 11 | logVerbose('CLI Mode: ', detectCliMode()); 12 | logVerbose('Global options: ', { interactive, verbose, rcPath }); 13 | return handler(yargs.argv as any); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /packages/cli/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import {join} from "node:path"; 2 | 3 | export const DEFAULT_RC_NAME = '.user-flowrc.json'; 4 | export const DEFAULT_RC_PATH = `./`; 5 | export const DEFAULT_FULL_RC_PATH = join(DEFAULT_RC_PATH, DEFAULT_RC_NAME); 6 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/file/to-file-name.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { toFileName } from './to-file-name.js'; 3 | 4 | describe('toFileName', () => { 5 | 6 | it('should escape a URL', () => { 7 | const url = 'www.test.com'; 8 | const httpUrl = 'http://www.test.com'; 9 | const httpsUrl = 'https://www.test.com'; 10 | expect(toFileName(url)).toEqual(url); 11 | expect(toFileName(httpUrl)).toEqual(url); 12 | expect(toFileName(httpsUrl)).toEqual(url); 13 | }); 14 | 15 | it('should escape a URL and port', () => { 16 | const url = 'www.test.com'; 17 | const urlAndPort = `https://${url}:4200`; 18 | expect(toFileName(urlAndPort)).toEqual(url + '-' + 4200); 19 | }); 20 | 21 | 22 | it('should escape a folder name', () => { 23 | const folder = 'my-folder-name'; 24 | const folder2 = 'myFolderName'; 25 | const folder3 = 'my folder name'; 26 | const folder4 = 'my Folder Name'; 27 | expect(toFileName(folder)).toEqual(folder); 28 | expect(toFileName(folder2)).toEqual(folder); 29 | expect(toFileName(folder3)).toEqual(folder); 30 | expect(toFileName(folder4)).toEqual(folder); 31 | }); 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/file/to-file-name.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Upper or camelCase to lowercase hyphenated 4 | */ 5 | export function toFileName(s: string): string { 6 | return s 7 | // if url 8 | .replace(/((http)s*:\/\/)/g, '') 9 | // if url port 10 | .replace(/:/, '-') 11 | // if camelcase split with "_" 12 | .replace(/([a-z\d])([A-Z])/g, '$1_$2') 13 | // make all letters lowercase 14 | .toLowerCase() 15 | // replace " ", "/" and "_" 16 | .replace(/[ _\/]/g, '-') 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/file/types.ts: -------------------------------------------------------------------------------- 1 | import { ReadFileConfig } from '../../commands/collect/utils/replay/types.js'; 2 | 3 | type GetDefinedType< 4 | T extends {} | undefined, 5 | K extends keyof T | undefined 6 | > = T extends undefined ? never : 7 | K extends undefined ? never 8 | : K extends keyof T ? T[K] : never; 9 | /* 10 | //type undef = GetDefinedType; // never 11 | //type r = GetDefinedType; // never 12 | //type t = GetDefinedType; // never 13 | //type u = GetDefinedType<{ ext: undefined }>; // never 14 | //type l = GetDefinedType<{ ext: string }>; // never 15 | type v = GetDefinedType<{ po: string }, 'po'>; // string 16 | */ 17 | 18 | 19 | 20 | /* 21 | type ExtToOutPut = GetDefinedType extends CFG ? 22 | GetDefinedType['ext'] extends string ? GetDefinedType['ext'] extends 'json' ? {} : never; 23 | */ 24 | 25 | export type ExtToOutPut = 26 | // if cfg is given 27 | GetDefinedType extends never ? string : 28 | // if ext prop present 29 | CFG['ext'] extends 'json' ? {} 30 | : never; 31 | /* 32 | type aaa = ExtToOutPut<{}>; // string 33 | type b = ExtToOutPut<{ ext: 'json' }>; // {} 34 | type a = ExtToOutPut; // never 35 | //type c = ExtToOutPut<{ ext: 'wrongExt' }>; // never 36 | //type aa = ExtToOutPut; // never 37 | // type f = ExtToOutPut<{ext: 234}>; // never 38 | // type h = ExtToOutPut<{ext: 'sda'}>; // never 39 | */ 40 | 41 | export type ResolveFileResult = { exports: T; path: string }; 42 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/loggin/index.ts: -------------------------------------------------------------------------------- 1 | import { get as verbose } from '../../global/options/verbose.js'; 2 | 3 | /** 4 | * logs messages only if the CLI parameter -v or --verbose is passed as true 5 | * 6 | * @example 7 | * user-flow collect -v // log is present 8 | * user-flow collect -v=false // log is NOT present 9 | * 10 | * @param message 11 | */ 12 | export function logVerbose(...message: Array>): void { 13 | if (verbose()) { 14 | return console.log(...message); 15 | } 16 | } 17 | 18 | /** 19 | * logs messages to the CLI independent if the parameter -v or --verbose is passed as true or not 20 | * 21 | * @example 22 | * user-flow collect -v // log is present 23 | * user-flow collect -v=false // log is present 24 | * 25 | * @param message 26 | */ 27 | export function log(...message: Array>): void { 28 | return console.log(...message); 29 | } 30 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/md/code.ts: -------------------------------------------------------------------------------- 1 | import { NEW_LINE } from './constants.js'; 2 | 3 | /** 4 | * ```{format} 5 | * {code} 6 | * ``` 7 | * or with inline set to `true` 8 | * `{code}` 9 | */ 10 | export function code(code: string, cfg: {format: string}): string { 11 | if(cfg?.format === 'inline') { 12 | return `\`${code}\``; 13 | } 14 | const format = cfg?.format || 'javascript'; 15 | return ` 16 | \`\`\`${format}${NEW_LINE} 17 | ${code} 18 | \`\`\` ${NEW_LINE} 19 | ${NEW_LINE}`; 20 | } 21 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/md/constants.ts: -------------------------------------------------------------------------------- 1 | export const NEW_LINE = "\r\n"; 2 | export const SNIPPET_AREA_START = ''; 3 | export const SNIPPET_AREA_END = ''; 4 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/md/details.ts: -------------------------------------------------------------------------------- 1 | import { NEW_LINE } from './constants.js'; 2 | 3 | /** 4 | *
5 | * {title} 6 | * {content} 7 | *
8 | */ 9 | export function details(title: string, content: string, cfg: { open: boolean } = { open: false }): string { 10 | return `
${NEW_LINE} 11 | ${title}${NEW_LINE} 12 | ${content}${NEW_LINE} 13 |
${NEW_LINE}`; 14 | } 15 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/md/font-style.ts: -------------------------------------------------------------------------------- 1 | const stylesMap = { 2 | i: '*', // italic 3 | b: '**', // bold 4 | s: '~', // strike through 5 | } as const; 6 | 7 | export type FontStyle = keyof typeof stylesMap; 8 | 9 | /** 10 | * **{text}** // default is bold 11 | * 12 | * *{text}* // italic - styles set to `['i']` 13 | * 14 | * ~**{text}**~ // bold & stroke-through - styles set to `['b','s']` 15 | */ 16 | export function style(text: string, styles: FontStyle[] = ['b']): string { 17 | return styles.reduce((t, s) => `${stylesMap[s]}${t}${stylesMap[s]}`, text); 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/md/headline.ts: -------------------------------------------------------------------------------- 1 | export type Hierarchy = 1 | 2 | 3 | 4 | 5 | 6; 2 | 3 | /** 4 | * \# {text} // hierarchy set to 1 5 | * 6 | * \## {text} // hierarchy set to 2 7 | */ 8 | export function headline(text: string, hierarchy: Hierarchy = 1): string { 9 | return `${new Array(hierarchy).fill('#').join('')} ${text}`; 10 | } 11 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/md/table.ts: -------------------------------------------------------------------------------- 1 | import { formatCode } from '../prettier/index.js'; 2 | 3 | export type Alignment = 'l' | 'c' | 'r'; 4 | const alignString = new Map([['l', ':--'],['c', ':--:'],['r', '--:']]); 5 | 6 | /** 7 | * | Table Header 1 | Table Header 2 | 8 | * | --------------- | -------------- | 9 | * | String 1 | 1 | 10 | * | String 1 | 2 | 11 | * | String 1 | 3 | 12 | */ 13 | export async function table(data: (string | number)[][], align?: Alignment[]): Promise { 14 | align = align || data[0].map(_ => 'c'); 15 | const _data = data.map((arr) => arr.join('|')); 16 | const secondRow = align.map((s) => alignString.get(s)).join('|'); 17 | return await formatCode(_data.shift() + '\n' + secondRow + '\n' + _data.join('\n'), 'markdown'); 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/prettier/constants.ts: -------------------------------------------------------------------------------- 1 | export const supportedExtname = ['yml', 'yaml', 'css' , 'html' , 'json' , 'less' , 'scss' , 'md' , 'ts' , 'mts', 'js']; 2 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/prettier/index.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | import Prettier, { Options as PrettierOptions } from 'prettier'; 4 | import { SupportedExtname, SupportedParser } from './types.js'; 5 | import { supportedExtname } from './constants.js'; 6 | 7 | export function getParserFromExtname(extname: SupportedExtname | string): SupportedParser { 8 | extname = extname[0] === '.' ? extname.slice(1, extname.length) : extname; 9 | 10 | if (!supportedExtname.includes(extname)) { 11 | throw new Error(`Extension name ${extname} is not supported.`); 12 | } 13 | 14 | return (['md', 'ts', 'mts', 'js', 'yml'].includes(extname) ? ({ 15 | md: 'markdown', 16 | ts: 'typescript', 17 | js: 'javascript', 18 | yml: 'yaml' 19 | } as any)[extname] : extname) as any as SupportedParser; 20 | } 21 | 22 | /** 23 | * Code formatter that uses prettier under the hood 24 | * 25 | * @param code 26 | * @param parser 27 | */ 28 | export async function formatCode( 29 | code: string, 30 | parser: PrettierOptions['parser'] = 'typescript' 31 | ) { 32 | const prettierConfig = await Prettier.resolveConfig(dirname(fileURLToPath(import.meta.url))); 33 | const content = await Prettier.format(code, { parser, ...prettierConfig }) 34 | return content.trim(); 35 | } 36 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/prettier/types.ts: -------------------------------------------------------------------------------- 1 | import { BuiltInParserName } from 'prettier'; 2 | 3 | export type SupportedParser = Extract; 4 | 5 | type SupportedParserNotExtname = Extract; 6 | export type SupportedExtname = Exclude | 'md' | 'ts' | 'js' | 'yml'; 7 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/processing/behaviors.ts: -------------------------------------------------------------------------------- 1 | import { CLIProcess } from './types.js'; 2 | import { RcJson } from '../../types.js'; 3 | 4 | export function run( 5 | tasks: CLIProcess[] 6 | ): CLIProcess { 7 | return (config) => concat(tasks)(config); 8 | } 9 | 10 | export function concat(processes: CLIProcess[]): CLIProcess { 11 | return async function(r: RcJson): Promise { 12 | return await processes.reduce( 13 | async (cfg, processor) => await processor(await cfg), 14 | Promise.resolve(r) 15 | ); 16 | }; 17 | } 18 | 19 | 20 | export function ifThenElse(condition: (r: RcJson) => boolean, thenProcess: CLIProcess, elseProcess: CLIProcess = (_: RcJson) => Promise.resolve(_)): CLIProcess { 21 | return async function(r: RcJson): Promise { 22 | const conditionResult = await condition(r); 23 | if (conditionResult) { 24 | return thenProcess(r); 25 | } else { 26 | return elseProcess ? elseProcess(r) : Promise.resolve(r); 27 | } 28 | }; 29 | } 30 | 31 | export function tap(process: CLIProcess): CLIProcess { 32 | return async function(d: RcJson): Promise { 33 | await process(d); 34 | return Promise.resolve(d); 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/processing/types.ts: -------------------------------------------------------------------------------- 1 | import { RcJson } from '../../types.js'; 2 | 3 | export interface CLIProcess { 4 | (cfg: RcJson): Promise; 5 | } 6 | 7 | export interface Process { 8 | (_?: any): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/prompt/confirm-to-process.ts: -------------------------------------------------------------------------------- 1 | import { promptParam } from './prompt.js'; 2 | import { CLIProcess } from '../processing/types.js'; 3 | import { RcJson } from '../../types.js'; 4 | 5 | type Precondition = (context: RcJson) => Promise | boolean; 6 | 7 | type ConfirmProcess = { 8 | prompt: string, 9 | process: CLIProcess, 10 | denied?: CLIProcess 11 | precondition?: Precondition, 12 | } 13 | 14 | const confirmedPrompt = async (prompt: string): Promise => { 15 | return await promptParam({ 16 | type: 'confirm', 17 | message: prompt, 18 | initial: true, 19 | }); 20 | } 21 | 22 | 23 | const isPreconditionMet = async (context: RcJson, precondition?: Precondition): Promise => { 24 | return precondition === undefined ? true : precondition(context); 25 | } 26 | 27 | export function confirmToProcess({ prompt, process, precondition }: ConfirmProcess): CLIProcess { 28 | return async (context: RcJson): Promise => { 29 | 30 | const shouldPrompt = await isPreconditionMet(context, precondition); 31 | if (!shouldPrompt) { 32 | return context; 33 | } 34 | 35 | const shouldProcess = await confirmedPrompt(prompt); 36 | if (!shouldProcess) { 37 | return context; 38 | } 39 | 40 | return process(context); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/prompt/confirm-to-process.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, beforeEach, vi } from 'vitest'; 2 | import { confirmToProcess } from './confirm-to-process.js'; 3 | import { RcJson } from '../../types.js'; 4 | import * as prompt from './prompt.js'; 5 | 6 | vi.mock('./prompt'); 7 | 8 | describe('confirmToProcess', () => { 9 | 10 | beforeEach(() => { 11 | vi.clearAllMocks(); 12 | }) 13 | 14 | it('should check if precondition is met if one is given', async () => { 15 | const mockPrecondition = vi.fn(); 16 | await confirmToProcess({ 17 | prompt: 'Confirm should process?', 18 | process: vi.fn(), 19 | precondition: mockPrecondition, 20 | })({} as RcJson); 21 | expect(mockPrecondition).toHaveBeenCalled(); 22 | }); 23 | 24 | it('should not prompt if precondition is not met', async () => { 25 | const promptParamSpy = vi.spyOn(prompt, 'promptParam'); 26 | await confirmToProcess({ 27 | prompt: 'Confirm should process?', 28 | process: vi.fn(), 29 | precondition: () => false, 30 | })({} as RcJson); 31 | expect(promptParamSpy).not.toHaveBeenCalled(); 32 | }); 33 | 34 | it('should prompt if precondition is met', async () => { 35 | const promptParamSpy = vi.spyOn(prompt, 'promptParam'); 36 | await confirmToProcess({ 37 | prompt: 'Confirm should process?', 38 | process: vi.fn(), 39 | precondition: () => true, 40 | })({} as RcJson); 41 | expect(promptParamSpy).toHaveBeenCalled(); 42 | }); 43 | 44 | it('should prompt if no precondition is passed', async () => { 45 | const promptParamSpy = vi.spyOn(prompt, 'promptParam'); 46 | await confirmToProcess({ 47 | prompt: 'Confirm should process?', 48 | process: vi.fn(), 49 | })({} as RcJson); 50 | expect(promptParamSpy).toHaveBeenCalled(); 51 | }); 52 | 53 | it('should not process if prompt is denied', async () => { 54 | vi.spyOn(prompt, 'promptParam').mockResolvedValue(false); 55 | const processSpy = vi.fn(); 56 | await confirmToProcess({ 57 | prompt: 'Confirm should process?', 58 | process: processSpy, 59 | })({} as RcJson); 60 | expect(processSpy).not.toHaveBeenCalled(); 61 | }); 62 | 63 | it('should process if prompt is accepted', async () => { 64 | vi.spyOn(prompt, 'promptParam').mockResolvedValue(true); 65 | const processSpy = vi.fn(); 66 | await confirmToProcess({ 67 | prompt: 'Confirm should process?', 68 | process: processSpy, 69 | })({} as RcJson); 70 | expect(processSpy).toHaveBeenCalled(); 71 | }); 72 | }) 73 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/prompt/prompt.ts: -------------------------------------------------------------------------------- 1 | import Enquirer from 'enquirer'; 2 | 3 | export async function promptParam(cfg: {initial?: T, skip?: boolean, message: string, type?: any, [key: string]: any}): Promise { 4 | const { type, initial, message, skip, result } = cfg; 5 | 6 | const { param } = await Enquirer.prompt<{ param: T }>([{ 7 | name: 'param', 8 | type: type || 'input', 9 | initial, 10 | message, 11 | skip, 12 | result 13 | }]); 14 | 15 | return param; 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/types.ts: -------------------------------------------------------------------------------- 1 | /* @TODO add description => explain functionality*/ 2 | export type Modify = Omit & R; 3 | 4 | 5 | /** 6 | * Type to pluck out a key value pain of an existing type 7 | * 8 | * @example 9 | * 10 | * type Example = { 11 | * key1: number; 12 | * key2: string; 13 | * } 14 | * 15 | * type Example2 = PickOne; 16 | * 17 | * const a: Example2 = { key1: 1 } // correct 18 | * const b: Example2 = { key1: "1" } // incorrect - string cannot be assigned to number 19 | * const c: Example2 = { key2: 'a' } // correct 20 | * const d: Example2 = { key3: 'a' } // incorrect - unknown property 21 | * 22 | */ 23 | export type PickOne = { 24 | [P in keyof T]?: Record 25 | }[keyof T] 26 | 27 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/validation/index.ts: -------------------------------------------------------------------------------- 1 | import {Error, ValidatorFn} from './types.js'; 2 | 3 | export function applyValidations(value: T, validators: ValidatorFn[]): Error { 4 | return validators.reduce((errors, validator) => { 5 | return { ...errors, ...(validator(value) || {}) }; 6 | }, {}); 7 | } 8 | 9 | export function hasError(errors: Error): boolean { 10 | return Object.entries(errors).length > 0; 11 | } 12 | 13 | const oneOf = (set: string[]) => (value: string) => { 14 | return (set.find(i => { 15 | return i === value 16 | }) === undefined) ? { 17 | oneOf: { value } 18 | } 19 | : null; 20 | } 21 | export const VALIDATORS = { 22 | required: (value: string) => value !== undefined && value !== '' ? null : { required: true }, 23 | oneOf, 24 | allOf: (set: string[]) => (values: string[]) => { 25 | const _oneOf = oneOf(set); 26 | let errors: { allOf: { value: string } } | null = null 27 | values.forEach((value: string) => { 28 | const e = _oneOf(value); 29 | if (e) { 30 | errors = { 31 | allOf: { value }, 32 | }; 33 | } 34 | }); 35 | return errors; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/validation/types.ts: -------------------------------------------------------------------------------- 1 | export type Error = any; 2 | export type ValidatorFn = (value: any, ctx?: any) => Error | null; 3 | export type ValidatorFnFactory = (cfg: any) => (value: any, ctx?: any) => Error | null; 4 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/yargs/index.ts: -------------------------------------------------------------------------------- 1 | import yargs from './instance.js' 2 | import { Options } from 'yargs'; 3 | import { YargsCommandObject } from './types.js'; 4 | import { applyConfigMiddleware } from '../../config.middleware.js'; 5 | 6 | export function setupYargs( 7 | commands: YargsCommandObject[], 8 | options: { [key: string]: Options }, 9 | configParser: Options['configParser'] 10 | ) { 11 | yargs.options(options) 12 | .parserConfiguration({ 'boolean-negation': true }) 13 | .recommendCommands() 14 | .example([ 15 | ['init', 'Setup user-flows over prompts'] 16 | ]) 17 | .help() 18 | .alias('h', 'help'); 19 | 20 | commands.forEach((command) => yargs.command( 21 | command.command, 22 | command.description, 23 | command?.builder || (() => { 24 | }), 25 | applyConfigMiddleware(command.module.handler, configParser) 26 | )); 27 | return yargs; 28 | } 29 | 30 | export function runCli(cliCfg: { 31 | commands: YargsCommandObject[]; 32 | options: { [key: string]: Options }; 33 | configParser: Options['configParser']; 34 | }) { 35 | // apply `.argv` to get args as plain obj available 36 | setupYargs(cliCfg.commands, cliCfg.options, cliCfg.configParser).argv; 37 | } 38 | 39 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/yargs/instance.ts: -------------------------------------------------------------------------------- 1 | import Yargs from 'yargs/yargs'; 2 | import { hideBin } from 'yargs/helpers'; 3 | import { argv } from 'node:process'; 4 | 5 | const yargs = Yargs(hideBin(argv)); 6 | 7 | export default yargs; 8 | -------------------------------------------------------------------------------- /packages/cli/src/lib/core/yargs/types.ts: -------------------------------------------------------------------------------- 1 | import { Argv, CommandModule, Options } from 'yargs'; 2 | 3 | export interface YargsCommandObject { 4 | command: string | ReadonlyArray; 5 | description: string; 6 | builder?: (y: Argv) => any; 7 | module: CommandModule; 8 | } 9 | 10 | export type YargsOptionTypeToTsType = T extends 'array' ? Array : 11 | T extends 'count' | 'number' ? number : 12 | T extends 'string' ? string : 13 | T extends 'boolean' ? boolean : 14 | T extends undefined ? undefined : never; 15 | export type YargsArgvOptionFromParamsOptions = { [key in keyof T]: YargsOptionTypeToTsType } 16 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/cli-mode/cli.mode.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it } from 'vitest'; 2 | import { CI_PROPERTY, CLI_MODE_PROPERTY, detectCliMode, isEnvCi, isEnvSandbox } from './cli-mode.js'; 3 | import { CLI_MODES } from './types.js'; 4 | 5 | function setupEnvVars(env: CLI_MODES): void { 6 | if (env === 'DEFAULT') { 7 | delete process.env[CI_PROPERTY]; 8 | } else { 9 | process.env[CI_PROPERTY] = (env === 'CI' ? true : 'SANDBOX') as string; 10 | } 11 | } 12 | 13 | function teardownEnvVars() { 14 | delete process.env[CI_PROPERTY]; 15 | delete process.env[CLI_MODE_PROPERTY]; 16 | } 17 | 18 | describe('isEnvCi', () => { 19 | 20 | afterEach(teardownEnvVars); 21 | // This will only pass run in CI 22 | it('should return true in the CI', () => { 23 | setupEnvVars('CI'); 24 | expect(isEnvCi()).toBe(true); 25 | }); 26 | 27 | it('should return false if sandbox mode is configured', () => { 28 | setupEnvVars('SANDBOX'); 29 | expect(isEnvCi()).toBe(false); 30 | }); 31 | 32 | it('should return false on a local machine', () => { 33 | teardownEnvVars(); 34 | expect(isEnvCi()).toBe(false); 35 | }); 36 | 37 | }); 38 | 39 | describe('isEnvSandbox', () => { 40 | 41 | afterEach(teardownEnvVars); 42 | 43 | it('should return true if sandbox is configured', () => { 44 | setupEnvVars('SANDBOX'); 45 | expect(isEnvSandbox()).toBe(true); 46 | }); 47 | 48 | it('should return false if sandbox mode is NOT configured', () => { 49 | setupEnvVars('CI'); 50 | expect(isEnvSandbox()).toBe(false); 51 | }); 52 | 53 | }); 54 | 55 | describe('detectCliMode', () => { 56 | afterEach(teardownEnvVars); 57 | 58 | it('should return SANDBOX if sandbox is configured', () => { 59 | setupEnvVars('SANDBOX'); 60 | expect(isEnvSandbox()).toBe(true); 61 | }); 62 | 63 | it('should return CI if CI', () => { 64 | setupEnvVars('CI'); 65 | expect(isEnvSandbox()).toBe(false); 66 | }); 67 | 68 | it('should return DEFAULT by default', () => { 69 | setupEnvVars('CI'); 70 | expect(isEnvSandbox()).toBe(false); 71 | }); 72 | 73 | }); 74 | 75 | describe('detectCliMode', () => { 76 | afterEach(teardownEnvVars); 77 | 78 | it('should return SANDBOX if it is configured', () => { 79 | setupEnvVars('SANDBOX'); 80 | expect(detectCliMode()).toBe('SANDBOX'); 81 | }); 82 | 83 | // This will only pass run in CI 84 | it('should return CI in the CI', () => { 85 | expect(process.env[CLI_MODE_PROPERTY]).toBe(undefined); 86 | setupEnvVars('CI'); 87 | expect(detectCliMode()).toBe('CI'); 88 | 89 | }); 90 | 91 | it('should return DEFAULT if it is no CI or sandbox environment', () => { 92 | teardownEnvVars(); 93 | expect(detectCliMode()).toBe('DEFAULT'); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/cli-mode/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cli-mode.js' 2 | export * from './types.js' 3 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/cli-mode/types.ts: -------------------------------------------------------------------------------- 1 | export type CLI_MODES = 'DEFAULT' | 'CI' | 'SANDBOX'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/options/index.ts: -------------------------------------------------------------------------------- 1 | import { InferredOptionTypes, Options } from 'yargs'; 2 | 3 | import { verbose, get as getVerbose } from './verbose.js'; 4 | import { rcPath, get as getRcPath } from '../rc-json/options/rc.js'; 5 | import { interactive, get as getInteractive } from './interactive.js'; 6 | 7 | export const GLOBAL_OPTIONS_YARGS_CFG = { 8 | verbose, 9 | rcPath, 10 | interactive 11 | } satisfies Record; 12 | export type GlobalCliOptions = InferredOptionTypes; 13 | 14 | export const globalOptions = { 15 | getVerbose, 16 | getRcPath, 17 | getInteractive 18 | }; 19 | 20 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/options/interactive.model.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | import { Modify } from '../../core/types.js'; 3 | 4 | export type Param = { 5 | interactive: Modify 9 | }; 10 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/options/interactive.ts: -------------------------------------------------------------------------------- 1 | import yargs from '../../core/yargs/instance.js'; 2 | import { Options } from 'yargs'; 3 | import { getEnvPreset } from '../../pre-set.js'; 4 | 5 | export const interactive = { 6 | alias: 'i', 7 | type: 'boolean', 8 | description: 'When false questions are skipped with the values from the suggestions. This is useful for CI integrations.', 9 | default: getEnvPreset().interactive, 10 | } satisfies Options; 11 | 12 | export function get(): boolean { 13 | const { interactive } = yargs.argv as any as { interactive: boolean }; 14 | return interactive; 15 | } 16 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/options/types.ts: -------------------------------------------------------------------------------- 1 | import { Param as Verbose } from './verbose.model.js'; 2 | import { Param as Rc} from '../rc-json/options/rc.model.js'; 3 | import { Param as Interactive} from './interactive.model.js'; 4 | import { YargsArgvOptionFromParamsOptions } from '../../core/yargs/types.js'; 5 | 6 | export type CoreOptions = Verbose & Rc & Interactive; 7 | export type GlobalOptionsArgv = YargsArgvOptionFromParamsOptions; 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/options/verbose.model.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | import { Modify } from '../../core/types.js'; 3 | 4 | export type Param = { 5 | verbose: Modify 9 | }; 10 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/options/verbose.ts: -------------------------------------------------------------------------------- 1 | import yargs from '../../core/yargs/instance.js'; 2 | import { Options } from 'yargs'; 3 | import { getEnvPreset } from '../../pre-set.js'; 4 | 5 | export const verbose = { 6 | alias: 'v', 7 | type: 'boolean', 8 | description: 'Run with verbose logging', 9 | default: getEnvPreset()['verbose'] 10 | } satisfies Options; 11 | 12 | // We are in the process of removing this getter 13 | export function get(): boolean { 14 | const {verbose} = yargs.argv as any as { verbose?: boolean }; 15 | if (verbose === undefined) { 16 | throw new Error('Error extracting verbose cli argument'); 17 | } 18 | return verbose as boolean; 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/rc-json/index.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from '../../core/file/index.js'; 2 | import { logVerbose } from '../../core/loggin/index.js'; 3 | import { RcJson } from '../../types.js'; 4 | import { globalOptions } from '../options/index.js'; 5 | 6 | export function readRcConfig(rcPath: string = '', options?: { 7 | fail?: boolean, 8 | fallback?: {} 9 | }): RcJson { 10 | let { fail, fallback } = options || {}; 11 | fallback = fallback || {}; 12 | rcPath = rcPath || globalOptions.getRcPath(); 13 | let repoConfigJson = readFile(rcPath, { ext: 'json', fail: !!fail }); 14 | if (!repoConfigJson) { 15 | repoConfigJson = fallback as RcJson; 16 | } 17 | return repoConfigJson; 18 | } 19 | 20 | export function updateRcConfig(config: RcJson, rcPath: string = ''): void { 21 | rcPath = rcPath || globalOptions.getRcPath(); 22 | // NOTICE: this is needed for better git flow. 23 | // Touch a file only if needed 24 | if (JSON.stringify(readRcConfig()) !== JSON.stringify(config)) { 25 | writeFile(rcPath, JSON.stringify(config)); 26 | logVerbose(`Update config under ${rcPath} to`, config); 27 | } 28 | } 29 | 30 | export function getCliOptionsFromRcConfig(rcPath?: string): T { 31 | const { collect, persist } = readRcConfig(rcPath || globalOptions.getRcPath()); 32 | return { ...collect, ...persist } as unknown as T; 33 | } 34 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/rc-json/options/rc.model.ts: -------------------------------------------------------------------------------- 1 | import { Options } from 'yargs'; 2 | import { Modify } from '../../../core/types.js'; 3 | 4 | export type Param = { 5 | rcPath: Modify 9 | }; 10 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/rc-json/options/rc.ts: -------------------------------------------------------------------------------- 1 | import yargs from '../../../core/yargs/instance.js'; 2 | import { Options } from 'yargs'; 3 | import { getEnvPreset } from '../../../pre-set.js'; 4 | 5 | export const rcPath = { 6 | alias: 'p', 7 | type: 'string', 8 | description: 'Path to user-flow.config.json. e.g. `./user-flowrc.json`', 9 | default: getEnvPreset().rcPath 10 | } satisfies Options; 11 | 12 | // We don't rely on yargs option normalization features as this can happen before cli bootstrap 13 | export function get(): string { 14 | const { rcPath } = yargs.argv as unknown as { rcPath: string }; 15 | return rcPath as string 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/lib/global/utils.ts: -------------------------------------------------------------------------------- 1 | import { GlobalOptionsArgv } from './options/types.js'; 2 | 3 | export function getGlobalOptionsFromArgv(argv: any): Partial { 4 | const { rcPath, interactive, verbose } = argv; 5 | 6 | let globalOptions = {} as GlobalOptionsArgv; 7 | rcPath && (globalOptions.rcPath = rcPath); 8 | interactive && (globalOptions.interactive = interactive); 9 | verbose && (globalOptions.verbose = verbose); 10 | 11 | return globalOptions; 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { readRcConfig } from './global/rc-json/index.js'; 2 | export { ERROR_PERSIST_FORMAT_WRONG } from './commands/collect/options/format.constant.js'; 3 | export { getGlobalOptionsFromArgv } from './global/utils.js'; 4 | export { SETUP_CONFIRM_MESSAGE } from './commands/init/constants.js'; 5 | export { PROMPT_COLLECT_URL } from './commands/collect/options/url.constant.js'; 6 | export { getStepsTable } from './commands/assert/utils/md-report.js'; 7 | export { PROMPT_COLLECT_UF_PATH } from './commands/collect/options/ufPath.constant.js'; 8 | export { PROMPT_PERSIST_OUT_PATH } from './commands/collect/options/outPath.constant.js'; 9 | export { PROMPT_PERSIST_FORMAT } from './commands/collect/options/format.constant.js'; 10 | export { getInitCommandOptionsFromArgv } from './commands/init/utils.js'; 11 | export { UserFlowMock } from './commands/collect/utils/user-flow/user-flow.mock.js'; 12 | export { Ufo } from './ufo/ufo.js'; 13 | export { 14 | StepOptions, UserFlowOptions, LaunchOptions, UserFlowContext, UserFlowProvider, UserFlowInteractionsFn 15 | } from './commands/collect/utils/user-flow/types.js'; 16 | export { createUserFlowRunner } from './commands/collect/utils/replay/index.js'; 17 | export { MeasureModes } from './commands/collect/utils/replay/types.js'; 18 | export { RcJson } from './types.js'; 19 | export { CLI_MODES, CI_PROPERTY, CLI_MODE_PROPERTY } from './global/cli-mode/index.js'; 20 | export { getEnvPreset, SANDBOX_PRESET, CI_PRESET, DEFAULT_PRESET } from './pre-set.js'; 21 | export { GlobalOptionsArgv } from './global/options/types.js'; 22 | export { CollectCommandArgv, CollectArgvOptions } from './commands/collect/options/types.js'; 23 | export { DEFAULT_RC_NAME } from './constants.js'; 24 | export { DEFAULT_COLLECT_URL } from './commands/collect/options/url.constant.js'; 25 | export { DEFAULT_COLLECT_UF_PATH } from './commands/collect/options/ufPath.constant.js'; 26 | export { DEFAULT_PERSIST_OUT_PATH } from './commands/collect/options/outPath.constant.js'; 27 | export { ReportFormat } from './commands/collect/options/types.js'; 28 | export { createReducedReport } from './commands/collect/utils/report/utils.js'; 29 | export {enrichReducedReportWithBaseline} from './commands/collect/utils/report/utils.js'; 30 | export {runInitCommand} from './commands/init/command-impl.js'; 31 | export {runCollectCommand} from './commands/collect/command-impl.js'; 32 | -------------------------------------------------------------------------------- /packages/cli/src/lib/pre-set.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, afterEach, it, expect} from 'vitest'; 2 | 3 | import { CI_PRESET, DEFAULT_PRESET, getEnvPreset, SANDBOX_PRESET } from './pre-set.js'; 4 | import { CI_PROPERTY, CLI_MODE_PROPERTY, CLI_MODES } from './global/cli-mode/index.js'; 5 | 6 | 7 | function setupEnvVars(env: CLI_MODES): void { 8 | if (env === 'DEFAULT') { 9 | delete process.env[CI_PROPERTY]; 10 | } else { 11 | process.env[CI_PROPERTY] = (env === 'CI' ? true : 'SANDBOX') as string; 12 | } 13 | } 14 | 15 | function teardownEnvVars() { 16 | delete process.env[CI_PROPERTY]; 17 | delete process.env[CLI_MODE_PROPERTY]; 18 | } 19 | 20 | describe('getEnvPreset', () => { 21 | 22 | afterEach(() => setupEnvVars('SANDBOX')); 23 | afterEach(teardownEnvVars); 24 | 25 | it('should return default preset', () => { 26 | teardownEnvVars(); 27 | expect(getEnvPreset()).toEqual(DEFAULT_PRESET); 28 | }); 29 | 30 | // This will only pass run in CI 31 | it('should return CI preset in CI', () => { 32 | setupEnvVars('CI'); 33 | expect(getEnvPreset()).toEqual(CI_PRESET); 34 | }); 35 | 36 | it('should return sandbox preset if mode is configured', () => { 37 | setupEnvVars('SANDBOX'); 38 | expect(getEnvPreset()).toEqual(SANDBOX_PRESET); 39 | }); 40 | 41 | }); 42 | -------------------------------------------------------------------------------- /packages/cli/src/lib/pre-set.ts: -------------------------------------------------------------------------------- 1 | import { ArgvPreset } from './types.js'; 2 | import { detectCliMode } from './global/cli-mode/cli-mode.js'; 3 | import { DEFAULT_FULL_RC_PATH } from './constants.js'; 4 | 5 | export const DEFAULT_PRESET: ArgvPreset = { 6 | // GLOBAL 7 | rcPath: DEFAULT_FULL_RC_PATH, 8 | interactive: true, 9 | verbose: false, 10 | // PERSIST COMMAND 11 | openReport: true, 12 | format: ['html'] 13 | }; 14 | 15 | export const CI_PRESET: ArgvPreset = { 16 | ...DEFAULT_PRESET, 17 | // GLOBAL 18 | interactive: false, 19 | verbose: false, 20 | // COLLECT COMMAND 21 | dryRun: false, 22 | // PERSIST COMMAND 23 | openReport: false 24 | }; 25 | 26 | export const SANDBOX_PRESET: ArgvPreset = { 27 | ...DEFAULT_PRESET, 28 | // GLOBAL 29 | verbose: true, 30 | // COLLECT COMMAND 31 | dryRun: true, 32 | // PERSIST COMMAND 33 | openReport: false 34 | }; 35 | 36 | export function getEnvPreset(): ArgvPreset { 37 | const m = detectCliMode(); 38 | if (m === 'SANDBOX') { 39 | return SANDBOX_PRESET; 40 | } 41 | if (m === 'CI') { 42 | return CI_PRESET; 43 | } 44 | return DEFAULT_PRESET; 45 | } 46 | -------------------------------------------------------------------------------- /packages/cli/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { GlobalOptionsArgv } from './global/options/types.js'; 2 | import { 3 | CollectArgvOptions, 4 | CollectRcOptions, 5 | PersistArgvOptions, 6 | PersistRcOptions 7 | } from './commands/collect/options/types.js'; 8 | 9 | export type ArgvPreset = 10 | { rcPath: GlobalOptionsArgv['rcPath'] } & 11 | Partial & CollectArgvOptions & PersistArgvOptions> 12 | 13 | export type RcJson = { 14 | collect: CollectRcOptions; 15 | persist: PersistRcOptions; 16 | } & Object; 17 | -------------------------------------------------------------------------------- /packages/cli/src/lib/ufo/index.ts: -------------------------------------------------------------------------------- 1 | export {Ufo} from './ufo.js'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/lib/ufo/ufo.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'puppeteer'; 2 | import { UserFlowContext } from '../index.js'; 3 | 4 | /** 5 | * This class is used in the user-flow interactions to ensure the context of the flow is available in UFO's 6 | */ 7 | export class Ufo { 8 | protected page: Page; 9 | 10 | constructor({ page }: UserFlowContext) { 11 | this.page = page; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "es2022", 5 | "esModuleInterop": true, 6 | "moduleResolution": "NodeNext", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "allowSyntheticDefaultImports": true, 14 | "importHelpers": true 15 | }, 16 | "files": [], 17 | "include": [], 18 | "references": [ 19 | { 20 | "path": "./tsconfig.lib.json" 21 | }, 22 | { 23 | "path": "./tsconfig.spec.json" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node"], 7 | "allowJs": true, 8 | "paths": { 9 | "@packageName": [ 10 | "../../asva/wgra/af", 11 | "dist/asva/wgra/af", 12 | "node_modules/asva/wgra/af" 13 | ] 14 | } 15 | }, 16 | "include": ["**/*.ts"], 17 | "exclude": [ 18 | "**/tests/*", 19 | "**/*.spec.ts", 20 | "**/*.test.ts", 21 | "**/*.mocks.ts", 22 | "**/mocks/**", 23 | "vite.config.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [ 6 | "vitest/globals", 7 | "vitest/importMeta", 8 | "vite/client", 9 | "node", 10 | "vitest" 11 | ] 12 | }, 13 | "include": [ 14 | "vite.config.ts", 15 | "vitest.config.ts", 16 | "src/**/*.test.ts", 17 | "src/**/*.spec.ts", 18 | "src/**/*.test.tsx", 19 | "src/**/*.spec.tsx", 20 | "src/**/*.test.js", 21 | "src/**/*.spec.js", 22 | "src/**/*.test.jsx", 23 | "src/**/*.spec.jsx", 24 | "src/**/*.d.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/cli/vite.config.mts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite'; 3 | 4 | import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; 5 | 6 | export default defineConfig({ 7 | cacheDir: '../../node_modules/.vite/packages/cli', 8 | 9 | plugins: [nxViteTsPaths()], 10 | 11 | test: { 12 | globals: true, 13 | environment: 'node', 14 | include: ['src/**/*.test.ts'], 15 | pool: 'threads', 16 | poolOptions: { threads: { singleThread: true } }, 17 | reporters: ['basic'], 18 | coverage: { 19 | reportsDirectory: '../../coverage/packages/cli', 20 | provider: 'v8', 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@nx/js/babel" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "parserOptions": { 5 | "project": ["packages/cli/tsconfig.*?.json"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/.user-flowrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/src/app/app.element.ts: -------------------------------------------------------------------------------- 1 | import './app.element.css'; 2 | 3 | export class AppElement extends HTMLElement { 4 | public static observedAttributes = []; 5 | 6 | connectedCallback() { 7 | const title = 'nx-plugin-integration'; 8 | 9 | this.innerHTML = ` 10 |
11 |
12 | 13 |
14 |

15 | Hello there, 16 | Welcome ${title} 👋 17 |

18 |
19 | 20 | 21 |
22 |
23 |

You're up and running

24 |
25 |
26 | 27 |
28 |
29 | `; 30 | } 31 | } 32 | 33 | customElements.define('user-flow-root', AppElement); 34 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/nx-plugin-integration/src/assets/.gitkeep -------------------------------------------------------------------------------- /packages/nx-plugin-integration/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // When building for production, this file is replaced with `environment.prod.ts`. 3 | 4 | export const environment = { 5 | production: false, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/nx-plugin-integration/src/favicon.ico -------------------------------------------------------------------------------- /packages/nx-plugin-integration/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NxPluginIntegration 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/src/main.ts: -------------------------------------------------------------------------------- 1 | import './app/app.element'; 2 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [ 6 | "node" 7 | ] 8 | }, 9 | "exclude": [ 10 | "jest.config.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.test.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/user-flows/basic-navigation.uf.mts: -------------------------------------------------------------------------------- 1 | import {UserFlowContext, UserFlowInteractionsFn, UserFlowProvider} from '@push-based/user-flow'; 2 | 3 | const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise => { 4 | const { flow, browser, collectOptions} = ctx; 5 | const { url} = collectOptions; 6 | 7 | await flow.navigate(url, { 8 | name: '🧭 Navigate to Home', 9 | }); 10 | }; 11 | 12 | export default { 13 | flowOptions: {name: 'Basic Navigation Example'}, 14 | interactions 15 | } satisfies UserFlowProvider; 16 | -------------------------------------------------------------------------------- /packages/nx-plugin-integration/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { composePlugins, withNx, withWeb } = require('@nx/webpack'); 2 | 3 | // Nx plugins for webpack. 4 | module.exports = composePlugins(withNx(), withWeb(), (config) => { 5 | // Update the webpack config as needed here. 6 | // e.g. `config.plugins.push(new MyPlugin())` 7 | return config; 8 | }); 9 | -------------------------------------------------------------------------------- /packages/nx-plugin/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "parserOptions": { 5 | "project": ["packages/cli/tsconfig.*?.json"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/nx-plugin/executors.json: -------------------------------------------------------------------------------- 1 | { 2 | "executors": { 3 | "user-flow": { 4 | "implementation": "./src/executors/user-flow/executor", 5 | "schema": "./src/executors/user-flow/schema.json", 6 | "description": "user-flow executor" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/nx-plugin/generators.json: -------------------------------------------------------------------------------- 1 | { 2 | "generators": { 3 | "target": { 4 | "factory": "./src/generators/target/generator", 5 | "schema": "./src/generators/target/schema.json", 6 | "description": "target generator" 7 | }, 8 | "init": { 9 | "factory": "./src/generators/init/generator", 10 | "schema": "./src/generators/init/schema.json", 11 | "description": "init generator" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/nx-plugin/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'nx-plugin', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^.+\\.[tj]s$': ['ts-jest', {tsconfig: '/tsconfig.spec.json'}], 7 | }, 8 | moduleFileExtensions: ['ts', 'js', 'html'], 9 | coverageDirectory: '../../coverage/packages/plugin-user-flow', 10 | }; 11 | -------------------------------------------------------------------------------- /packages/nx-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@push-based/user-flow-nx-plugin", 3 | "version": "0.1.5", 4 | "type": "commonjs", 5 | "executors": "./executors.json", 6 | "generators": "./generators.json", 7 | "peerDependencies": { 8 | "@nx/devkit": "^16.3.2" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/nx-plugin/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nx-plugin", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/nx-plugin/src", 5 | "projectType": "library", 6 | "tags": [], 7 | "targets": { 8 | "build": { 9 | "executor": "@nx/js:tsc", 10 | "dependsOn": [ 11 | { 12 | "target": "build", 13 | "projects": "dependencies" 14 | } 15 | ], 16 | "outputs": ["{options.outputPath}"], 17 | "options": { 18 | "outputPath": "dist/packages/nx-plugin", 19 | "main": "packages/nx-plugin/src/index.ts", 20 | "tsConfig": "packages/nx-plugin/tsconfig.lib.json", 21 | "assets": [ 22 | "packages/nx-plugin/*.md", 23 | { 24 | "input": "./packages/nx-plugin/src", 25 | "glob": "**/!(*.ts)", 26 | "output": "./src" 27 | }, 28 | { 29 | "input": "./packages/nx-plugin/src", 30 | "glob": "**/*.d.ts", 31 | "output": "./src" 32 | }, 33 | { 34 | "input": "./packages/nx-plugin", 35 | "glob": "generators.json", 36 | "output": "." 37 | }, 38 | { 39 | "input": "./packages/nx-plugin", 40 | "glob": "executors.json", 41 | "output": "." 42 | } 43 | ] 44 | } 45 | }, 46 | "lint": { 47 | "executor": "@nx/eslint:lint" 48 | }, 49 | "test": { 50 | "executor": "@nx/jest:jest", 51 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 52 | "options": { 53 | "jestConfig": "packages/nx-plugin/jest.config.ts" 54 | } 55 | }, 56 | "version": { 57 | "executor": "@jscutlery/semver:version", 58 | "dependsOn": [ 59 | { 60 | "target": "build" 61 | } 62 | ], 63 | "options": { 64 | "postTargets": ["nx-plugin:npm", "nx-plugin:github"], 65 | "commitMessageFormat": "release(${projectName}): ${version}", 66 | "noVerify": true, 67 | "push": true 68 | } 69 | }, 70 | "github": { 71 | "executor": "@jscutlery/semver:github", 72 | "options": { 73 | "tag": "${tag}", 74 | "notes": "${notes}" 75 | } 76 | }, 77 | "npm": { 78 | "executor": "ngx-deploy-npm:deploy", 79 | "options": { 80 | "access": "public", 81 | "distFolderPath": "dist/packages/nx-plugin" 82 | } 83 | }, 84 | "link": { 85 | "executor": "nx:run-commands", 86 | "dependsOn": ["build"], 87 | "options": { 88 | "commands": [ 89 | { 90 | "command": "npx cpx \"./dist/packages/nx-plugin/**\" ./node_modules/@push-based/user-flow-nx-plugin" 91 | } 92 | ] 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/executors/user-flow/executor.ts: -------------------------------------------------------------------------------- 1 | import {UserFlowExecutorSchema} from './schema'; 2 | import {execSync} from 'child_process'; 3 | import {ExecutorContext} from "nx/src/config/misc-interfaces"; 4 | import * as process from "process"; 5 | import {CLI_MODES} from "@push-based/user-flow"; 6 | import {logger} from "@nx/devkit"; 7 | 8 | 9 | export default async function runExecutor( 10 | options: UserFlowExecutorSchema, 11 | context?: ExecutorContext & UserFlowExecutorSchema 12 | ): Promise<{ success: boolean, output: string }> { 13 | options.interactive = options.interactive !== undefined; 14 | const verbose = !!options.verbose; 15 | 16 | handleCliMode(options.cliMode, verbose); 17 | verbose && console.log('Executor ran for user-flow', options); 18 | const cliArgs = ['npx @push-based/user-flow collect'].concat(processParamsToParamsArray(options as any)).join(' '); 19 | 20 | verbose && console.log('Execute: ', cliArgs); 21 | 22 | let processOutput: Buffer | Error; 23 | try { 24 | processOutput = execSync(cliArgs); 25 | } catch (error: unknown) { 26 | if (error instanceof Error) { 27 | processOutput = error; 28 | } else { 29 | processOutput = new Error(String(error)); 30 | } 31 | } 32 | const success = !(processOutput instanceof Error); 33 | const output = processOutput.toString(); 34 | 35 | verbose && console.log('Result: ', output); 36 | 37 | return { 38 | success, 39 | output 40 | }; 41 | } 42 | export function processParamsToParamsArray(params: Record): string[] { 43 | return Object.entries(params).flatMap(([key, value]) => { 44 | if (key === '_') { 45 | return value.toString(); 46 | } else if (Array.isArray(value)) { 47 | return value.map(v => `--${key}=${v.toString()}`); 48 | } else { 49 | // exception to align with nx options context 50 | if (key === 'outputPath') { 51 | key = 'outPath' 52 | } 53 | if (typeof value === 'string') { 54 | return [`--${key}="${value}"`]; 55 | } else if (typeof value === 'boolean') { 56 | const noPrefix = value ? '' : 'no-'; 57 | return [`--${noPrefix}${key}`]; 58 | } 59 | return [`--${key}="${value}"`]; 60 | } 61 | }) as string[]; 62 | } 63 | 64 | function handleCliMode(cliMode: CLI_MODES = 'DEFAULT', verbose = false): void { 65 | const CI_PROPERTY = 'CI'; 66 | switch (cliMode) { 67 | case "DEFAULT": 68 | // delete process.env[CI_PROPERTY]; 69 | break; 70 | case "CI": 71 | process.env[CI_PROPERTY] = 'true'; 72 | break; 73 | case "SANDBOX": 74 | process.env[CI_PROPERTY] = 'SANDBOX'; 75 | break; 76 | default: 77 | throw new Error(`Wrong cliMode passed: ${cliMode}`); 78 | } 79 | verbose && logger.log('Nx executor runs in CLI mode: ', cliMode); 80 | } 81 | 82 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/executors/user-flow/schema.d.ts: -------------------------------------------------------------------------------- 1 | import {CLI_MODES, CollectCommandCfg, GlobalOptionsArgv} from "@push-based/user-flow"; 2 | 3 | export type UserFlowExecutorSchema = { 4 | cliMode?: CLI_MODES 5 | } & CollectCommandCfg & GlobalOptionsArgv; // eslint-disable-line 6 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/executors/user-flow/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "version": 2, 4 | "title": "user-flow executor", 5 | "description": "", 6 | "type": "object", 7 | "properties": { 8 | "verbose": { 9 | "type": "boolean", 10 | "description": "Verbose logging on/off" 11 | }, 12 | "url": { 13 | "type": "string", 14 | "description": "Target URL" 15 | }, 16 | "cliMode": { 17 | "type": "string", 18 | "description": "CLI mode for execution of user-flows. This is useful when you debug or write tests. (DEFAULT | SANDBOX | CI)" 19 | }, 20 | "rcPath": { 21 | "type": "string", 22 | "description": "Path to `.user-flowrc.json`" 23 | }, 24 | "interactive": { 25 | "type": "boolean", 26 | "description": "Prompt active" 27 | }, 28 | "ufPath": { 29 | "type": "string", 30 | "description": "Path to user-flow file or folder containing user-flow files to run. (`*.uf.ts` or`*.uf.js`) " 31 | }, 32 | "configPath": { 33 | "type": "string", 34 | "description": "Path to the lighthouse `config.json` file" 35 | }, 36 | "config": { 37 | "type": "string" 38 | }, 39 | "serveCommand": { 40 | "type": "string", 41 | "description": "Runs a npm script to serve the target app. This has to be used in combination with `--awaitServeStdout`" 42 | }, 43 | "awaitServeStdout": { 44 | "type": "string", 45 | "description": "Waits for stdout from the serve command to start collecting user-flows ()" 46 | }, 47 | "outPath": { 48 | "type": "string", 49 | "description": "Output folder for the user-flow reports" 50 | }, 51 | "outputPath": { 52 | "type": "string", 53 | "description": "Output folder for the user-flow reports" 54 | }, 55 | "format": { 56 | "type": "array", 57 | "description": "Format of the creates reports ( `html`, `json`, `md`, `stdout`)" 58 | }, 59 | "openReport": { 60 | "type": "boolean", 61 | "description": "Opens file automatically after the user-flow is captured" 62 | }, 63 | "budgetPath": { 64 | "type": "string", 65 | "description": "Path to the lighthouse `budget.json` file" 66 | }, 67 | "dryRun": { 68 | "type": "boolean", 69 | "description": "When true the user-flow test will get executed without measures (for fast development)" 70 | } 71 | }, 72 | "required": [ 73 | "rcPath", 74 | "outputPath" 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/constants.ts: -------------------------------------------------------------------------------- 1 | export const PLUGIN_NAME = "@push-based/user-flow-nx-plugin"; 2 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/init/generator.ts: -------------------------------------------------------------------------------- 1 | import {formatFiles, logger, Tree} from '@nx/devkit'; 2 | 3 | import {InitGeneratorSchema} from './schema'; 4 | import {normalizeOptions, updateDependencies, updateNxJson} from "./utils"; 5 | 6 | export default async function userFlowInitGenerator(tree: Tree, options: InitGeneratorSchema) { 7 | //const normalizedOptions = normalizeOptions(tree, options); 8 | 9 | if (options.skipPackageJson === false) { 10 | logger.log('Adding packages:'); 11 | updateDependencies(tree); 12 | logger.log('Adding nx config:'); 13 | updateNxJson(tree); 14 | } else { 15 | logger.log('Skip adding packages'); 16 | } 17 | 18 | await formatFiles(tree); 19 | } 20 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/init/schema.d.ts: -------------------------------------------------------------------------------- 1 | export type InitGeneratorSchema = { 2 | projectName: string; 3 | skipPackageJson?: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/init/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "Init", 4 | "title": "", 5 | "type": "object", 6 | "properties": { 7 | "skipPackageJson": { 8 | "type": "boolean", 9 | "default": false, 10 | "description": "Do not add dependencies to `package.json`." 11 | } 12 | }, 13 | "required": [] 14 | } 15 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/init/types.ts: -------------------------------------------------------------------------------- 1 | import {InitGeneratorSchema} from "./schema"; 2 | 3 | export interface NormalizedSchema extends InitGeneratorSchema { 4 | projectName: string; 5 | projectRoot: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/init/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addDependenciesToPackageJson, 3 | joinPathFragments, 4 | readProjectConfiguration, 5 | Tree, 6 | updateJson 7 | } from '@nx/devkit'; 8 | import {NormalizedSchema} from "./types"; 9 | import {InitGeneratorSchema} from "./schema"; 10 | import {PLUGIN_NAME} from "../constants"; 11 | import {DEFAULT_TARGET_NAME} from "../target/constants"; 12 | 13 | export function normalizeOptions(tree: Tree, options?: InitGeneratorSchema): NormalizedSchema { 14 | 15 | const projectName = options.projectName; 16 | const projectRoot = readProjectConfiguration(tree, options.projectName).root; 17 | 18 | return { 19 | ...options, 20 | projectName, 21 | projectRoot 22 | }; 23 | } 24 | 25 | export function updateDependencies(tree: Tree, options?: NormalizedSchema) { 26 | const devDeps = { 27 | '@push-based/user-flow': '^0.19.0', 28 | PLUGIN_NAME: '^0.0.0', 29 | '@nx/devkit': '^16.0.0' 30 | }; 31 | addDependenciesToPackageJson(tree, {}, devDeps); 32 | } 33 | 34 | export function updateNxJson(tree: Tree, options?: NormalizedSchema) { 35 | updateJson(tree, joinPathFragments('nx.json'), (json) => { 36 | if (json.tasksRunnerOptions) { 37 | if (!json.tasksRunnerOptions.default.options.cacheableOperations) { 38 | json.tasksRunnerOptions.default.options.cacheableOperations = [] 39 | } 40 | json.tasksRunnerOptions.default.options.cacheableOperations.push(DEFAULT_TARGET_NAME); 41 | } 42 | 43 | if (json.generators === undefined) { 44 | json.generators = {}; 45 | } 46 | json.generators[PLUGIN_NAME + ":target"] = { 47 | "targetName": DEFAULT_TARGET_NAME 48 | } 49 | 50 | return json; 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/target/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_TARGET_NAME = "user-flow"; 2 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/target/generator.ts: -------------------------------------------------------------------------------- 1 | import {formatFiles, Tree} from '@nx/devkit'; 2 | 3 | import {TargetGeneratorSchema} from './schema'; 4 | import {NormalizedSchema} from "./types"; 5 | import {addTarget, normalizeOptions, setupUserFlow} from "./utils"; 6 | 7 | export default async function (tree: Tree, options: TargetGeneratorSchema) { 8 | const normalizedOpts: NormalizedSchema = normalizeOptions(tree, options); 9 | setupUserFlow(tree, normalizedOpts); 10 | 11 | addTarget(tree, normalizedOpts); 12 | await formatFiles(tree); 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/target/schema.d.ts: -------------------------------------------------------------------------------- 1 | export interface TargetGeneratorSchema { 2 | projectName: string; 3 | url: string; 4 | targetName?: string; 5 | skipPackageJson?: boolean; 6 | verbose?: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/target/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "$id": "Target", 4 | "title": "", 5 | "type": "object", 6 | "properties": { 7 | "targetName": { 8 | "type": "string", 9 | "description": "" 10 | }, 11 | "projectName": { 12 | "type": "string", 13 | "description": "Project to add the target to", 14 | "x-prompt": "What project would you like to add your target to?" 15 | }, 16 | "url": { 17 | "type": "string", 18 | "description": "Target URL", 19 | "default": "https://coffee-cart.netlify.app/", 20 | "x-prompt": "What URL you want to target?" 21 | } 22 | }, 23 | "required": [ 24 | "projectName" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/target/types.ts: -------------------------------------------------------------------------------- 1 | import {TargetGeneratorSchema} from "./schema"; 2 | 3 | export interface NormalizedSchema extends TargetGeneratorSchema { 4 | projectName: string; 5 | projectRoot: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/generators/target/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | joinPathFragments, 3 | logger, 4 | readJson, 5 | readProjectConfiguration, 6 | Tree, 7 | updateJson, 8 | writeJson 9 | } from '@nx/devkit'; 10 | import {TargetGeneratorSchema} from "./schema"; 11 | import {NormalizedSchema} from "./types"; 12 | import {DEFAULT_TARGET_NAME} from "../target/constants"; 13 | 14 | export function normalizeOptions(tree: Tree, options?: TargetGeneratorSchema): NormalizedSchema { 15 | 16 | const projectName = options.projectName; 17 | const projectRoot = readProjectConfiguration(tree, options.projectName).root; 18 | 19 | return { 20 | ...options, 21 | projectName, 22 | projectRoot 23 | }; 24 | } 25 | 26 | export function setupUserFlow(tree: Tree, cfg: NormalizedSchema): void { 27 | const {projectName, targetName, projectRoot, verbose} = cfg; 28 | logger.log(`Adding .user-flowrc.json to project`); 29 | let existing; 30 | try { 31 | readJson(tree, joinPathFragments(projectRoot, '.user-flowrc.json')); 32 | existing = true; 33 | } 34 | catch (e) { 35 | existing = false; 36 | } 37 | if (!existing) { 38 | writeJson(tree, joinPathFragments(projectRoot, '.user-flowrc.json'), {}); 39 | } 40 | else { 41 | throw new Error(`.user-flowrc.json already exists in ${projectRoot}`); 42 | } 43 | } 44 | 45 | export function addTarget(tree: Tree, cfg: NormalizedSchema) { 46 | const {projectName, targetName, projectRoot, url} = cfg; 47 | const parsedTargetName = targetName || DEFAULT_TARGET_NAME; 48 | logger.log(`Adding target ${parsedTargetName} to project ${projectName}`); 49 | updateJson(tree, joinPathFragments(projectRoot, 'project.json'), (json) => { 50 | if (json.targets[parsedTargetName] !== undefined) { 51 | throw new Error(`Target ${parsedTargetName} already exists`) 52 | } 53 | json.targets[parsedTargetName] = { 54 | "executor": "@push-based/user-flow-nx-plugin:user-flow", 55 | "outputs": ["{options.outputPath}"], 56 | "options": { 57 | "url": url, 58 | "rcPath": joinPathFragments(projectRoot, './.user-flowrc.json'), 59 | "ufPath": joinPathFragments(projectRoot, '/user-flows'), 60 | "outputPath": joinPathFragments('dist', '/user-flow', projectRoot), 61 | "format": ["md"] 62 | } 63 | }; 64 | return json; 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /packages/nx-plugin/src/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/nx-plugin/src/index.ts -------------------------------------------------------------------------------- /packages/nx-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | }, 6 | "files": [], 7 | "include": [], 8 | "references": [ 9 | { 10 | "path": "./tsconfig.lib.json" 11 | }, 12 | { 13 | "path": "./tsconfig.spec.json" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /packages/nx-plugin/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node"] 7 | }, 8 | "include": ["src/**/*.ts"], 9 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/nx-plugin/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "src/**/*.test.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /packages/user-flow-example/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "parserOptions": { 5 | "project": ["packages/cli/tsconfig.*?.json"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/user-flow-example/.user-flowrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "collect": { 3 | "url": "https://coffee-cart.netlify.app/", 4 | "ufPath": "packages/user-flow-example/user-flows" 5 | }, 6 | "persist": { 7 | "outputPath": "dist/user-flow/user-flow-example", 8 | "format": ["md"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/user-flow-example/README.md: -------------------------------------------------------------------------------- 1 | # user-flow-example 2 | 3 | This folder contains the user flow examples mentioned in the docs. 4 | 5 | -------------------------------------------------------------------------------- /packages/user-flow-example/data/checkout.data.ts: -------------------------------------------------------------------------------- 1 | export const formData = { name: 'nina', email: 'nina@gmail.com' }; 2 | -------------------------------------------------------------------------------- /packages/user-flow-example/fixtures/checkout.fixture.ts: -------------------------------------------------------------------------------- 1 | export const checkoutBtnSelector = '[data-test=checkout]'; 2 | export const nameInputSelector = '#name'; 3 | export const emailInputSelector = '#email'; 4 | export const submitBtnSelector = '#submit-payment'; 5 | export const snackBarSelector = '.snackbar.success'; 6 | -------------------------------------------------------------------------------- /packages/user-flow-example/fixtures/coffee.fixture.ts: -------------------------------------------------------------------------------- 1 | export const cappuccinoSelector = '.cup:nth-child(1)'; 2 | -------------------------------------------------------------------------------- /packages/user-flow-example/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user-flow-example", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/user-flow-example/src", 5 | "projectType": "library", 6 | "tags": [], 7 | "targets": { 8 | "lint": { 9 | "executor": "@nx/eslint:lint" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/user-flow-example/recordings/order-coffee-1.replay.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "coffee checkout", 3 | "steps": [ 4 | { 5 | "type": "setViewport", 6 | "width": 1920, 7 | "height": 969, 8 | "deviceScaleFactor": 1, 9 | "isMobile": false, 10 | "hasTouch": false, 11 | "isLandscape": false 12 | }, 13 | { 14 | "type": "navigate", 15 | "url": "https://coffee-cart.netlify.app/", 16 | "assertedEvents": [ 17 | { 18 | "type": "navigation", 19 | "url": "https://coffee-cart.netlify.app/", 20 | "title": "Coffee cart" 21 | } 22 | ] 23 | }, 24 | { 25 | "type": "click", 26 | "target": "main", 27 | "selectors": [ 28 | [ 29 | "aria/Cappucino" 30 | ], 31 | [ 32 | ".cup:nth-child(1)" 33 | ] 34 | ], 35 | "offsetY": 103.6608657836914, 36 | "offsetX": 133.40185546875 37 | }, 38 | { 39 | "type": "click", 40 | "target": "main", 41 | "selectors": [ 42 | [ 43 | "aria/Proceed to checkout" 44 | ], 45 | [ 46 | "[data-test=checkout]" 47 | ] 48 | ], 49 | "offsetY": 19.796875, 50 | "offsetX": 53.1875 51 | }, 52 | { 53 | "type": "change", 54 | "value": "nina", 55 | "selectors": [ 56 | [ 57 | "#name" 58 | ] 59 | ], 60 | "target": "main" 61 | }, 62 | { 63 | "type": "click", 64 | "target": "main", 65 | "selectors": [ 66 | [ 67 | "#email" 68 | ] 69 | ], 70 | "offsetY": 14.234375, 71 | "offsetX": 36 72 | }, 73 | { 74 | "type": "change", 75 | "value": "nina@gmail.com", 76 | "selectors": [ 77 | [ 78 | "#email" 79 | ] 80 | ], 81 | "target": "main" 82 | }, 83 | { 84 | "type": "click", 85 | "target": "main", 86 | "selectors": [ 87 | [ 88 | "aria/Submit" 89 | ], 90 | [ 91 | "#submit-payment" 92 | ] 93 | ], 94 | "offsetY": 19.859375, 95 | "offsetX": 48.359375 96 | } 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /packages/user-flow-example/recordings/order-coffee-2.replay.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "coffee checkout", 3 | "steps": [ 4 | { 5 | "type": "navigate", 6 | "url": "https://coffee-cart.netlify.app/", 7 | "assertedEvents": [ 8 | { 9 | "type": "navigation", 10 | "url": "https://coffee-cart.netlify.app/", 11 | "title": "Coffee cart" 12 | } 13 | ] 14 | }, 15 | { "type": "startTimespan" }, 16 | { 17 | "type": "click", 18 | "target": "main", 19 | "selectors": [ 20 | [ 21 | "aria/Cappucino" 22 | ], 23 | [ 24 | ".cup:nth-child(1)" 25 | ] 26 | ], 27 | "offsetY": 103.6608657836914, 28 | "offsetX": 133.40185546875 29 | }, 30 | { "type": "endTimespan" }, 31 | { "type": "snapshot" }, 32 | { "type": "startTimespan" }, 33 | { 34 | "type": "click", 35 | "target": "main", 36 | "selectors": [ 37 | [ 38 | "aria/Proceed to checkout" 39 | ], 40 | [ 41 | "[data-test=checkout]" 42 | ] 43 | ], 44 | "offsetY": 19.796875, 45 | "offsetX": 53.1875 46 | }, 47 | { 48 | "type": "change", 49 | "value": "nina", 50 | "selectors": [ 51 | [ 52 | "#name" 53 | ] 54 | ], 55 | "target": "main" 56 | }, 57 | { 58 | "type": "click", 59 | "target": "main", 60 | "selectors": [ 61 | [ 62 | "#email" 63 | ] 64 | ], 65 | "offsetY": 14.234375, 66 | "offsetX": 36 67 | }, 68 | { 69 | "type": "change", 70 | "value": "nina@gmail.com", 71 | "selectors": [ 72 | [ 73 | "#email" 74 | ] 75 | ], 76 | "target": "main" 77 | }, 78 | { "type": "endTimespan" }, 79 | { "type": "snapshot" }, 80 | { "type": "startTimespan" }, 81 | { 82 | "type": "click", 83 | "target": "main", 84 | "selectors": [ 85 | [ 86 | "aria/Submit" 87 | ], 88 | [ 89 | "#submit-payment" 90 | ] 91 | ], 92 | "offsetY": 19.859375, 93 | "offsetX": 48.359375 94 | }, 95 | { "type": "endTimespan" }, 96 | { "type": "snapshot" } 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /packages/user-flow-example/replay-examples/order-coffee-1-replay.uf.ts: -------------------------------------------------------------------------------- 1 | import {createUserFlowRunner, UserFlowContext, UserFlowInteractionsFn, UserFlowProvider} from '@push-based/user-flow'; 2 | 3 | const interactions: UserFlowInteractionsFn = async ( 4 | ctx: UserFlowContext 5 | ): Promise => { 6 | 7 | const {flow} = ctx; 8 | 9 | 10 | await flow.startTimespan({name: 'Checkout order'}); 11 | // Use the create function to instanciate a the user-flow runner. 12 | const path = './recordings/order-coffee-1.replay.json'; 13 | const runner = await createUserFlowRunner( path, ctx); 14 | await runner.run(); 15 | await flow.endTimespan(); 16 | 17 | }; 18 | 19 | export default { 20 | flowOptions: { name: "Order Coffee 1" }, 21 | interactions, 22 | } satisfies UserFlowProvider; 23 | -------------------------------------------------------------------------------- /packages/user-flow-example/replay-examples/order-coffee-2-replay.uf.ts: -------------------------------------------------------------------------------- 1 | import {createUserFlowRunner, UserFlowContext, UserFlowInteractionsFn, UserFlowProvider} from '@push-based/user-flow'; 2 | 3 | const interactions: UserFlowInteractionsFn = async ( 4 | ctx: UserFlowContext 5 | ): Promise => { 6 | 7 | const path = './recordings/order-coffee-2.replay.json'; 8 | const runner = await createUserFlowRunner(path, ctx); 9 | await runner.run(); 10 | 11 | }; 12 | 13 | export default { 14 | flowOptions: { name: "Order Coffee 1" }, 15 | interactions, 16 | } satisfies UserFlowProvider; 17 | -------------------------------------------------------------------------------- /packages/user-flow-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": ["**/*"], 5 | } 6 | -------------------------------------------------------------------------------- /packages/user-flow-example/ufo/checkout.form.ts: -------------------------------------------------------------------------------- 1 | import {Ufo} from '@push-based/user-flow'; 2 | import { 3 | checkoutBtnSelector, 4 | emailInputSelector, 5 | nameInputSelector, 6 | snackBarSelector, 7 | submitBtnSelector 8 | } from '../fixtures/checkout.fixture'; 9 | 10 | export class CheckoutForm extends Ufo { 11 | 12 | async openOrder(): Promise { 13 | await this.page.waitForSelector(checkoutBtnSelector); 14 | await this.page.click(checkoutBtnSelector); 15 | } 16 | 17 | async fillCheckoutForm(formData: {name: string, email: string}): Promise { 18 | const {name, email} = formData; 19 | 20 | await this.page.waitForSelector(nameInputSelector); 21 | await this.page.type(nameInputSelector, name); 22 | 23 | await this.page.waitForSelector(emailInputSelector); 24 | await this.page.type(emailInputSelector, email); 25 | } 26 | 27 | async submitOrder(): Promise { 28 | await this.page.click(submitBtnSelector); 29 | await this.page.waitForSelector(submitBtnSelector); 30 | await this.page.waitForSelector(snackBarSelector); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /packages/user-flow-example/ufo/coffee.ufo.ts: -------------------------------------------------------------------------------- 1 | import {Ufo} from '@push-based/user-flow'; 2 | 3 | import {cappuccinoSelector} from '../fixtures/coffee.fixture'; 4 | 5 | export class Coffee extends Ufo { 6 | 7 | async selectCappuccino() { 8 | await this.selectCoffee(cappuccinoSelector); 9 | } 10 | 11 | async selectCoffee(itemSelector: string) { 12 | await this.page.waitForSelector(itemSelector); 13 | await this.page.click(itemSelector); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /packages/user-flow-example/ufo/snack-bar.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/push-based/user-flow/6bfe3afb08a843afa00fa6e1a8e94192bcb4b214/packages/user-flow-example/ufo/snack-bar.ts -------------------------------------------------------------------------------- /packages/user-flow-example/user-flows/order-coffee-1.uf.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UserFlowInteractionsFn, 3 | UserFlowContext, 4 | UserFlowProvider 5 | } from '@push-based/user-flow'; 6 | 7 | // Your custom interactions with the page 8 | const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise => { 9 | const { page, flow, browser, collectOptions } = ctx; 10 | const { url } = collectOptions; 11 | 12 | await flow.navigate(url, { 13 | name: 'Navigate to coffee cart', 14 | }); 15 | 16 | // Select coffee 17 | 18 | // Checkout order 19 | 20 | // Submit order 21 | 22 | }; 23 | 24 | export default { 25 | flowOptions: {name: 'Order Coffee'}, 26 | interactions 27 | } satisfies UserFlowProvider; 28 | -------------------------------------------------------------------------------- /packages/user-flow-example/user-flows/order-coffee-2.uf.ts: -------------------------------------------------------------------------------- 1 | import { UserFlowContext, UserFlowInteractionsFn, UserFlowProvider } from '@push-based/user-flow'; 2 | 3 | // Your custom interactions with the page 4 | const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise => { 5 | const { page, flow, browser, collectOptions } = ctx; 6 | const { url } = collectOptions; 7 | 8 | await flow.navigate(url, { 9 | name: 'Navigate to coffee cart', 10 | }); 11 | 12 | 13 | // Select coffee 14 | const cappuccinoItem = '.cup:nth-child(1)'; 15 | await page.waitForSelector(cappuccinoItem); 16 | await page.click(cappuccinoItem); 17 | 18 | // Checkout order 19 | const checkoutBtn = '[data-test=checkout]'; 20 | await page.waitForSelector(checkoutBtn); 21 | await page.click(checkoutBtn); 22 | 23 | const nameInputSelector = '#name'; 24 | await page.waitForSelector(nameInputSelector); 25 | await page.type(nameInputSelector, 'nina'); 26 | 27 | const emailInputSelector = '#email'; 28 | await page.waitForSelector(emailInputSelector); 29 | await page.type(emailInputSelector, 'nina@gmail.com'); 30 | 31 | // Submit order 32 | const submitBtn = '#submit-payment'; 33 | await page.click(submitBtn); 34 | await page.waitForSelector(submitBtn); 35 | const successMsg = '.snackbar.success'; 36 | await page.waitForSelector(successMsg); 37 | 38 | }; 39 | 40 | export default { 41 | flowOptions: {name: 'Order Coffee'}, 42 | interactions 43 | } satisfies UserFlowProvider; 44 | -------------------------------------------------------------------------------- /packages/user-flow-example/user-flows/order-coffee-3.uf.ts: -------------------------------------------------------------------------------- 1 | import {UserFlowContext, UserFlowInteractionsFn, UserFlowProvider} from '@push-based/user-flow'; 2 | 3 | // Your custom interactions with the page 4 | const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise => { 5 | const { page, flow, browser, collectOptions } = ctx; 6 | const { url } = collectOptions; 7 | 8 | // Navigate to coffee order site 9 | await flow.navigate(url, { 10 | name: 'Navigate to coffee cart', 11 | }); 12 | 13 | await flow.startTimespan({ name: 'Select coffee' }); 14 | 15 | // Select coffee 16 | const cappuccinoItem = '.cup:nth-child(1)'; 17 | await page.waitForSelector(cappuccinoItem); 18 | await page.click(cappuccinoItem); 19 | 20 | await flow.endTimespan(); 21 | 22 | 23 | await flow.startTimespan({ name: 'Checkout order' }); 24 | 25 | // Checkout order 26 | const checkoutBtn = '[data-test=checkout]'; 27 | await page.waitForSelector(checkoutBtn); 28 | await page.click(checkoutBtn); 29 | 30 | const nameInputSelector = '#name'; 31 | await page.waitForSelector(nameInputSelector); 32 | await page.type(nameInputSelector, 'nina'); 33 | 34 | const emailInputSelector = '#email'; 35 | await page.waitForSelector(emailInputSelector); 36 | await page.type(emailInputSelector, 'nina@gmail.com'); 37 | 38 | await flow.endTimespan(); 39 | 40 | 41 | await flow.startTimespan({ name: 'Submit order' }); 42 | 43 | // Submit order 44 | const submitBtn = '#submit-payment'; 45 | await page.click(submitBtn); 46 | await page.waitForSelector(submitBtn); 47 | const successMsg = '.snackbar.success'; 48 | await page.waitForSelector(successMsg); 49 | 50 | await flow.endTimespan(); 51 | 52 | }; 53 | 54 | export default { 55 | flowOptions: {name: 'Order Coffee'}, 56 | interactions 57 | } satisfies UserFlowProvider; 58 | -------------------------------------------------------------------------------- /packages/user-flow-example/user-flows/order-coffee-4.uf.ts: -------------------------------------------------------------------------------- 1 | import {UserFlowContext, UserFlowInteractionsFn, UserFlowProvider} from '@push-based/user-flow'; 2 | 3 | // Your custom interactions with the page 4 | const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise => { 5 | const { page, flow, browser, collectOptions } = ctx; 6 | const { url } = collectOptions; 7 | 8 | // Navigate to coffee order site 9 | await flow.navigate(url, { 10 | name: 'Navigate to coffee cart', 11 | }); 12 | 13 | await flow.startTimespan({ name: 'Select coffee' }); 14 | 15 | // Select coffee 16 | const cappuccinoItem = '.cup:nth-child(1)'; 17 | await page.waitForSelector(cappuccinoItem); 18 | await page.click(cappuccinoItem); 19 | 20 | await flow.endTimespan(); 21 | 22 | await flow.snapshot({ name: 'Coffee selected' }); 23 | 24 | 25 | await flow.startTimespan({ name: 'Checkout order' }); 26 | 27 | // Checkout order 28 | const checkoutBtn = '[data-test=checkout]'; 29 | await page.waitForSelector(checkoutBtn); 30 | await page.click(checkoutBtn); 31 | 32 | const nameInputSelector = '#name'; 33 | await page.waitForSelector(nameInputSelector); 34 | await page.type(nameInputSelector, 'nina'); 35 | 36 | const emailInputSelector = '#email'; 37 | await page.waitForSelector(emailInputSelector); 38 | await page.type(emailInputSelector, 'nina@gmail.com'); 39 | 40 | await flow.endTimespan(); 41 | 42 | await flow.snapshot({ name: 'Order checked out' }); 43 | 44 | await flow.startTimespan({ name: 'Submit order' }); 45 | 46 | // Submit order 47 | const submitBtn = '#submit-payment'; 48 | await page.click(submitBtn); 49 | await page.waitForSelector(submitBtn); 50 | const successMsg = '.snackbar.success'; 51 | await page.waitForSelector(successMsg); 52 | 53 | await flow.endTimespan(); 54 | 55 | await flow.snapshot({ name: 'Order submitted' }); 56 | 57 | }; 58 | 59 | export default { 60 | flowOptions: {name: 'Order Coffee'}, 61 | interactions 62 | } satisfies UserFlowProvider; 63 | -------------------------------------------------------------------------------- /packages/user-flow-example/user-flows/order-coffee-5.uf.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UserFlowInteractionsFn, 3 | UserFlowContext, 4 | UserFlowProvider 5 | } from '@push-based/user-flow'; 6 | import { Coffee } from '../ufo/coffee.ufo'; 7 | import { CheckoutForm } from '../ufo/checkout.form'; 8 | import { formData } from '../data/checkout.data'; 9 | 10 | // Your custom interactions with the page 11 | const interactions: UserFlowInteractionsFn = async (ctx: UserFlowContext): Promise => { 12 | const { flow, collectOptions } = ctx; 13 | const { url } = collectOptions; 14 | 15 | const coffeeUfo = new Coffee(ctx); 16 | const checkoutFormUfo = new CheckoutForm(ctx); 17 | 18 | // Navigate to coffee order site 19 | await flow.navigate(url, { 20 | name: 'Navigate to coffee cart' 21 | }); 22 | 23 | await flow.startTimespan({ name: 'Select coffee' }); 24 | // Select coffee 25 | coffeeUfo.selectCappuccino(); 26 | await flow.endTimespan(); 27 | await flow.snapshot({ name: 'Coffee selected' }); 28 | 29 | await flow.startTimespan({ name: 'Checkout order' }); 30 | // Checkout order 31 | await checkoutFormUfo.openOrder(); 32 | await checkoutFormUfo.fillCheckoutForm(formData); 33 | await flow.endTimespan(); 34 | await flow.snapshot({ name: 'Order checked out' }); 35 | 36 | await flow.startTimespan({ name: 'Submit order' }); 37 | // Submit order 38 | await checkoutFormUfo.submitOrder(); 39 | await flow.endTimespan(); 40 | await flow.snapshot({ name: 'Order submitted' }); 41 | }; 42 | 43 | export default { 44 | flowOptions: { name: 'Order Coffee' }, 45 | interactions 46 | } satisfies UserFlowProvider; 47 | -------------------------------------------------------------------------------- /project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@push-based/user-flow-source", 3 | "$schema": "node_modules/nx/schemas/project-schema.json", 4 | "targets": { 5 | "local-registry": { 6 | "executor": "@nx/js:verdaccio", 7 | "options": { 8 | "port": 4873, 9 | "config": ".verdaccio/config.yml", 10 | "storage": "tmp/local-registry/storage" 11 | } 12 | }, 13 | "version": { 14 | "executor": "@jscutlery/semver:version", 15 | "options": { 16 | "syncVersions": true, 17 | "preset": "angular", 18 | "commitMessageFormat": "release: {version} [skip ci]", 19 | "postTargets": ["github"], 20 | "push": true, 21 | "skipProjectChangelog": true 22 | } 23 | }, 24 | "github": { 25 | "executor": "@jscutlery/semver:github", 26 | "options": { 27 | "tag": "{tag}", 28 | "notes": "{notes}" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tools/scripts/publish.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a minimal script to publish your package to "npm". 3 | * This is meant to be used as-is or customize as you see fit. 4 | * 5 | * This script is executed on "dist/path/to/library" as "cwd" by default. 6 | * 7 | * You might need to authenticate with NPM before running this script. 8 | */ 9 | import devkit from '@nx/devkit'; 10 | import { execSync } from 'node:child_process'; 11 | import { readFileSync, writeFileSync } from 'node:fs'; 12 | 13 | const { readCachedProjectGraph } = devkit; 14 | 15 | function invariant(condition, message) { 16 | if (!condition) { 17 | console.error(message); 18 | process.exit(1); 19 | } 20 | } 21 | 22 | // Executing publish script: node path/to/publish.mjs {name} --version {version} --tag {tag} 23 | // Default "tag" to "next" so we won't publish the "latest" tag by accident. 24 | const [, , name, version, tag = 'next'] = process.argv; 25 | 26 | // A simple SemVer validation to validate the version 27 | const validVersion = /^\d+\.\d+\.\d+(-\w+\.\d+)?/; 28 | invariant( 29 | version && validVersion.test(version), 30 | `No version provided or version did not match Semantic Versioning, expected: #.#.#-tag.# or #.#.#, got ${version}.`, 31 | ); 32 | 33 | const graph = readCachedProjectGraph(); 34 | const project = graph.nodes[name]; 35 | 36 | invariant( 37 | project, 38 | `Could not find project "${name}" in the workspace. Is the project.json configured correctly?`, 39 | ); 40 | 41 | const outputPath = project.data?.targets?.build?.options?.outputPath; 42 | invariant( 43 | outputPath, 44 | `Could not find "build.options.outputPath" of project "${name}". Is project.json configured correctly?`, 45 | ); 46 | 47 | process.chdir(outputPath); 48 | 49 | // Updating the version in "package.json" before publishing 50 | try { 51 | const json = JSON.parse(readFileSync(`package.json`).toString()); 52 | json.version = version; 53 | writeFileSync(`package.json`, JSON.stringify(json, null, 2)); 54 | } catch (e) { 55 | console.error(`Error reading package.json file from library build output.`); 56 | } 57 | 58 | // Execute "npm publish" to publish 59 | execSync(`npm publish --access public --tag ${tag}`); 60 | -------------------------------------------------------------------------------- /tools/scripts/stop-local-registry.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This script stops the local registry for e2e testing purposes. 3 | * It is meant to be called in jest's globalTeardown. 4 | */ 5 | 6 | export default () => { 7 | console.log('Teardown registry') 8 | if (global.stopLocalRegistry) { 9 | global.stopLocalRegistry(); 10 | console.log('Registry down') 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /tools/tsconfig.tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/out-tsc/tools", 5 | "rootDir": ".", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": ["node"], 9 | "importHelpers": false 10 | }, 11 | "include": ["**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2022", 12 | "module": "esnext", 13 | "lib": ["es2022", "dom"], 14 | "skipLibCheck": true, 15 | "skipDefaultLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@push-based/user-flow": ["packages/cli/src/index.ts"], 19 | "@push-based/user-flow-nx-plugin": ["packages/nx-plugin/src/index.ts"], 20 | } 21 | }, 22 | "exclude": ["node_modules", "tmp", "code-pushup.config.ts"] 23 | } 24 | --------------------------------------------------------------------------------