Merge branch 'master' into mct7175

This commit is contained in:
Andrew Henry
2025-03-26 12:38:53 -07:00
committed by GitHub
573 changed files with 30828 additions and 8914 deletions

View File

@ -1,59 +1,33 @@
version: 2.1 version: 2.1
orbs:
node: circleci/node@5.2.0
browser-tools: circleci/browser-tools@1.3.0
executors: executors:
pw-focal-development: pw-focal-development:
docker: docker:
- image: mcr.microsoft.com/playwright:v1.39.0-focal - image: mcr.microsoft.com/playwright:v1.48.1-focal
environment: environment:
NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed
PERCY_POSTINSTALL_BROWSER: "true" # Needed to store the percy browser in cache deps PERCY_POSTINSTALL_BROWSER: 'true' # Needed to store the percy browser in cache deps
PERCY_LOGLEVEL: "debug" # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742) PERCY_LOGLEVEL: 'debug' # Enable DEBUG level logging for Percy (Issue: https://github.com/nasa/openmct/issues/5742)
PERCY_PARALLEL_TOTAL: 2
ubuntu: ubuntu:
machine: machine:
image: ubuntu-2204:current image: ubuntu-2204:current
docker_layer_caching: true docker_layer_caching: true
parameters:
BUST_CACHE:
description: "Set this with the CircleCI UI Trigger Workflow button (boolean = true) to bust the cache!"
default: false
type: boolean
commands: commands:
build_and_install: build_and_install:
description: "All steps used to build and install. Will use cache if found" description: 'All steps used to build and install.'
parameters: parameters:
node-version: node-version:
type: string type: string
steps: steps:
- checkout - checkout
- restore_cache_cmd:
node-version: << parameters.node-version >>
- node/install: - node/install:
node-version: << parameters.node-version >> node-version: << parameters.node-version >>
- run: npm install --no-audit --progress=false - node/install-packages
restore_cache_cmd:
description: "Custom command for restoring cache with the ability to bust cache. When BUST_CACHE is set to true, jobs will not restore cache"
parameters:
node-version:
type: string
steps:
- when:
condition:
equal: [false, << pipeline.parameters.BUST_CACHE >>]
steps:
- restore_cache:
key: deps--{{ arch }}--{{ .Branch }}--<< parameters.node-version >>--{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }}
save_cache_cmd:
description: "Custom command for saving cache."
parameters:
node-version:
type: string
steps:
- save_cache:
key: deps--{{ arch }}--{{ .Branch }}--<< parameters.node-version >>--{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }}
paths:
- ~/.npm
- node_modules
generate_and_store_version_and_filesystem_artifacts: generate_and_store_version_and_filesystem_artifacts:
description: "Track important packages and files" description: 'Track important packages and files'
steps: steps:
- run: | - run: |
[[ $EUID -ne 0 ]] && (sudo mkdir -p /tmp/artifacts && sudo chmod 777 /tmp/artifacts) || (mkdir -p /tmp/artifacts && chmod 777 /tmp/artifacts) [[ $EUID -ne 0 ]] && (sudo mkdir -p /tmp/artifacts && sudo chmod 777 /tmp/artifacts) || (mkdir -p /tmp/artifacts && chmod 777 /tmp/artifacts)
@ -63,17 +37,45 @@ commands:
ls -latR >> /tmp/artifacts/dir.txt ls -latR >> /tmp/artifacts/dir.txt
- store_artifacts: - store_artifacts:
path: /tmp/artifacts/ path: /tmp/artifacts/
download_verify_codecov_cli:
description: 'Download and verify Codecov CLI'
steps:
- run:
name: Download and verify Codecov CLI
command: |
# Download Codecov CLI
curl -Os https://cli.codecov.io/latest/linux/codecov
# Import Codecov's GPG key
curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --keyring trustedkeys.gpg --import
# Download and verify the SHA256SUM and its signature
curl -Os https://cli.codecov.io/latest/linux/codecov.SHA256SUM
curl -Os https://cli.codecov.io/latest/linux/codecov.SHA256SUM.sig
gpgv codecov.SHA256SUM.sig codecov.SHA256SUM
# Verify the checksum
shasum -a 256 -c codecov.SHA256SUM
# Make the codecov executable
[[ $EUID -ne 0 ]] && sudo chmod +x codecov || chmod +x codecov
./codecov --help
generate_e2e_code_cov_report: generate_e2e_code_cov_report:
description: "Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test" description: 'Generate e2e code coverage artifacts and publish to codecov.io. Needed to that we can ignore the exit code status of the npm run test'
parameters: parameters:
suite: suite:
type: string type: string
steps: steps:
- run: npm run cov:e2e:report || true - run: npm run cov:e2e:report || true
- run: npm run cov:e2e:<<parameters.suite>>:publish - download_verify_codecov_cli
orbs: - run:
node: circleci/node@5.1.0 name: Upload coverage report to Codecov
browser-tools: circleci/browser-tools@1.3.0 command: |
./codecov --verbose upload-process --disable-search \
-t $CODECOV_TOKEN \
-n 'e2e-<<parameters.suite>>'-${CIRCLE_WORKFLOW_ID} \
-F e2e-<<parameters.suite>> \
-f ./coverage/e2e/lcov.info
jobs: jobs:
npm-audit: npm-audit:
parameters: parameters:
@ -110,9 +112,15 @@ jobs:
mkdir -p dist/reports/tests/ mkdir -p dist/reports/tests/
TESTFILES=$(circleci tests glob "src/**/*Spec.js") TESTFILES=$(circleci tests glob "src/**/*Spec.js")
echo "$TESTFILES" | circleci tests run --command="xargs npm run test" --verbose echo "$TESTFILES" | circleci tests run --command="xargs npm run test" --verbose
- run: npm run cov:unit:publish - download_verify_codecov_cli
- save_cache_cmd: - run:
node-version: <<parameters.node-version>> name: Upload coverage report to Codecov
command: |
./codecov --verbose upload-process --disable-search \
-t $CODECOV_TOKEN \
-n 'unit-test'-${CIRCLE_WORKFLOW_ID} \
-F unit \
-f ./coverage/unit/lcov.info
- store_test_results: - store_test_results:
path: dist/reports/tests/ path: dist/reports/tests/
- store_artifacts: - store_artifacts:
@ -124,16 +132,16 @@ jobs:
- generate_and_store_version_and_filesystem_artifacts - generate_and_store_version_and_filesystem_artifacts
e2e-test: e2e-test:
parameters: parameters:
suite: #stable or full suite: #ci or full
type: string type: string
executor: pw-focal-development executor: pw-focal-development
parallelism: 7 parallelism: 8
steps: steps:
- build_and_install: - build_and_install:
node-version: lts/hydrogen node-version: lts/hydrogen
- when: #Only install chrome-beta when running the 'full' suite to save $$$ - when: #Only install chrome-beta when running the 'full' suite to save $$$
condition: condition:
equal: ["full", <<parameters.suite>>] equal: ['full', <<parameters.suite>>]
steps: steps:
- run: npx playwright install chrome-beta - run: npx playwright install chrome-beta
- run: - run:
@ -160,15 +168,40 @@ jobs:
equal: [42, 42] # Always generate version artifacts regardless of test failure https://discuss.circleci.com/t/make-custom-command-run-always-with-when-always/38957/2 equal: [42, 42] # Always generate version artifacts regardless of test failure https://discuss.circleci.com/t/make-custom-command-run-always-with-when-always/38957/2
steps: steps:
- generate_and_store_version_and_filesystem_artifacts - generate_and_store_version_and_filesystem_artifacts
e2e-mobile:
executor: pw-focal-development
steps:
- build_and_install:
node-version: lts/hydrogen
- run: npm run test:e2e:mobile
- when:
condition:
equal: [42, 42] # Always run codecov reports regardless of test failure https://discuss.circleci.com/t/make-custom-command-run-always-with-when-always/38957/2
steps:
- generate_e2e_code_cov_report:
suite: full
- store_test_results:
path: test-results/results.xml
- store_artifacts:
path: test-results
- store_artifacts:
path: coverage
- store_artifacts:
path: html-test-results
- when:
condition:
equal: [42, 42] # Always generate version artifacts regardless of test failure https://discuss.circleci.com/t/make-custom-command-run-always-with-when-always/38957/2
steps:
- generate_and_store_version_and_filesystem_artifacts
e2e-couchdb: e2e-couchdb:
executor: ubuntu executor: ubuntu
steps: steps:
- build_and_install: - build_and_install:
node-version: lts/hydrogen node-version: lts/hydrogen
- run: npx playwright@1.39.0 install #Necessary for bare ubuntu machine - run: npx playwright@1.48.1 install #Necessary for bare ubuntu machine
- run: | - run: |
export $(cat src/plugins/persistence/couch/.env.ci | xargs) export $(cat src/plugins/persistence/couch/.env.ci | xargs)
docker-compose -f src/plugins/persistence/couch/couchdb-compose.yaml up --detach docker compose -f src/plugins/persistence/couch/couchdb-compose.yaml up --detach
sleep 3 sleep 3
bash src/plugins/persistence/couch/setup-couchdb.sh bash src/plugins/persistence/couch/setup-couchdb.sh
- run: sh src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh #Replace LocalStorage Plugin with CouchDB - run: sh src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh #Replace LocalStorage Plugin with CouchDB
@ -227,15 +260,16 @@ jobs:
equal: [42, 42] # Always run codecov reports regardless of test failure https://discuss.circleci.com/t/make-custom-command-run-always-with-when-always/38957/2 equal: [42, 42] # Always run codecov reports regardless of test failure https://discuss.circleci.com/t/make-custom-command-run-always-with-when-always/38957/2
steps: steps:
- generate_and_store_version_and_filesystem_artifacts - generate_and_store_version_and_filesystem_artifacts
visual-a11y-tests: visual-a11y:
parameters: parameters:
suite: suite:
type: string # ci or full type: string # ci or full
executor: pw-focal-development executor: pw-focal-development
parallelism: 2
steps: steps:
- build_and_install: - build_and_install:
node-version: lts/hydrogen node-version: lts/iron
- run: npm run test:e2e:visual:<<parameters.suite>> - run: SHARD="$((${CIRCLE_NODE_INDEX}+1))"; npm run test:e2e:visual:<<parameters.suite>> -- --shard=${SHARD}/${CIRCLE_NODE_TOTAL}
- store_test_results: - store_test_results:
path: test-results/results.xml path: test-results/results.xml
- store_artifacts: - store_artifacts:
@ -252,23 +286,26 @@ workflows:
overall-circleci-commit-status: #These jobs run on every commit overall-circleci-commit-status: #These jobs run on every commit
jobs: jobs:
- lint: - lint:
name: node20-lint name: node22-lint
node-version: lts/iron node-version: '22'
- unit-test: - unit-test:
name: node18-chrome name: node18-chrome
node-version: lts/hydrogen node-version: lts/hydrogen
- e2e-test: - e2e-test:
name: e2e-stable name: e2e-ci
suite: stable
- visual-a11y-tests:
name: visual-test-ci
suite: ci suite: ci
- e2e-mobile
- visual-a11y:
name: visual-a11y-ci
suite: ci
- perf-test
- mem-test
the-nightly: #These jobs do not run on PRs, but against master at night the-nightly: #These jobs do not run on PRs, but against master at night
jobs: jobs:
- unit-test: - unit-test:
name: node20-chrome-nightly name: node22-chrome-nightly
node-version: lts/iron node-version: '22'
- unit-test: - unit-test:
name: node18-chrome name: node18-chrome
node-version: lts/hydrogen node-version: lts/hydrogen
@ -277,15 +314,16 @@ workflows:
- e2e-test: - e2e-test:
name: e2e-full-nightly name: e2e-full-nightly
suite: full suite: full
- mem-test - e2e-mobile
- perf-test - perf-test
- visual-a11y-tests: - mem-test
name: visual-test-nightly - visual-a11y:
name: visual-a11y-nightly
suite: full suite: full
- e2e-couchdb - e2e-couchdb
triggers: triggers:
- schedule: - schedule:
cron: "0 0 * * *" cron: '0 0 * * *'
filters: filters:
branches: branches:
only: only:

View File

@ -7,24 +7,18 @@
"minmax", "minmax",
"openmct", "openmct",
"datasources", "datasources",
"recieved",
"evalute",
"Sinewave", "Sinewave",
"deregistration", "deregistration",
"unregisters", "unregisters",
"configutation",
"configuation",
"codecov", "codecov",
"carryforward", "carryforward",
"Chacon", "Chacon",
"Straub", "Straub",
"OWASP", "OWASP",
"Testathon", "Testathon",
"exploratorily",
"Testathons", "Testathons",
"testathon", "testathon",
"npmjs", "npmjs",
"publishj",
"treeitem", "treeitem",
"timespan", "timespan",
"Timespan", "Timespan",
@ -41,14 +35,10 @@
"faultname", "faultname",
"gantt", "gantt",
"sharded", "sharded",
"perfromance",
"MMOC", "MMOC",
"codegen", "codegen",
"Unfortuantely",
"viewports", "viewports",
"updatesnapshots", "updatesnapshots",
"excercised",
"Circel",
"browsercontexts", "browsercontexts",
"miminum", "miminum",
"testcase", "testcase",
@ -135,9 +125,7 @@
"tortor", "tortor",
"faucibus", "faucibus",
"euismod", "euismod",
"pratices",
"pathing", "pathing",
"pases",
"testcases", "testcases",
"Noneditable", "Noneditable",
"listitem", "listitem",
@ -206,16 +194,12 @@
"unlisten", "unlisten",
"symbolsfont", "symbolsfont",
"ellipsize", "ellipsize",
"dismissable",
"TIMESYSTEM", "TIMESYSTEM",
"Metadatas", "Metadatas",
"stalenes",
"receieves",
"unsub", "unsub",
"callbacktwo", "callbacktwo",
"unsubscribetwo", "unsubscribetwo",
"telem", "telem",
"Telemetery",
"unemitted", "unemitted",
"granually", "granually",
"timesystem", "timesystem",
@ -457,7 +441,6 @@
"Userand", "Userand",
"Userbefore", "Userbefore",
"brdr", "brdr",
"pushs",
"ALPH", "ALPH",
"Recents", "Recents",
"Qbert", "Qbert",
@ -497,7 +480,11 @@
"checksnapshots", "checksnapshots",
"specced", "specced",
"composables", "composables",
"countup" "countup",
"darkmatter",
"Undeletes",
"SSSZ",
"pageerror"
], ],
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"], "dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"],
"ignorePaths": [ "ignorePaths": [

View File

@ -1,13 +1,20 @@
const LEGACY_FILES = ['example/**']; const LEGACY_FILES = ['example/**'];
module.exports = { /** @type {import('eslint').Linter.Config} */
const config = {
env: { env: {
browser: true, browser: true,
es6: true, es2024: true,
jasmine: true, jasmine: true,
amd: true amd: true,
node: true
}, },
globals: { globals: {
_: 'readonly' _: 'readonly',
__webpack_public_path__: 'writeable',
__OPENMCT_VERSION__: 'readonly',
__OPENMCT_BUILD_DATE__: 'readonly',
__OPENMCT_REVISION__: 'readonly',
__OPENMCT_BUILD_BRANCH__: 'readonly'
}, },
plugins: ['prettier', 'unicorn', 'simple-import-sort'], plugins: ['prettier', 'unicorn', 'simple-import-sort'],
extends: [ extends: [
@ -23,10 +30,11 @@ module.exports = {
parser: '@babel/eslint-parser', parser: '@babel/eslint-parser',
requireConfigFile: false, requireConfigFile: false,
allowImportExportEverywhere: true, allowImportExportEverywhere: true,
ecmaVersion: 2015, ecmaVersion: 'latest',
ecmaFeatures: { ecmaFeatures: {
impliedStrict: true impliedStrict: true
} },
sourceType: 'module'
}, },
rules: { rules: {
'simple-import-sort/imports': 'warn', 'simple-import-sort/imports': 'warn',
@ -35,6 +43,7 @@ module.exports = {
'vue/no-deprecated-events-api': 'warn', 'vue/no-deprecated-events-api': 'warn',
'vue/no-v-for-template-key': 'off', 'vue/no-v-for-template-key': 'off',
'vue/no-v-for-template-key-on-child': 'error', 'vue/no-v-for-template-key-on-child': 'error',
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
'prettier/prettier': 'error', 'prettier/prettier': 'error',
'you-dont-need-lodash-underscore/omit': 'off', 'you-dont-need-lodash-underscore/omit': 'off',
'you-dont-need-lodash-underscore/throttle': 'off', 'you-dont-need-lodash-underscore/throttle': 'off',
@ -152,7 +161,7 @@ module.exports = {
cases: { cases: {
pascalCase: true pascalCase: true
}, },
ignore: ['^.*\\.js$'] ignore: ['^.*\\.(js|cjs|mjs)$']
} }
], ],
'vue/first-attribute-linebreak': 'error', 'vue/first-attribute-linebreak': 'error',
@ -179,3 +188,5 @@ module.exports = {
} }
] ]
}; };
module.exports = config;

View File

@ -8,7 +8,7 @@ Closes <!--- Insert Issue Number(s) this PR addresses. Start by typing # will op
* [ ] Have you followed the guidelines in our [Contributing document](https://github.com/nasa/openmct/blob/master/CONTRIBUTING.md)? * [ ] Have you followed the guidelines in our [Contributing document](https://github.com/nasa/openmct/blob/master/CONTRIBUTING.md)?
* [ ] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/nasa/openmct/pulls) for the same update/change? * [ ] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/nasa/openmct/pulls) for the same update/change?
* [ ] Is this a notable change that will require a special callout in the release notes [Notable Change](../docs/src/process/release.md) ? For example, will this break compatibility with existing APIs or projects which source these plugins? * [ ] Is this a [notable change](../docs/src/process/release.md) that will require a special callout in the release notes? For example, will this break compatibility with existing APIs or projects that consume these plugins?
### Author Checklist ### Author Checklist
@ -17,7 +17,6 @@ Closes <!--- Insert Issue Number(s) this PR addresses. Start by typing # will op
* [ ] Has this been smoke tested? * [ ] Has this been smoke tested?
* [ ] Have you associated this PR with a `type:` label? Note: this is not necessarily the same as the original issue. * [ ] Have you associated this PR with a `type:` label? Note: this is not necessarily the same as the original issue.
* [ ] Have you associated a milestone with this PR? Note: leave blank if unsure. * [ ] Have you associated a milestone with this PR? Note: leave blank if unsure.
* [ ] Is this a breaking change to be called out in the release notes?
* [ ] Testing instructions included in associated issue OR is this a dependency/testcase change? * [ ] Testing instructions included in associated issue OR is this a dependency/testcase change?
### Reviewer Checklist ### Reviewer Checklist

View File

@ -1 +1,5 @@
name: 'Custom CodeQL config' name: 'Custom CodeQL config'
paths-ignore:
# Ignore e2e tests and framework
- e2e

View File

@ -28,7 +28,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
- run: npm install --cache ~/.npm --no-audit --progress=false - run: npm ci --no-audit --progress=false
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v3
@ -37,12 +37,12 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- run: npx playwright@1.39.0 install - run: npx playwright@1.48.1 install
- name: Start CouchDB Docker Container and Init with Setup Scripts - name: Start CouchDB Docker Container and Init with Setup Scripts
run: | run: |
export $(cat src/plugins/persistence/couch/.env.ci | xargs) export $(cat src/plugins/persistence/couch/.env.ci | xargs)
docker-compose -f src/plugins/persistence/couch/couchdb-compose.yaml up --detach docker compose -f src/plugins/persistence/couch/couchdb-compose.yaml up --detach
sleep 3 sleep 3
bash src/plugins/persistence/couch/setup-couchdb.sh bash src/plugins/persistence/couch/setup-couchdb.sh
bash src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh bash src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh
@ -52,22 +52,33 @@ jobs:
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha }} COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha }}
run: npm run test:e2e:couchdb run: npm run test:e2e:couchdb
- name: Generate Code Coverage Report
run: npm run cov:e2e:report
- name: Publish Results to Codecov.io - name: Publish Results to Codecov.io
env: uses: codecov/codecov-action@v4
SUPER_SECRET: ${{ secrets.CODECOV_TOKEN }} with:
run: npm run cov:e2e:full:publish token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/e2e/lcov.info
flags: e2e-full
fail_ci_if_error: true
verbose: true
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-couchdb-test-results
path: test-results path: test-results
overwrite: true
- name: Archive html test results - name: Archive html test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-couchdb-html-test-results
path: html-test-results path: html-test-results
overwrite: true
- name: Remove pr:e2e:couchdb label (if present) - name: Remove pr:e2e:couchdb label (if present)
if: always() if: always()

View File

@ -1,15 +1,15 @@
name: 'pr:e2e:flakefinder' name: 'pr:e2e:flakefinder'
on: on:
push: # push:
branches: master # branches: master
workflow_dispatch: workflow_dispatch:
pull_request: # pull_request:
types: # types:
- labeled # - labeled
- opened # - opened
schedule: # schedule:
- cron: '0 0 * * *' # - cron: '0 0 * * *'
jobs: jobs:
e2e-flakefinder: e2e-flakefinder:
@ -30,17 +30,19 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
- run: npx playwright@1.39.0 install - run: npx playwright@1.48.1 install
- run: npm install --cache ~/.npm --no-audit --progress=false - run: npm ci --no-audit --progress=false
- name: Run E2E Tests (Repeated 10 Times) - name: Run E2E Tests (Repeated 10 Times)
run: npm run test:e2e:stable -- --retries=0 --repeat-each=10 --max-failures=50 run: npm run test:e2e:ci -- --retries=0 --repeat-each=10 --max-failures=50
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-flakefinder-test-results
path: test-results path: test-results
overwrite: true
- name: Remove pr:e2e:flakefinder label (if present) - name: Remove pr:e2e:flakefinder label (if present)
if: always() if: always()

View File

@ -28,16 +28,18 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
- run: npx playwright@1.39.0 install - run: npx playwright@1.48.1 install
- run: npm install --cache ~/.npm --no-audit --progress=false - run: npm ci --no-audit --progress=false
- run: npm run test:perf:localhost - run: npm run test:perf:localhost
- run: npm run test:perf:contract - run: npm run test:perf:contract
- run: npm run test:perf:memory - run: npm run test:perf:memory
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-perf-test-results
path: test-results path: test-results
overwrite: true
- name: Remove pr:e2e:perf label (if present) - name: Remove pr:e2e:perf label (if present)
if: always() if: always()

View File

@ -33,9 +33,9 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
- run: npx playwright@1.39.0 install - run: npx playwright@1.47.2 install
- run: npx playwright install chrome-beta - run: npx playwright install chrome-beta
- run: npm install --cache ~/.npm --no-audit --progress=false - run: npm ci --no-audit --progress=false
- run: npm run test:e2e:full -- --max-failures=40 - run: npm run test:e2e:full -- --max-failures=40
- run: npm run cov:e2e:report || true - run: npm run cov:e2e:report || true
- shell: bash - shell: bash
@ -45,9 +45,11 @@ jobs:
npm run cov:e2e:full:publish npm run cov:e2e:full:publish
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: e2e-pr-test-results
path: test-results path: test-results
overwrite: true
- name: Remove pr:e2e label (if present) - name: Remove pr:e2e label (if present)
if: always() if: always()

View File

@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: lts/hydrogen node-version: lts/hydrogen
- run: npm install - run: npm ci
- run: | - run: |
echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> ~/.npmrc echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> ~/.npmrc
npm whoami npm whoami
@ -31,7 +31,7 @@ jobs:
with: with:
node-version: lts/hydrogen node-version: lts/hydrogen
registry-url: https://registry.npmjs.org/ registry-url: https://registry.npmjs.org/
- run: npm install - run: npm ci
- run: npm publish --access=public --tag unstable - run: npm publish --access=public --tag unstable
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -45,7 +45,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-${{ matrix.node_version }}- ${{ runner.os }}-${{ matrix.node_version }}-
- run: npm install --cache ~/.npm --no-audit --progress=false - run: npm ci --no-audit --progress=false
- run: npm test - run: npm test

View File

@ -5,6 +5,8 @@ on:
types: types:
- labeled - labeled
- unlabeled - unlabeled
- milestoned
- demilestoned
- opened - opened
- reopened - reopened
- synchronize - synchronize

4
.gitignore vendored
View File

@ -48,5 +48,5 @@ index.html.bak
coverage coverage
codecov codecov
# :( # Don't commit MacOS screenshots
package-lock.json *-darwin.png

View File

@ -23,8 +23,5 @@
!openmct.js !openmct.js
!SECURITY.md !SECURITY.md
# Add e2e tests to npm package # Dont include the example html
!/e2e/**/* dist/index.html
# ... except our test-data folder files.
/e2e/test-data/*.json

3
.npmrc
View File

@ -2,6 +2,3 @@ loglevel=warn
#Prevent folks from ignoring an important error when building from source #Prevent folks from ignoring an important error when building from source
engine-strict=true engine-strict=true
# Dont include lockfile
package-lock=false

View File

@ -5,7 +5,6 @@
// List of extensions which should be recommended for users of this workspace. // List of extensions which should be recommended for users of this workspace.
"recommendations": [ "recommendations": [
"Vue.volar", "Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"rvest.vs-code-prettier-eslint" "rvest.vs-code-prettier-eslint"
], ],

View File

@ -1,8 +1,8 @@
/* /*
This is the OpenMCT common webpack file. It is imported by the other three webpack configurations: This is the OpenMCT common webpack file. It is imported by the other three webpack configurations:
- webpack.prod.js - the production configuration for OpenMCT (default) - webpack.prod.mjs - the production configuration for OpenMCT (default)
- webpack.dev.js - the development configuration for OpenMCT - webpack.dev.mjs - the development configuration for OpenMCT
- webpack.coverage.js - imports webpack.dev.js and adds code coverage - webpack.coverage.mjs - imports webpack.dev.js and adds code coverage
There are separate npm scripts to use these configurations, though simply running `npm install` There are separate npm scripts to use these configurations, though simply running `npm install`
will use the default production configuration. will use the default production configuration.
*/ */
@ -15,10 +15,11 @@ import CopyWebpackPlugin from 'copy-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { VueLoaderPlugin } from 'vue-loader'; import { VueLoaderPlugin } from 'vue-loader';
import webpack from 'webpack'; import webpack from 'webpack';
import { merge } from 'webpack-merge';
let gitRevision = 'error-retrieving-revision'; let gitRevision = 'error-retrieving-revision';
let gitBranch = 'error-retrieving-branch'; let gitBranch = 'error-retrieving-branch';
const packageDefinition = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url))); const { version } = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url)));
try { try {
gitRevision = execSync('git rev-parse HEAD').toString().trim(); gitRevision = execSync('git rev-parse HEAD').toString().trim();
@ -48,15 +49,18 @@ const config = {
couchDBChangesFeed: './src/plugins/persistence/couch/CouchChangesFeed.js', couchDBChangesFeed: './src/plugins/persistence/couch/CouchChangesFeed.js',
inMemorySearchWorker: './src/api/objects/InMemorySearchWorker.js', inMemorySearchWorker: './src/api/objects/InMemorySearchWorker.js',
espressoTheme: './src/plugins/themes/espresso-theme.scss', espressoTheme: './src/plugins/themes/espresso-theme.scss',
snowTheme: './src/plugins/themes/snow-theme.scss' snowTheme: './src/plugins/themes/snow-theme.scss',
darkmatterTheme: './src/plugins/themes/darkmatter-theme.scss'
}, },
output: { output: {
globalObject: 'this', globalObject: 'this',
filename: '[name].js', filename: '[name].js',
path: path.resolve(projectRootDir, 'dist'), path: path.resolve(projectRootDir, 'dist'),
library: 'openmct', library: {
libraryExport: 'default', name: 'openmct',
libraryTarget: 'umd', type: 'umd',
export: 'default'
},
publicPath: '', publicPath: '',
hashFunction: 'xxhash64', hashFunction: 'xxhash64',
clean: true clean: true
@ -66,7 +70,6 @@ const config = {
'@': path.join(projectRootDir, 'src'), '@': path.join(projectRootDir, 'src'),
legacyRegistry: path.join(projectRootDir, 'src/legacyRegistry'), legacyRegistry: path.join(projectRootDir, 'src/legacyRegistry'),
csv: 'comma-separated-values', csv: 'comma-separated-values',
EventEmitter: 'eventemitter3',
bourbon: 'bourbon.scss', bourbon: 'bourbon.scss',
'plotly-basic': 'plotly.js-basic-dist-min', 'plotly-basic': 'plotly.js-basic-dist-min',
'plotly-gl2d': 'plotly.js-gl2d-dist-min', 'plotly-gl2d': 'plotly.js-gl2d-dist-min',
@ -81,7 +84,7 @@ const config = {
}, },
plugins: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
__OPENMCT_VERSION__: `'${packageDefinition.version}'`, __OPENMCT_VERSION__: `'${version}'`,
__OPENMCT_BUILD_DATE__: `'${new Date()}'`, __OPENMCT_BUILD_DATE__: `'${new Date()}'`,
__OPENMCT_REVISION__: `'${gitRevision}'`, __OPENMCT_REVISION__: `'${gitRevision}'`,
__OPENMCT_BUILD_BRANCH__: `'${gitBranch}'`, __OPENMCT_BUILD_BRANCH__: `'${gitBranch}'`,

View File

@ -1,15 +1,12 @@
/* /*
This file extends the webpack.dev.js config to add babel istanbul coverage. This file extends the webpack.dev.mjs config to add babel istanbul coverage.
OpenMCT Continuous Integration servers use this configuration to add code coverage OpenMCT Continuous Integration servers use this configuration to add code coverage
information to pull requests. information to pull requests.
*/ */
import config from './webpack.dev.js'; import config from './webpack.dev.mjs';
// eslint-disable-next-line no-undef
const CI = process.env.CI === 'true';
config.devtool = CI ? false : undefined;
config.devtool = 'inline-source-map';
config.devServer.hot = false; config.devServer.hot = false;
config.module.rules.push({ config.module.rules.push({
@ -19,7 +16,6 @@ config.module.rules.push({
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
retainLines: true, retainLines: true,
// eslint-disable-next-line no-undef
plugins: [ plugins: [
[ [
'babel-plugin-istanbul', 'babel-plugin-istanbul',

View File

@ -1,14 +1,15 @@
/* /*
This configuration should be used for development purposes. It contains full source map, a This configuration should be used for development purposes. It contains full source map, a
devServer (which be invoked using by `npm start`), and a non-minified Vue.js distribution. devServer (which be invoked using by `npm start`), and a non-minified Vue.js distribution.
If OpenMCT is to be used for a production server, use webpack.prod.js instead. If OpenMCT is to be used for a production server, use webpack.prod.mjs instead.
*/ */
import { fileURLToPath } from 'node:url';
import path from 'path'; import path from 'path';
import webpack from 'webpack'; import webpack from 'webpack';
import { merge } from 'webpack-merge'; import { merge } from 'webpack-merge';
import { fileURLToPath } from 'node:url';
import common from './webpack.common.js'; import common from './webpack.common.mjs';
export default merge(common, { export default merge(common, {
mode: 'development', mode: 'development',
@ -38,7 +39,7 @@ export default merge(common, {
return shouldWrite; return shouldWrite;
} }
}, },
watchFiles: ['**/*.css'], watchFiles: ['src/**/*.css', 'example/**/*.css'],
static: { static: {
directory: fileURLToPath(new URL('../dist', import.meta.url)), directory: fileURLToPath(new URL('../dist', import.meta.url)),
publicPath: '/dist', publicPath: '/dist',

View File

@ -6,7 +6,7 @@ It is the default webpack configuration.
import webpack from 'webpack'; import webpack from 'webpack';
import { merge } from 'webpack-merge'; import { merge } from 'webpack-merge';
import common from './webpack.common.js'; import common from './webpack.common.mjs';
export default merge(common, { export default merge(common, {
mode: 'production', mode: 'production',

51
API.md
View File

@ -381,6 +381,7 @@ openmct.composition.addProvider({
The `addProvider` function accepts a Composition Provider object as its sole The `addProvider` function accepts a Composition Provider object as its sole
argument. A Composition Provider is a javascript object exposing two functions: argument. A Composition Provider is a javascript object exposing two functions:
- `appliesTo`: A `function` that accepts a `domainObject` argument, and returns - `appliesTo`: A `function` that accepts a `domainObject` argument, and returns
a `boolean` value indicating whether this composition provider applies to the a `boolean` value indicating whether this composition provider applies to the
given object. given object.
@ -621,6 +622,7 @@ Open MCT on its own defines a handful of built-in formats:
###### **Number Format (default):** ###### **Number Format (default):**
Applied to data with `format: 'number'` Applied to data with `format: 'number'`
```js ```js
valueMetadata = { valueMetadata = {
format: 'number' format: 'number'
@ -635,15 +637,18 @@ interface NumberFormatter extends Formatter {
validate: (value: any) => boolean; validate: (value: any) => boolean;
} }
``` ```
###### **String Format**:
###### **String Format**
Applied to data with `format: 'string'` Applied to data with `format: 'string'`
```js ```js
valueMetadata = { valueMetadata = {
format: 'string' format: 'string'
// ... // ...
}; };
``` ```
```ts ```ts
interface StringFormatter extends Formatter { interface StringFormatter extends Formatter {
parse: (value: any) => string; parse: (value: any) => string;
@ -652,8 +657,10 @@ interface StringFormatter extends Formatter {
} }
``` ```
###### **Enum Format**: ###### **Enum Format**
Applied to data with `format: 'enum'` Applied to data with `format: 'enum'`
```js ```js
valueMetadata = { valueMetadata = {
format: 'enum', format: 'enum',
@ -676,6 +683,7 @@ valueMetadata = {
Creates a two-way mapping between enum string and value to be used in the `parse` and `format` methods. Creates a two-way mapping between enum string and value to be used in the `parse` and `format` methods.
Ex: Ex:
- `formatter.parse('APPLE') === 1;` - `formatter.parse('APPLE') === 1;`
- `formatter.format(1) === 'APPLE';` - `formatter.format(1) === 'APPLE';`
@ -691,7 +699,6 @@ interface EnumFormatter extends Formatter {
Formats implement the following interface (provided here as TypeScript for simplicity): Formats implement the following interface (provided here as TypeScript for simplicity):
Formats are registered with the Telemetry API using the `addFormat` function. eg. Formats are registered with the Telemetry API using the `addFormat` function. eg.
```javascript ```javascript
@ -713,7 +720,7 @@ openmct.telemetry.addFormat({
A single telemetry point is considered a Datum, and is represented by a standard A single telemetry point is considered a Datum, and is represented by a standard
javascript object. Realtime subscriptions (obtained via **subscribe**) will javascript object. Realtime subscriptions (obtained via **subscribe**) will
invoke the supplied callback once for each telemetry datum recieved. Telemetry invoke the supplied callback once for each telemetry datum received. Telemetry
requests (obtained via **request**) will return a promise for an array of requests (obtained via **request**) will return a promise for an array of
telemetry datums. telemetry datums.
@ -738,7 +745,7 @@ section.
Limit evaluators allow a telemetry integrator to define which limits exist for a Limit evaluators allow a telemetry integrator to define which limits exist for a
telemetry endpoint and how limits should be applied to telemetry from a given domain object. telemetry endpoint and how limits should be applied to telemetry from a given domain object.
A limit evaluator can implement the `evalute` method which is used to define how limits A limit evaluator can implement the `evaluate` method which is used to define how limits
should be applied to telemetry and the `getLimits` method which is used to specify should be applied to telemetry and the `getLimits` method which is used to specify
what the limit values are for different limit levels. what the limit values are for different limit levels.
@ -841,10 +848,9 @@ Setting the active time system will trigger a [`'timeSystemChanged'`](#time-even
event. If you supplied bounds, a [`'boundsChanged'`](#time-events) event will be triggered afterwards with your newly supplied bounds. event. If you supplied bounds, a [`'boundsChanged'`](#time-events) event will be triggered afterwards with your newly supplied bounds.
> ⚠️ **Deprecated** > ⚠️ **Deprecated**
>
> - The method `timeSystem()` is deprecated. Please use `getTimeSystem()` and `setTimeSystem()` as a replacement. > - The method `timeSystem()` is deprecated. Please use `getTimeSystem()` and `setTimeSystem()` as a replacement.
#### Time Bounds #### Time Bounds
The TimeAPI provides a getter and setter for querying and setting time bounds. Time The TimeAPI provides a getter and setter for querying and setting time bounds. Time
@ -875,6 +881,7 @@ To respond to bounds change events, listen for the [`'boundsChanged'`](#time-eve
event. event.
> ⚠️ **Deprecated** > ⚠️ **Deprecated**
>
> - The method `bounds()` is deprecated and will be removed in a future release. Please use `getBounds()` and `setBounds()` as a replacement. > - The method `bounds()` is deprecated and will be removed in a future release. Please use `getBounds()` and `setBounds()` as a replacement.
### Clocks ### Clocks
@ -972,6 +979,7 @@ openmct.time.getClock();
``` ```
> ⚠️ **Deprecated** > ⚠️ **Deprecated**
>
> - The method `clock()` is deprecated and will be removed in a future release. Please use `getClock()` and `setClock()` as a replacement. > - The method `clock()` is deprecated and will be removed in a future release. Please use `getClock()` and `setClock()` as a replacement.
#### ⚠️ [DEPRECATED] Stopping an active clock #### ⚠️ [DEPRECATED] Stopping an active clock
@ -986,6 +994,7 @@ openmct.time.stopClock();
``` ```
> ⚠️ **Deprecated** > ⚠️ **Deprecated**
>
> - The method `stopClock()` is deprecated and will be removed in a future release. > - The method `stopClock()` is deprecated and will be removed in a future release.
#### Clock Offsets #### Clock Offsets
@ -1026,6 +1035,7 @@ new bounds will be calculated based on the `currentValue()` of the active clock.
Clock offsets are only relevant when in Real-time [mode](#time-modes). Clock offsets are only relevant when in Real-time [mode](#time-modes).
> ⚠️ **Deprecated** > ⚠️ **Deprecated**
>
> - The method `clockOffsets()` is deprecated and will be removed in a future release. Please use `getClockOffsets()` and `setClockOffsets()` as a replacement. > - The method `clockOffsets()` is deprecated and will be removed in a future release. Please use `getClockOffsets()` and `setClockOffsets()` as a replacement.
### Time Modes ### Time Modes
@ -1120,6 +1130,7 @@ The events emitted by the Time API are:
- `mode`: A string representation of the current time mode, either `'realtime'` or `'fixed'`. - `mode`: A string representation of the current time mode, either `'realtime'` or `'fixed'`.
> ⚠️ **Deprecated Events** (These will be removed in a future release): > ⚠️ **Deprecated Events** (These will be removed in a future release):
>
> - `bounds` → `boundsChanged` > - `bounds` → `boundsChanged`
> - `timeSystem` → `timeSystemChanged` > - `timeSystem` → `timeSystemChanged`
> - `clock` → `clockChanged` > - `clock` → `clockChanged`
@ -1180,7 +1191,7 @@ An example time conductor configuration is provided below. It sets up some
default options for the [UTCTimeSystem](https://github.com/nasa/openmct/blob/master/src/plugins/utcTimeSystem/UTCTimeSystem.js) default options for the [UTCTimeSystem](https://github.com/nasa/openmct/blob/master/src/plugins/utcTimeSystem/UTCTimeSystem.js)
and [LocalTimeSystem](https://github.com/nasa/openmct/blob/master/src/plugins/localTimeSystem/LocalTimeSystem.js), and [LocalTimeSystem](https://github.com/nasa/openmct/blob/master/src/plugins/localTimeSystem/LocalTimeSystem.js),
in both fixed mode, and for the [LocalClock](https://github.com/nasa/openmct/blob/master/src/plugins/utcTimeSystem/LocalClock.js) in both fixed mode, and for the [LocalClock](https://github.com/nasa/openmct/blob/master/src/plugins/utcTimeSystem/LocalClock.js)
source. In this configutation, the local clock supports both the UTCTimeSystem source. In this configuration, the local clock supports both the UTCTimeSystem
and LocalTimeSystem. Configuration for fixed bounds mode is specified by omitting and LocalTimeSystem. Configuration for fixed bounds mode is specified by omitting
a clock key. a clock key.
@ -1190,7 +1201,7 @@ const ONE_MINUTE = 60 * 1000;
openmct.install(openmct.plugins.Conductor({ openmct.install(openmct.plugins.Conductor({
menuOptions: [ menuOptions: [
// 'Fixed' bounds mode configuation for the UTCTimeSystem // 'Fixed' bounds mode configuration for the UTCTimeSystem
{ {
timeSystem: 'utc', timeSystem: 'utc',
bounds: {start: Date.now() - 30 * ONE_MINUTE, end: Date.now()}, bounds: {start: Date.now() - 30 * ONE_MINUTE, end: Date.now()},
@ -1262,7 +1273,7 @@ Returns the currently set text as a `string`.
[the built-in glyphs](https://nasa.github.io/openmct/style-guide/#/browse/styleguide:home/glyphs?view=styleguide.glyphs) [the built-in glyphs](https://nasa.github.io/openmct/style-guide/#/browse/styleguide:home/glyphs?view=styleguide.glyphs)
may be used here, or a custom CSS class can be provided. Returns the currently defined CSS may be used here, or a custom CSS class can be provided. Returns the currently defined CSS
class as a `string`. class as a `string`.
- `.statusClass([className])`: Gets or sets the CSS class used to determine status. Accepts an __optional__ - `.statusClass([className])`: Gets or sets the CSS class used to determine status. Accepts an **optional**
`string` parameter to be used to set a status class applied to the indicator. May be used to apply `string` parameter to be used to set a status class applied to the indicator. May be used to apply
different colors to indicate status. different colors to indicate status.
@ -1305,6 +1316,16 @@ View provider Example:
} }
``` ```
## User API
Open MCT provides a User API which can be used to define providers for user information. The API
can be used to manage user information and roles.
### Example
Open MCT provides an example [user](example/exampleUser/exampleUserCreator.js) and [user provider](example/exampleUser/ExampleUserProvider.js) which
can be used as a starting point for creating a custom user provider.
## Visibility-Based Rendering in View Providers ## Visibility-Based Rendering in View Providers
To enhance performance and resource efficiency in OpenMCT, a visibility-based rendering feature has been added. This feature is designed to defer the execution of rendering logic for views that are not currently visible. It ensures that views are only updated when they are in the viewport, similar to how modern browsers handle rendering of inactive tabs but optimized for the OpenMCT tabbed display. It also works when views are scrolled outside the viewport (e.g., in a Display Layout). To enhance performance and resource efficiency in OpenMCT, a visibility-based rendering feature has been added. This feature is designed to defer the execution of rendering logic for views that are not currently visible. It ensures that views are only updated when they are in the viewport, similar to how modern browsers handle rendering of inactive tabs but optimized for the OpenMCT tabbed display. It also works when views are scrolled outside the viewport (e.g., in a Display Layout).
@ -1325,10 +1346,10 @@ Heres the signature for the show function:
`show(element, isEditing, viewOptions)` `show(element, isEditing, viewOptions)`
* `element` (HTMLElement) - The DOM element where the view should be rendered. - `element` (HTMLElement) - The DOM element where the view should be rendered.
* `isEditing` (boolean) - Indicates whether the view is in editing mode. - `isEditing` (boolean) - Indicates whether the view is in editing mode.
* `viewOptions` (Object) - An object with configuration options for the view, including: - `viewOptions` (Object) - An object with configuration options for the view, including:
* `renderWhenVisible` (Function) - This function wraps the `requestAnimationFrame` and only triggers the provided render logic when the view is visible in the viewport. - `renderWhenVisible` (Function) - This function wraps the `requestAnimationFrame` and only triggers the provided render logic when the view is visible in the viewport.
### Example ### Example
@ -1357,8 +1378,6 @@ const myViewProvider = {
}; };
``` ```
Note that `renderWhenVisible` defers rendering while the view is not visible and caters to the latest execution call. This provides responsiveness for dynamic content while ensuring performance optimizations. Note that `renderWhenVisible` defers rendering while the view is not visible and caters to the latest execution call. This provides responsiveness for dynamic content while ensuring performance optimizations.
Ensure your view logic is prepared to handle potentially multiple deferrals if using this API, as only the last call to renderWhenVisible will be queued for execution upon the view becoming visible. Ensure your view logic is prepared to handle potentially multiple deferrals if using this API, as only the last call to renderWhenVisible will be queued for execution upon the view becoming visible.

View File

@ -5,12 +5,10 @@ Open MCT (Open Mission Control Technologies) is a next-generation mission contro
> [!NOTE] > [!NOTE]
> Please visit our [Official Site](https://nasa.github.io/openmct/) and [Getting Started Guide](https://nasa.github.io/openmct/getting-started/) > Please visit our [Official Site](https://nasa.github.io/openmct/) and [Getting Started Guide](https://nasa.github.io/openmct/getting-started/)
Once you've created something amazing with Open MCT, showcase your work in our GitHub Discussions [Show and Tell](https://github.com/nasa/openmct/discussions/categories/show-and-tell) section. We love seeing unique and wonderful implementations of Open MCT! Once you've created something amazing with Open MCT, showcase your work in our GitHub Discussions [Show and Tell](https://github.com/nasa/openmct/discussions/categories/show-and-tell) section. We love seeing unique and wonderful implementations of Open MCT!
![Screen Shot 2022-11-23 at 9 51 36 AM](https://user-images.githubusercontent.com/4215777/203617422-4d912bfc-766f-4074-8324-409d9bbe7c05.png) ![Screen Shot 2022-11-23 at 9 51 36 AM](https://user-images.githubusercontent.com/4215777/203617422-4d912bfc-766f-4074-8324-409d9bbe7c05.png)
## Building and Running Open MCT Locally ## Building and Running Open MCT Locally
Building and running Open MCT in your local dev environment is very easy. Be sure you have [Git](https://git-scm.com/downloads) and [Node.js](https://nodejs.org/) installed, then follow the directions below. Need additional information? Check out the [Getting Started](https://nasa.github.io/openmct/getting-started/) page on our website. Building and running Open MCT in your local dev environment is very easy. Be sure you have [Git](https://git-scm.com/downloads) and [Node.js](https://nodejs.org/) installed, then follow the directions below. Need additional information? Check out the [Getting Started](https://nasa.github.io/openmct/getting-started/) page on our website.
@ -18,19 +16,19 @@ Building and running Open MCT in your local dev environment is very easy. Be sur
1. Clone the source code: 1. Clone the source code:
``` ```sh
git clone https://github.com/nasa/openmct.git git clone https://github.com/nasa/openmct.git
``` ```
2. (Optional) Install the correct node version using [nvm](https://github.com/nvm-sh/nvm): 2. (Optional) Install the correct node version using [nvm](https://github.com/nvm-sh/nvm):
``` ```sh
nvm install nvm install
``` ```
3. Install development dependencies (Note: Check the `package.json` engine for our tested and supported node versions): 3. Install development dependencies (Note: Check the `package.json` engine for our tested and supported node versions):
``` ```sh
npm install npm install
``` ```
@ -68,11 +66,11 @@ For more on developing with Open MCT, see our documentation for a guide on [Deve
## Compatibility ## Compatibility
This is a fast moving project and we do our best to test and support the widest possible range of browsers, operating systems, and nodejs APIs. We have a published list of support available in our package.json's `browserslist` key. This is a fast moving project and we do our best to test and support the widest possible range of browsers, operating systems, and NodeJS APIs. We have a published list of support available in our package.json's `browserslist` key.
The project uses `nvm` to ensure the node and npm version used, is coherent in all projects. Install nvm (non-windows), [here](https://github.com/nvm-sh/nvm) or the windows equivalent [here](https://github.com/coreybutler/nvm-windows) The project utilizes `nvm` to maintain consistent node and npm versions across all projects. For UNIX, MacOS, Windows WSL, and other POSIX-compliant shell environments, click [here](https://github.com/nvm-sh/nvm). For Windows, check out [nvm-windows](https://github.com/coreybutler/nvm-windows).
If you encounter an issue with a particular browser, OS, or nodejs API, please file a [GitHub issue](https://github.com/nasa/openmct/issues/new/choose) If you encounter an issue with a particular browser, OS, or NodeJS API, please [file an issue](https://github.com/nasa/openmct/issues/new/choose).
## Plugins ## Plugins
@ -90,6 +88,7 @@ For information on writing plugins, please see [our API documentation](./API.md#
Our automated test coverage comes in the form of unit, e2e, visual, performance, and security tests. Our automated test coverage comes in the form of unit, e2e, visual, performance, and security tests.
### Unit Tests ### Unit Tests
Unit Tests are written for [Jasmine](https://jasmine.github.io/api/edge/global) Unit Tests are written for [Jasmine](https://jasmine.github.io/api/edge/global)
and run by [Karma](http://karma-runner.github.io). To run: and run by [Karma](http://karma-runner.github.io). To run:
@ -101,24 +100,34 @@ in the `src` hierarchy. Full configuration details are found in
alongside the units that they test; for example, `src/foo/Bar.js` would be alongside the units that they test; for example, `src/foo/Bar.js` would be
tested by `src/foo/BarSpec.js`. tested by `src/foo/BarSpec.js`.
### e2e, Visual, and Performance tests ### e2e, Visual, and Performance Testing
The e2e, Visual, and Performance tests are written for playwright and run by playwright's new test runner [@playwright/test](https://playwright.dev/).
To run the e2e tests which are part of every commit: Our e2e (end-to-end), Visual, and Performance tests leverage the Playwright framework and are executed using Playwright's test runner, [@playwright/test](https://playwright.dev/).
`npm run test:e2e:stable` #### How to Run Tests
To run the visual test suite: - **e2e Tests**: These tests are run on every commit. To run the tests locally, use:
`npm run test:e2e:visual` ```sh
npm run test:e2e:ci
```
To run the performance tests: - **Visual Tests**: For running the visual test suite, use:
`npm run test:perf` ```sh
npm run test:e2e:visual
```
The test suite is configured to all tests located in `e2e/tests/` ending in `*.e2e.spec.js`. For more about the e2e test suite, please see the [README](./e2e/README.md) - **Performance Tests**: To initiate the performance tests, enter:
```sh
npm run test:perf
```
All tests are located within the `e2e/tests/` directory and are identified by the `*.e2e.spec.js` filename pattern. For more information about the e2e test suite, refer to the [README](./e2e/README.md).
### Security Tests ### Security Tests
Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/). The list of CWE coverage items is available in the [CodeQL docs](https://codeql.github.com/codeql-query-help/javascript-cwe/). The CodeQL workflow is specified in the [CodeQL analysis file](./.github/workflows/codeql-analysis.yml) and the custom [CodeQL config](./.github/codeql/codeql-config.yml). Each commit is analyzed for known security vulnerabilities using [CodeQL](https://codeql.github.com/docs/codeql-language-guides/codeql-library-for-javascript/). The list of CWE coverage items is available in the [CodeQL docs](https://codeql.github.com/codeql-query-help/javascript-cwe/). The CodeQL workflow is specified in the [CodeQL analysis file](./.github/workflows/codeql-analysis.yml) and the custom [CodeQL config](./.github/codeql/codeql-config.yml).
### Test Reporting and Code Coverage ### Test Reporting and Code Coverage
@ -129,60 +138,41 @@ Our code coverage is generated during the runtime of our unit, e2e, and visual t
For more on the specifics of our code coverage setup, [see](TESTING.md#code-coverage) For more on the specifics of our code coverage setup, [see](TESTING.md#code-coverage)
# Glossary ## Glossary
Certain terms are used throughout Open MCT with consistent meanings Certain terms are used throughout Open MCT with consistent meanings
or conventions. Any deviations from the below are issues and should be or conventions. Any deviations from the below are issues and should be
addressed (either by updating this glossary or changing code to reflect addressed (either by updating this glossary or changing code to reflect
correct usage.) Other developer documentation, particularly in-line correct usage.) Other developer documentation, particularly in-line
documentation, may presume an understanding of these terms. documentation, may presume an understanding of these terms.
| Term | Definition |
* _plugin_: A plugin is a removable, reusable grouping of software elements. |---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
The application is composed of plugins. | _plugin_ | A removable, reusable grouping of software elements. The application is composed of plugins. |
* _composition_: In the context of a domain object, this refers to the set of | _composition_ | In the context of a domain object, this term refers to the set of other domain objects that compose or are contained by that object. A domain object's composition is the set of domain objects that should appear immediately beneath it in a tree hierarchy. It is described in its model as an array of ids, providing a means to asynchronously retrieve the actual domain object instances associated with these identifiers. |
other domain objects that compose or are contained by that object. A domain | _description_ | When used as an object property, this term refers to the human-readable description of a thing, usually a single sentence or short paragraph. It is most often used in the context of extensions, domain object models, or other similar application-specific objects. |
object's composition is the set of domain objects that should appear | _domain object_ | A meaningful object to the user and a distinct thing in the work supported by Open MCT. Anything that appears in the left-hand tree is a domain object. |
immediately beneath it in a tree hierarchy. A domain object's composition is | _identifier_ | A tuple consisting of a namespace and a key, which together uniquely identifies a domain object. |
described in its model as an array of id's; its composition capability | _model_ | The persistent state associated with a domain object. A domain object's model is a JavaScript object that can be converted to JSON without losing information, meaning it contains no methods. |
provides a means to retrieve the actual domain object instances associated | _name_ | When used as an object property, this term refers to the human-readable name for a thing. It is most often used in the context of extensions, domain object models, or other similar application-specific objects. |
with these identifiers asynchronously. | _navigation_ | This term refers to the current state of the application with respect to the user's expressed interest in a specific domain object. For example, when a user clicks on a domain object in the tree, they are navigating to it, and it is thereafter considered the navigated object until the user makes another such choice. |
* _description_: When used as an object property, this refers to the human-readable | _namespace_ | A name used to identify a persistence store. A running Open MCT application could potentially use multiple persistence stores. |
description of a thing; usually a single sentence or short paragraph.
(Most often used in the context of extensions, domain
object models, or other similar application-specific objects.)
* _domain object_: A meaningful object to the user; a distinct thing in
the work support by Open MCT. Anything that appears in the left-hand
tree is a domain object.
* _identifier_: A tuple consisting of a namespace and a key, which together uniquely
identifies a domain object.
* _model_: The persistent state associated with a domain object. A domain
object's model is a JavaScript object which can be converted to JSON
without losing information (that is, it contains no methods.)
* _name_: When used as an object property, this refers to the human-readable
name for a thing. (Most often used in the context of extensions, domain
object models, or other similar application-specific objects.)
* _navigation_: Refers to the current state of the application with respect
to the user's expressed interest in a specific domain object; e.g. when
a user clicks on a domain object in the tree, they are _navigating_ to
it, and it is thereafter considered the _navigated_ object (until the
user makes another such choice.)
* _namespace_: A name used to identify a persistence store. A running open MCT
application could potentially use multiple persistence stores, with the
## Open MCT v2.0.0 ## Open MCT v2.0.0
Support for our legacy bundle-based API, and the libraries that it was built on (like Angular 1.x), have now been removed entirely from this repository. Support for our legacy bundle-based API, and the libraries that it was built on (like Angular 1.x), have now been removed entirely from this repository.
For now if you have an Open MCT application that makes use of the legacy API, [a plugin](https://github.com/nasa/openmct-legacy-plugin) is provided that bootstraps the legacy bundling mechanism and API. This plugin will not be maintained over the long term however, and the legacy support plugin will not be tested for compatibility with future versions of Open MCT. It is provided for convenience only. For now if you have an Open MCT application that makes use of the legacy API, [a plugin](https://github.com/nasa/openmct-legacy-plugin) is provided that bootstraps the legacy bundling mechanism and API. This plugin will not be maintained over the long term however, and the legacy support plugin will not be tested for compatibility with future versions of Open MCT. It is provided for convenience only.
### How do I know if I am using legacy API? ### How do I know if I am using legacy API?
You might still be using legacy API if your source code You might still be using legacy API if your source code
* Contains files named bundle.js, or bundle.json, - Contains files named bundle.js, or bundle.json,
* Makes calls to `openmct.$injector()`, or `openmct.$angular`, - Makes calls to `openmct.$injector()`, or `openmct.$angular`,
* Makes calls to `openmct.legacyRegistry`, `openmct.legacyExtension`, or `openmct.legacyBundle`. - Makes calls to `openmct.legacyRegistry`, `openmct.legacyExtension`, or `openmct.legacyBundle`.
### What should I do if I am using legacy API? ### What should I do if I am using legacy API?
Please refer to [the modern Open MCT API](https://nasa.github.io/openmct/documentation/). Post any questions to the [Discussions section](https://github.com/nasa/openmct/discussions) of the Open MCT GitHub repository. Please refer to [the modern Open MCT API](https://nasa.github.io/openmct/documentation/). Post any questions to the [Discussions section](https://github.com/nasa/openmct/discussions) of the Open MCT GitHub repository.
## Related Repos ## Related Repos

View File

@ -16,8 +16,6 @@ The [CodeQL GitHub Actions workflow](https://github.com/nasa/openmct/blob/master
CodeQL is run for every pull-request in GitHub Actions. CodeQL is run for every pull-request in GitHub Actions.
The project is also monitored by [LGTM](https://lgtm.com/projects/g/nasa/openmct/) and is available to public.
### ESLint ### ESLint
Static analysis is run for every push on the master branch and every pull request on all branches in Github Actions. Static analysis is run for every push on the master branch and every pull request on all branches in Github Actions.

View File

@ -63,11 +63,11 @@ Once the file is generated, it can be published to codecov with
### e2e ### e2e
The e2e line coverage is a bit more complex than the karma implementation. This is the general sequence of events: The e2e line coverage is a bit more complex than the karma implementation. This is the general sequence of events:
1. Each e2e suite will start webpack with the ```npm run start:coverage``` command with config `webpack.coverage.js` and the `babel-plugin-istanbul` plugin to generate code coverage during e2e test execution using our custom [baseFixture](./baseFixtures.js). 1. Each e2e suite will start webpack with the ```npm run start:coverage``` command with config `webpack.coverage.mjs` and the `babel-plugin-istanbul` plugin to generate code coverage during e2e test execution using our custom [baseFixture](./baseFixtures.js).
1. During testcase execution, each e2e shard will generate its piece of the larger coverage suite. **This coverage file is not merged**. The raw coverage file is stored in a `.nyc_report` directory. 1. During testcase execution, each e2e shard will generate its piece of the larger coverage suite. **This coverage file is not merged**. The raw coverage file is stored in a `.nyc_report` directory.
1. [nyc](https://github.com/istanbuljs/nyc) converts this directory into a `lcov` file with the following command `npm run cov:e2e:report` 1. [nyc](https://github.com/istanbuljs/nyc) converts this directory into a `lcov` file with the following command `npm run cov:e2e:report`
1. Most of the tests are run in the '@stable' configuration and focus on chrome/ubuntu at a single resolution. This coverage is published to codecov with `npm run cov:e2e:stable:publish`. 1. Most of the tests focus on chrome/ubuntu at a single resolution. This coverage is published to codecov with `npm run cov:e2e:ci:publish`.
1. The rest of our coverage only appears when run against `@unstable` tests, persistent datastore (couchdb), non-ubuntu machines, and non-chrome browsers with the `npm run cov:e2e:full:publish` flag. Since this happens about once a day, we have leveraged codecov.io's carryforward flag to report on lines covered outside of each commit on an individual PR. 1. The rest of our coverage only appears when run against persistent datastore (couchdb), non-ubuntu machines, and non-chrome browsers with the `npm run cov:e2e:full:publish` flag. Since this happens about once a day, we have leveraged codecov.io's carryforward flag to report on lines covered outside of each commit on an individual PR.
### Limitations in our code coverage reporting ### Limitations in our code coverage reporting
@ -91,12 +91,14 @@ There are a few reasons that your GitHub PR could be failing beyond simple faile
### Local=Pass and CI=Fail ### Local=Pass and CI=Fail
Although rare, it is possible that your test can pass locally but fail in CI. Although rare, it is possible that your test can pass locally but fail in CI.
#### Busting Cache ### Reset your workspace
In certain circumstances, the CircleCI cache can become stale. In order to bust the cache, we've implemented a runtime boolean parameter in Circle CI creatively name BUST_CACHE. To execute: It's possible that you're running with dependencies or a local environment which is out of sync with the branch you're working on. Make sure to execute the following:
1. Navigate to the branch in Circle CI believed to have stale cache.
1. Click on the 'Trigger Pipeline' button. ```sh
1. Add Parameter -> Parameter Type = boolean , Name = BUST_CACHE ,Value = true nvm use
1. Click 'Trigger Pipeline' npm run clean
npm install
```
#### Run tests in the same container as CI #### Run tests in the same container as CI

View File

@ -11,18 +11,18 @@ coverage:
informational: true informational: true
precision: 2 precision: 2
round: down round: down
range: '66...100' range: "66...100"
flags: flags:
unit: unit:
carryforward: false carryforward: false
e2e-stable: e2e-ci:
carryforward: false carryforward: false
e2e-full: e2e-full:
carryforward: true carryforward: true
comment: comment:
layout: 'diff,flags,files,footer' layout: "diff,flags,files,footer"
behavior: default behavior: default
require_changes: false require_changes: false
show_carryforward_flags: true show_carryforward_flags: true

View File

@ -15,12 +15,12 @@
## Sections ## Sections
* The [API](api/) uses inline documentation * The [API](api/) uses inline documentation.
using [TypeScript](https://www.typescriptlang.org) and some legacy [JSDoc](https://jsdoc.app/). It describes the JavaScript objects and using [TypeScript](https://www.typescriptlang.org) and some legacy [JSDoc](https://jsdoc.app/). It describes the JavaScript objects and
functions that make up the software platform. functions that make up the software platform.
* The [Development Process](process/) document describes the * The [Development Process](process/) document describes the
Open MCT software development cycle. Open MCT software development cycle.
* The [Tutorials](https://github.com/nasa/openmct-tutorial) give examples of extending the platform to add * The [tutorial](https://github.com/nasa/openmct-tutorial) and [plugin template](https://github.com/nasa/openmct-hello) give examples of extending the platform to add
functionality, and integrate with data sources. functionality and integrate with data sources.

View File

@ -133,7 +133,7 @@ emphasis on testing.
Multi-user testing, involving as many users as Multi-user testing, involving as many users as
is feasible, plus development team. Open-ended; should verify is feasible, plus development team. Open-ended; should verify
completed work from this sprint using the sprint branch, test completed work from this sprint using the sprint branch, test
exploratorily for regressions, et cetera. exploratory for regressions, et cetera.
* [__Long-Duration Test.__](testing/plan.md#long-duration-testing) A * [__Long-Duration Test.__](testing/plan.md#long-duration-testing) A
test to verify that the software remains test to verify that the software remains
stable after running for longer durations. May include some stable after running for longer durations. May include some

View File

@ -132,7 +132,7 @@ numbers by the following process:
4. Test the package before publishing by doing `npm publish --dry-run` 4. Test the package before publishing by doing `npm publish --dry-run`
if necessary. if necessary.
5. Publish the package to the npmjs registry (e.g. `npm publish --access public`) 5. Publish the package to the npmjs registry (e.g. `npm publish --access public`)
NOTE: Use the `--tag unstable` flag to the npm publishj if this is a prerelease. NOTE: Use the `--tag unstable` flag to the npm publish if this is a prerelease.
6. Confirm the package has been published (e.g. `https://www.npmjs.com/package/openmct`) 6. Confirm the package has been published (e.g. `https://www.npmjs.com/package/openmct`)
5. Update snapshot status in `package.json` 5. Update snapshot status in `package.json`
1. Create a new branch off the `master` branch. 1. Create a new branch off the `master` branch.

View File

@ -1,14 +1,37 @@
/* eslint-disable no-undef */ /* eslint-disable no-undef */
module.exports = { module.exports = {
extends: ['plugin:playwright/playwright-test'], extends: ['plugin:playwright/recommended'],
rules: { rules: {
'playwright/max-nested-describe': ['error', { max: 1 }] 'playwright/max-nested-describe': ['error', { max: 1 }],
'playwright/expect-expect': 'off'
}, },
overrides: [ overrides: [
{ {
files: ['tests/visual/*.spec.js'], //Apply Best Practices to externalFixtures and exampleTemplate.e2e.spec.js
files: [
'appActions.js',
'baseFixtures.js',
'pluginFixtures.js',
'**/exampleTemplate.e2e.spec.js'
],
rules: { rules: {
'playwright/no-wait-for-timeout': 'off' 'playwright/no-raw-locators': 'error',
'playwright/no-nth-methods': 'error',
'playwright/no-get-by-title': 'error',
'playwright/prefer-comparison-matcher': 'error'
}
},
{
// Disable no-raw-locators for .contract.perf.spec.js files until https://github.com/grafana/xk6-browser/issues/1226
files: ['**/*.contract.perf.spec.js'],
rules: {
'playwright/no-raw-locators': 'off'
}
},
{
files: ['**/*.visual.spec.js'],
rules: {
'playwright/no-networkidle': 'off' //https://github.com/nasa/openmct/issues/7549
} }
} }
] ]

7
e2e/.npmignore Normal file
View File

@ -0,0 +1,7 @@
*
!appActions.js
!baseFixtures.js
!pluginFixtures.js
!avpFixtures.js
!index.js
!*.md

View File

@ -30,4 +30,15 @@ snapshot:
.gl-plot-chart-area{ .gl-plot-chart-area{
opacity: 0 !important; opacity: 0 !important;
} }
/* SWG Time values on plot */
.gl-plot-x{
opacity: 0 !important;
}
/* Notification Time in modal */
.c-ne__time{
opacity: 0 !important;
}
/* Snapshot name with embedded time */
.l-browse-bar__snapshot-datetime{
opacity: 0 !important;
}

View File

@ -30,3 +30,15 @@ snapshot:
.gl-plot-chart-area{ .gl-plot-chart-area{
opacity: 0 !important; opacity: 0 !important;
} }
/* SWG Time values on plot */
.gl-plot-x{
opacity: 0 !important;
}
/* Notification Time in modal */
.c-ne__time{
opacity: 0 !important;
}
/* Snapshot name with embedded time */
.l-browse-bar__snapshot-datetime{
opacity: 0 !important;
}

View File

@ -78,6 +78,7 @@ To read about how to write a good visual test, please see [How to write a great
- `npm run test:e2e:visual:ci` will run against every commit and PR. - `npm run test:e2e:visual:ci` will run against every commit and PR.
- `npm run test:e2e:visual:full` will run every night with additional comparisons made for Larger Displays and with the `snow` theme. - `npm run test:e2e:visual:full` will run every night with additional comparisons made for Larger Displays and with the `snow` theme.
#### Percy.io #### Percy.io
To make this possible, we're leveraging a 3rd party service, [Percy](https://percy.io/). This service maintains a copy of all changes, users, scm-metadata, and baselines to verify that the application looks and feels the same _unless approved by a Open MCT developer_. To request a Percy API token, please reach out to the Open MCT Dev team on GitHub. For more information, please see the official [Percy documentation](https://docs.percy.io/docs/visual-testing-basics). To make this possible, we're leveraging a 3rd party service, [Percy](https://percy.io/). This service maintains a copy of all changes, users, scm-metadata, and baselines to verify that the application looks and feels the same _unless approved by a Open MCT developer_. To request a Percy API token, please reach out to the Open MCT Dev team on GitHub. For more information, please see the official [Percy documentation](https://docs.percy.io/docs/visual-testing-basics).
@ -89,15 +90,16 @@ At present, we are using percy with two configuration files: `./e2e/.percy.night
While snapshot testing offers a precise way to detect changes in your application without relying on third-party services like Percy.io, we've found that it doesn't offer any advantages over visual testing in our use-cases. Therefore, snapshot testing is **not recommended** for further implementation. While snapshot testing offers a precise way to detect changes in your application without relying on third-party services like Percy.io, we've found that it doesn't offer any advantages over visual testing in our use-cases. Therefore, snapshot testing is **not recommended** for further implementation.
#### CI vs Manual Checks #### CI vs Manual Checks
Snapshot tests can be reliably executed in Continuous Integration (CI) environments but lack the manual oversight provided by visual testing platforms like Percy.io. This means they may miss issues that a human reviewer could catch during manual checks. Snapshot tests can be reliably executed in Continuous Integration (CI) environments but lack the manual oversight provided by visual testing platforms like Percy.io. This means they may miss issues that a human reviewer could catch during manual checks.
#### Example #### Example
A single visual test assertion in Percy.io can be executed across 10 different browser and resolution combinations without additional setup, providing comprehensive testing with minimal configuration. In contrast, a snapshot test is restricted to a single OS and browser resolution, requiring more effort to achieve the same level of coverage. A single visual test assertion in Percy.io can be executed across 10 different browser and resolution combinations without additional setup, providing comprehensive testing with minimal configuration. In contrast, a snapshot test is restricted to a single OS and browser resolution, requiring more effort to achieve the same level of coverage.
#### Further Reading #### Further Reading
For those interested in the mechanics of snapshot testing with Playwright, you can refer to the [Playwright Snapshots Documentation](https://playwright.dev/docs/test-snapshots). However, keep in mind that we do not recommend using this approach.
For those interested in the mechanics of snapshot testing with Playwright, you can refer to the [Playwright Snapshots Documentation](https://playwright.dev/docs/test-snapshots). However, keep in mind that we do not recommend using this approach.
#### Open MCT's implementation #### Open MCT's implementation
@ -118,14 +120,6 @@ When the `@snapshot` tests fail, they will need to be evaluated to determine if
To compare a snapshot, run a test and open the html report with the 'Expected' vs 'Actual' screenshot. If the actual screenshot is preferred, then the source-controlled 'Expected' snapshots will need to be updated with the following scripts. To compare a snapshot, run a test and open the html report with the 'Expected' vs 'Actual' screenshot. If the actual screenshot is preferred, then the source-controlled 'Expected' snapshots will need to be updated with the following scripts.
MacOS
```
npm run test:e2e:updatesnapshots
```
Linux/CI
```sh ```sh
// Replace {X.X.X} with the current Playwright version // Replace {X.X.X} with the current Playwright version
// from our package.json or circleCI configuration file // from our package.json or circleCI configuration file
@ -173,9 +167,9 @@ When an a11y test fails, the result must be interpreted in the html test report
The open source performance tests function in three ways which match their naming and folder structure: The open source performance tests function in three ways which match their naming and folder structure:
`./e2e/tests/performance` - The tests at the root of this folder path detect functional changes which are mostly apparent with large performance regressions like [this](https://github.com/nasa/openmct/issues/6879). These tests run against openmct webpack in `production-mode` with the `npm run test:perf:localhost` script. `tests/performance` - The tests at the root of this folder path detect functional changes which are mostly apparent with large performance regressions like [this](https://github.com/nasa/openmct/issues/6879). These tests run against openmct webpack in `production-mode` with the `npm run test:perf:localhost` script.
`./e2e/tests/performance/contract/` - These tests serve as [contracts](https://martinfowler.com/bliki/ContractTest.html) for the locator logic, functionality, and assumptions will work in our downstream, closed source test suites. These tests run against openmct webpack in `dev-mode` with the `npm run test:perf:contract` script. `tests/performance/contract/` - These tests serve as [contracts](https://martinfowler.com/bliki/ContractTest.html) for the locator logic, functionality, and assumptions will work in our downstream, closed source test suites. These tests run against openmct webpack in `dev-mode` with the `npm run test:perf:contract` script.
`./e2e/tests/performance/memory/` - These tests execute memory leak detection checks in various ways. This is expected to evolve as we move to the `memlab` project. These tests run against openmct webpack in `production-mode` with the `npm run test:perf:memory` script. `tests/performance/memory/` - These tests execute memory leak detection checks in various ways. This is expected to evolve as we move to the `memlab` project. These tests run against openmct webpack in `production-mode` with the `npm run test:perf:memory` script.
These tests are expected to become blocking and gating with assertions as we extend the capabilities of Playwright. These tests are expected to become blocking and gating with assertions as we extend the capabilities of Playwright.
@ -187,7 +181,7 @@ In addition to the explicit definition of performance tests, we also ensure that
### File Structure ### File Structure
Our file structure follows the type of type of testing being excercised at the e2e layer and files containing test suites which matcher application behavior or our `src` and `example` layout. This area is not well refined as we figure out what works best for closed source and downstream projects. This may change altogether if we move `e2e` to it's own npm package. Our file structure follows the type of type of testing being exercised at the e2e layer and files containing test suites which matcher application behavior or our `src` and `example` layout. This area is not well refined as we figure out what works best for closed source and downstream projects. This may change altogether if we move `e2e` to it's own npm package.
|File Path|Description| |File Path|Description|
|:-:|-| |:-:|-|
@ -229,19 +223,19 @@ Current list of test tags:
|Test Tag|Description| |Test Tag|Description|
|:-:|-| |:-:|-|
|`@ipad` | Test case or test suite is compatible with Playwright's iPad support and Open MCT's read-only mobile view (i.e. no create button).| |`@mobile` | Test case or test suite is compatible with Playwright's iPad support and Open MCT's read-only mobile view (i.e. no create button).|
|`@a11y` | Test case or test suite to execute playwright-axe accessibility checks and generate a11y reports.| |`@a11y` | Test case or test suite to execute playwright-axe accessibility checks and generate a11y reports.|
|`@gds` | Denotes a GDS Test Case used in the VIPER Mission.|
|`@addInit` | Initializes the browser with an injected and artificial state. Useful for loading non-default plugins. Likely will not work outside of `npm start`.| |`@addInit` | Initializes the browser with an injected and artificial state. Useful for loading non-default plugins. Likely will not work outside of `npm start`.|
|`@localStorage` | Captures or generates session storage to manipulate browser state. Useful for excluding in tests which require a persistent backend (i.e. CouchDB). See [note](#utilizing-localstorage)| |`@localStorage` | Captures or generates session storage to manipulate browser state. Useful for excluding in tests which require a persistent backend (i.e. CouchDB). See [note](#utilizing-localstorage)|
|`@snapshot` | Uses Playwright's snapshot functionality to record a copy of the DOM for direct comparison. Must be run inside of the playwright container.| |`@snapshot` | Uses Playwright's snapshot functionality to record a copy of the DOM for direct comparison. Must be run inside of the playwright container.|
|`@unstable` | A new test or test which is known to be flaky.|
|`@2p` | Indicates that multiple users are involved, or multiple tabs/pages are used. Useful for testing multi-user interactivity.| |`@2p` | Indicates that multiple users are involved, or multiple tabs/pages are used. Useful for testing multi-user interactivity.|
|`@generatedata` | Indicates that a test is used to generate testdata or test the generated test data. Usually to be associated with localstorage, but this may grow over time.| |`@generatedata` | Indicates that a test is used to generate testdata or test the generated test data. Usually to be associated with localstorage, but this may grow over time.|
|`@clock` | A test which modifies the clock. These have expanded out of the visual tests and into the functional tests.
|`@framework` | A test for open mct e2e capabilities. This is primarily to ensure we don't break projects which depend on sourcing this project's fixtures like appActions.js.
### Continuous Integration ### Continuous Integration
The cheapest time to catch a bug is pre-merge. Unfortuantely, this is the most expensive time to run all of the tests since each merge event can consist of hundreds of commits. For this reason, we're selective in _what we run_ as much as _when we run it_. The cheapest time to catch a bug is pre-merge. Unfortunately, this is the most expensive time to run all of the tests since each merge event can consist of hundreds of commits. For this reason, we're selective in _what we run_ as much as _when we run it_.
We leverage CircleCI to run tests against each commit and inject the Test Reports which are generated by Playwright so that they team can keep track of flaky and [historical test trends](https://app.circleci.com/insights/github/nasa/openmct/workflows/overall-circleci-commit-status/tests?branch=master&reporting-window=last-30-days) We leverage CircleCI to run tests against each commit and inject the Test Reports which are generated by Playwright so that they team can keep track of flaky and [historical test trends](https://app.circleci.com/insights/github/nasa/openmct/workflows/overall-circleci-commit-status/tests?branch=master&reporting-window=last-30-days)
@ -253,7 +247,7 @@ Our CI environment consists of 3 main modes of operation:
CircleCI CircleCI
- Stable e2e tests against ubuntu and chrome - e2e tests against ubuntu and chrome
- Performance tests against ubuntu and chrome - Performance tests against ubuntu and chrome
- e2e tests are linted - e2e tests are linted
- Visual and a11y tests are run in a single resolution on the default `espresso` theme - Visual and a11y tests are run in a single resolution on the default `espresso` theme
@ -286,24 +280,12 @@ Playwright has native support for semi-intelligent sharding. Read about it [here
We will be adjusting the parallelization of the Per-Commit tests to keep below the 5 minute total runtime threshold. We will be adjusting the parallelization of the Per-Commit tests to keep below the 5 minute total runtime threshold.
In addition to the Parallelization of Test Runners (Sharding), we're also running two concurrent threads on every Shard. This is the functional limit of what CircelCI Agents can support from a memory and CPU resource constraint. In addition to the Parallelization of Test Runners (Sharding), we're also running two concurrent threads on every Shard. This is the functional limit of what CircleCI Agents can support from a memory and CPU resource constraint.
So for every commit, Playwright is effectively running 4 x 2 concurrent browsercontexts to keep the overall runtime to a miminum. So for every commit, Playwright is effectively running 4 x 2 concurrent browsercontexts to keep the overall runtime to a miminum.
At the same time, we don't want to waste CI resources on parallel runs, so we've configured each shard to fail after 5 test failures. Test failure logs are recorded and stored to allow fast triage. At the same time, we don't want to waste CI resources on parallel runs, so we've configured each shard to fail after 5 test failures. Test failure logs are recorded and stored to allow fast triage.
#### Test Promotion
In order to maintain fast and reliable feedback, tests go through a promotion process. All new test cases or test suites must be labeled with the `@unstable` annotation. The Open MCT dev team runs these unstable tests in our private repos to ensure they work downstream and are reliable.
- To run the stable tests, use the `npm run test:e2e:stable` command.
- To run the new and flaky tests, use the `npm run test:e2e:unstable` command.
A testcase and testsuite are to be unmarked as @unstable when:
1. They run as part of "full" run 5 times without failure.
2. They've been by a Open MCT Developer 5 times in the closed source repo without failure.
### Cross-browser and Cross-operating system ### Cross-browser and Cross-operating system
#### **What's supported:** #### **What's supported:**
@ -329,9 +311,17 @@ In terms of operating system testing, we're only limited by what the CI provider
#### **Mobile** #### **Mobile**
We have the Mission-need to support iPad. To run our iPad suite, please see our `playwright-*.config.js` with the 'iPad' project. We have a Mission-need to support iPad and mobile devices. To run our test suites with mobile devices, please see our `playwright-mobile.config.js` projects.
In general, our test suite is not designed to run against mobile devices as the mobile experience is a focused version of the application. Core functionality is missing (chiefly the 'Create' button) and so this will likely turn into a separate suite. In general, our test suite is not designed to run against mobile devices as the mobile experience is a focused version of the application. Core functionality is missing (chiefly the 'Create' button). To bypass the object creation, we leverage the `storageState` properties for starting the mobile tests with localstorage.
For now, the mobile tests will exist in the /tests/mobile/ suites and be executed with the
```sh
npm run test:e2e:mobile
```
command.
#### **Skipping or executing tests based on browser, os, and/os browser version:** #### **Skipping or executing tests based on browser, os, and/os browser version:**
@ -370,13 +360,14 @@ In general, strive to test only through the UI as a user would. As stated in the
By adhering to this principle, we can create tests that are both robust and reflective of actual user experiences. By adhering to this principle, we can create tests that are both robust and reflective of actual user experiences.
#### How to make tests robust to function in other contexts (VISTA, COUCHDB, YAMCS, VIPER, etc.) #### How to make tests robust to function in other contexts (VISTA, COUCHDB, YAMCS, VIPER, etc.)
1. Leverage the use of `appActions.js` methods such as `createDomainObjectWithDefaults()`. This ensures that your tests will create unique instances of objects for your test to interact with. 1. Leverage the use of `appActions.js` methods such as `createDomainObjectWithDefaults()`. This ensures that your tests will create unique instances of objects for your test to interact with.
1. Do not assert on the order or structure of objects available unless you created them yourself. These tests may be used against a persistent datastore like couchdb with many objects in the tree. 1. Do not assert on the order or structure of objects available unless you created them yourself. These tests may be used against a persistent datastore like couchdb with many objects in the tree.
1. Do not search for your created objects. Open MCT does not performance uniqueness checks so it's possible that your tests will break when run twice. 1. Do not search for your created objects. Open MCT does not performance uniqueness checks so it's possible that your tests will break when run twice.
1. Avoid creating locator aliases. This likely means that you're compensating for a bad locator. Improve the application instead. 1. Avoid creating locator aliases. This likely means that you're compensating for a bad locator. Improve the application instead.
1. Leverage `await page.goto('./', { waitUntil: 'domcontentloaded' });` instead of `{ waitUntil: 'networkidle' }`. Tests run against deployments with websockets often have issues with the networkidle detection. 1. Leverage `await page.goto('./', { waitUntil: 'domcontentloaded' });` instead of `{ waitUntil: 'networkidle' }`. Tests run against deployments with websockets often have issues with the networkidle detection.
#### How to make tests faster and more resilient #### How to make tests faster and more resilient to application changes
1. Avoid app interaction when possible. The best way of doing this is to navigate directly by URL: 1. Avoid app interaction when possible. The best way of doing this is to navigate directly by URL:
```js ```js
@ -391,8 +382,19 @@ By adhering to this principle, we can create tests that are both robust and refl
- Initial navigation should _almost_ always use the `{ waitUntil: 'domcontentloaded' }` option. - Initial navigation should _almost_ always use the `{ waitUntil: 'domcontentloaded' }` option.
1. Avoid repeated setup to test a single assertion. Write longer tests with multiple soft assertions. 1. Avoid repeated setup to test a single assertion. Write longer tests with multiple soft assertions.
This ensures that your changes will be picked up with large refactors. This ensures that your changes will be picked up with large refactors.
1. Use [user-facing locators](https://playwright.dev/docs/best-practices#use-locators) (Now a eslint rule!)
```js
page.getByRole('button', { name: 'Create' } )
```
Instead of
```js
page.locator('.c-create-button')
```
Note: `page.locator()` can be used in performance tests as xk6-browser does not yet support the new `page.getBy` pattern and css lookups can be [1.5x faster](https://serpapi.com/blog/css-selectors-faster-than-getbyrole-playwright/)
##### Utilizing LocalStorage ##### Utilizing LocalStorage
1. In order to save test runtime in the case of tests that require a decent amount of initial setup (such as in the case of testing complex displays), you may use [Playwright's `storageState` feature](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state) to generate and load localStorage states. 1. In order to save test runtime in the case of tests that require a decent amount of initial setup (such as in the case of testing complex displays), you may use [Playwright's `storageState` feature](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state) to generate and load localStorage states.
1. To generate a localStorage state to be used in a test: 1. To generate a localStorage state to be used in a test:
- Add an e2e test to our generateLocalStorageData suite which sets the initial state (creating/configuring objects, etc.), saving it in the `test-data` folder: - Add an e2e test to our generateLocalStorageData suite which sets the initial state (creating/configuring objects, etc.), saving it in the `test-data` folder:
@ -413,7 +415,6 @@ By adhering to this principle, we can create tests that are both robust and refl
}); });
``` ```
### How to write a great test ### How to write a great test
- Avoid using css locators to find elements to the page. Use modern web accessible locators like `getByRole` - Avoid using css locators to find elements to the page. Use modern web accessible locators like `getByRole`
@ -438,22 +439,28 @@ By adhering to this principle, we can create tests that are both robust and refl
3. **Expect the Unexpected**: Use functional expect statements only to verify assumptions about the state between steps. A great visual test doesn't fail during the test itself, but rather when changes are reviewed in Percy.io. 3. **Expect the Unexpected**: Use functional expect statements only to verify assumptions about the state between steps. A great visual test doesn't fail during the test itself, but rather when changes are reviewed in Percy.io.
4. **Control Variability**: Account for variations inherent in working with time-based telemetry and clocks. 4. **Control Variability**: Account for variations inherent in working with time-based telemetry and clocks.
- Utilize `percyCSS` to ignore time-based elements. For more details, consult our [percyCSS file](./.percy.ci.yml). - Utilize `percyCSS` to ignore time-based elements. For more details, consult our [percyCSS file](./.percy.ci.yml).
- Use Open MCT's fixed-time mode unless explicitly testing realtime clock - Use Open MCT's fixed-time mode unless explicitly testing realtime clock
- Employ the `createExampleTelemetryObject` appAction to source telemetry and specify a `name` to avoid autogenerated names. - Employ the `createExampleTelemetryObject` appAction to source telemetry and specify a `name` to avoid autogenerated names.
- Avoid creating objects with a time component like timers and clocks.
- Utilize the playwright clock() API. See @clock Annotations for examples.
5. **Hide the Tree and Inspector**: Generally, your test will not require comparisons involving the tree and inspector. These aspects are covered in component-specific tests (explained below). To exclude them from the comparison by default, navigate to the root of the main view with the tree and inspector hidden: 5. **Hide the Tree and Inspector**: Generally, your test will not require comparisons involving the tree and inspector. These aspects are covered in component-specific tests (explained below). To exclude them from the comparison by default, navigate to the root of the main view with the tree and inspector hidden:
- `await page.goto('./#/browse/mine?hideTree=true&hideInspector=true')` - `await page.goto('./#/browse/mine?hideTree=true&hideInspector=true')`
6. **Component-Specific Tests**: If you wish to focus on a particular component, use the `/visual-a11y/component/` folder and limit the scope of the comparison to that component. For instance: 6. **Component-Specific Tests**: If you wish to focus on a particular component, use the `/visual-a11y/component/` folder and limit the scope of the comparison to that component. For instance:
```js ```js
await percySnapshot(page, `Tree Pane w/ single level expanded (theme: ${theme})`, { await percySnapshot(page, `Tree Pane w/ single level expanded (theme: ${theme})`, {
scope: treePane scope: treePane
}); });
``` ```
- Note: The `scope` variable can be any valid CSS selector. - Note: The `scope` variable can be any valid CSS selector.
7. **Write many `percySnapshot` commands in a single test**: In line with our approach to longer functional tests, we recommend that many test percySnapshots are taken in a single test. For instance: 7. **Write many `percySnapshot` commands in a single test**: In line with our approach to longer functional tests, we recommend that many test percySnapshots are taken in a single test. For instance:
```js ```js
//<Some interesting state> //<Some interesting state>
await percySnapshot(page, `Before object expanded (theme: ${theme})`); await percySnapshot(page, `Before object expanded (theme: ${theme})`);
@ -462,6 +469,7 @@ By adhering to this principle, we can create tests that are both robust and refl
//Select from object //Select from object
await percySnapshot(page, `object selected (theme: ${theme})`) await percySnapshot(page, `object selected (theme: ${theme})`)
``` ```
8. **Use `networkidle` to wait for network requests to complete**: This is necessary to ensure that all network requests have completed before taking a snapshot. This ensures that icons are loaded and other assets are available. https://github.com/nasa/openmct/issues/7549
#### How to write a great network test #### How to write a great network test
@ -483,31 +491,51 @@ For best practices with regards to mocking network responses, see our [couchdb.e
The following contains a list of tips and tricks which don't exactly fit into a FAQ or Best Practices doc. The following contains a list of tips and tricks which don't exactly fit into a FAQ or Best Practices doc.
- (Advanced) Overriding the Browser's Clock - (Advanced) Overriding the Browser's Clock
It is possible to override the browser's clock in order to control time-based elements. Since this can cause unwanted behavior (i.e. Tree not rendering), only use this sparingly. To do this, use the `overrideClock` fixture as such: It is possible to override the browser's clock in order to control time-based elements. Since this can cause unwanted behavior -- i.e. Tree not rendering -- only use this sparingly. Use the `page.clock()` API as such:
```js ```js
import { test, expect } from '../../pluginFixtures.js'; import { test, expect } from '../../pluginFixtures.js';
test.describe('foo test suite', () => { test.describe('foo test suite @clock', () => {
test.beforeEach(async ({ page }) => {
// All subsequent tests in this suite will override the clock //Set clock time
test.use({ await page.clock.install({ time: MISSION_TIME });
clockOptions: { await page.clock.resume();
now: 1732413600000, // A timestamp given as milliseconds since the epoch //Navigate to page with new clock
shouldAdvanceTime: true // Should the clock tick? await page.goto('./', { waitUntil: 'domcontentloaded' });
}
}); });
test('bar test', async ({ page }) => { test('bar here', async ({ page }) => {
// ... /// ...
});
}); });
``` ```
More info and options for `overrideClock` can be found in [baseFixtures.js](baseFixtures.js)
- Working with multiple pages - Working with multiple pages
There are instances where multiple browser pages will needed to verify multi-page or multi-tab application behavior. Make sure to use the `@2p` annotation as well as name each page appropriately: i.e. `page1` and `page2` or `tab1` and `tab2` depending on the intended use case. Generally pages should be used unless testing `sharedWorker` code, specifically. There are instances where multiple browser pages will needed to verify multi-page or multi-tab application behavior. Make sure to use the `@2p` annotation as well as name each page appropriately: i.e. `page1` and `page2` or `tab1` and `tab2` depending on the intended use case. Generally pages should be used unless testing `sharedWorker` code, specifically.
- Working with file downloads and JSON data
Open MCT has the capability of exporting certain objects in the form of a JSON file handled by the chrome browser. The best example of this type of test can be found in the exportAsJson test.
```js
const [download] = await Promise.all([
page.waitForEvent('download'), // Waits for the download event
page.getByLabel('Export as JSON').click() // Triggers the download
]);
// Wait for the download process to complete
const path = await download.path();
// Read the contents of the downloaded file using readFile from fs/promises
const fileContents = await fs.readFile(path, 'utf8');
const jsonData = JSON.parse(fileContents);
// Use the function to retrieve the key
const key = getFirstKeyFromOpenMctJson(jsonData);
// Verify the contents of the JSON file
expect(jsonData.openmct[key]).toHaveProperty('name', 'e2e folder');
```
### Reporting ### Reporting
Test Reporting is done through official Playwright reporters and the CI Systems which execute them. Test Reporting is done through official Playwright reporters and the CI Systems which execute them.
@ -583,6 +611,7 @@ A single e2e test in Open MCT is extended to run:
### Writing Tests ### Writing Tests
Playwright provides 3 supported methods of debugging and authoring tests: Playwright provides 3 supported methods of debugging and authoring tests:
- A 'watch mode' for running tests locally and debugging on the fly - A 'watch mode' for running tests locally and debugging on the fly
- A 'debug mode' for debugging tests and writing assertions against tests - A 'debug mode' for debugging tests and writing assertions against tests
- A 'VSCode plugin' for debugging tests within the VSCode IDE. - A 'VSCode plugin' for debugging tests within the VSCode IDE.

View File

@ -35,7 +35,6 @@
* @property {string} type the type of domain object to create (e.g.: "Sine Wave Generator"). * @property {string} type the type of domain object to create (e.g.: "Sine Wave Generator").
* @property {string} [name] the desired name of the created domain object. * @property {string} [name] the desired name of the created domain object.
* @property {string | import('../src/api/objects/ObjectAPI').Identifier} [parent] the Identifier or uuid of the parent object. * @property {string | import('../src/api/objects/ObjectAPI').Identifier} [parent] the Identifier or uuid of the parent object.
* @property {Object<string, string>} [customParameters] any additional parameters to be passed to the domain object's form. E.g. '[aria-label="Data Rate (hz)"]': {'0.1'}
*/ */
/** /**
@ -62,14 +61,14 @@ import { v4 as genUuid } from 'uuid';
* This common function creates a domain object with the default options. It is the preferred way of creating objects * This common function creates a domain object with the default options. It is the preferred way of creating objects
* in the e2e suite when uninterested in properties of the objects themselves. * in the e2e suite when uninterested in properties of the objects themselves.
* *
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page - The Playwright page object.
* @param {CreateObjectOptions} options * @param {Object} options - Options for creating the domain object.
* @param {string} options.type - The type of domain object to create (e.g., "Sine Wave Generator").
* @param {string} [options.name] - The desired name of the created domain object.
* @param {string | import('../src/api/objects/ObjectAPI').Identifier} [options.parent='mine'] - The Identifier or uuid of the parent object. Defaults to 'mine' folder
* @returns {Promise<CreatedObjectInfo>} An object containing information about the newly created domain object. * @returns {Promise<CreatedObjectInfo>} An object containing information about the newly created domain object.
*/ */
async function createDomainObjectWithDefaults( async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine' }) {
page,
{ type, name, parent = 'mine', customParameters = {} }
) {
if (!name) { if (!name) {
name = `${type}:${genUuid()}`; name = `${type}:${genUuid()}`;
} }
@ -78,40 +77,26 @@ async function createDomainObjectWithDefaults(
// Navigate to the parent object. This is necessary to create the object // Navigate to the parent object. This is necessary to create the object
// in the correct location, such as a folder, layout, or plot. // in the correct location, such as a folder, layout, or plot.
await page.goto(`${parentUrl}`); await page.goto(parentUrl);
// Click the Create button // Click the Create button
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create', exact: true }).click();
// Click the object specified by 'type' // Click the object specified by 'type'-- case insensitive
await page.click(`li[role='menuitem']:text("${type}")`); await page.getByRole('menuitem', { name: new RegExp(`^${type}$`, 'i') }).click();
// Modify the name input field of the domain object to accept 'name' // Fill in the name of the object
const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]'); await page.getByLabel('Title', { exact: true }).fill('');
await nameInput.fill(''); await page.getByLabel('Title', { exact: true }).fill(name);
await nameInput.fill(name);
if (page.testNotes) { if (page.testNotes) {
// Fill the "Notes" section with information about the // Fill the "Notes" section with information about the
// currently running test and its project. // currently running test and its project.
const notesInput = page.locator('form[name="mctForm"] #notes-textarea'); // eslint-disable-next-line playwright/no-raw-locators
await notesInput.fill(page.testNotes); await page.locator('#notes-textarea').fill(page.testNotes);
} }
// If there are any further parameters, fill them in await page.getByRole('button', { name: 'Save' }).click();
for (const [key, value] of Object.entries(customParameters)) {
const input = page.locator(`form[name="mctForm"] ${key}`);
await input.fill('');
await input.fill(value);
}
// Click OK button and wait for Navigate event
await Promise.all([
page.waitForLoadState(),
await page.getByRole('button', { name: 'Save' }).click(),
// Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message')
]);
// Wait until the URL is updated // Wait until the URL is updated
await page.waitForURL(`**/${parent}/*`); await page.waitForURL(`**/${parent}/*`);
@ -151,61 +136,41 @@ async function createNotification(page, createNotificationOptions) {
} }
/** /**
* Expand an item in the tree by a given object name. * Create a Plan object from JSON with the provided options. Must be used with a json based plan.
* Please check appActions.e2e.spec.js for an example of how to use this function.
*
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} name * @param {string} name
*/ * @param {Object} json
async function expandTreePaneItemByName(page, name) { * @param {string | import('../src/api/objects/ObjectAPI').Identifier} [parent] the uuid or identifier of the parent object. Defaults to 'mine'
const treePane = page.getByRole('tree', {
name: 'Main Tree'
});
const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`);
const expandTriangle = treeItem.locator('.c-disclosure-triangle');
await expandTriangle.click();
}
/**
* Create a Plan object from JSON with the provided options.
* @param {import('@playwright/test').Page} page
* @param {*} options
* @returns {Promise<CreatedObjectInfo>} An object containing information about the newly created domain object. * @returns {Promise<CreatedObjectInfo>} An object containing information about the newly created domain object.
*/ */
async function createPlanFromJSON(page, { name, json, parent = 'mine' }) { async function createPlanFromJSON(page, { name, json, parent = 'mine' }) {
if (!name) {
name = `Plan:${genUuid()}`;
}
const parentUrl = await getHashUrlToDomainObject(page, parent); const parentUrl = await getHashUrlToDomainObject(page, parent);
// Navigate to the parent object. This is necessary to create the object // Navigate to the parent object. This is necessary to create the object
// in the correct location, such as a folder, layout, or plot. // in the correct location, such as a folder, layout, or plot.
await page.goto(`${parentUrl}`); await page.goto(`${parentUrl}`);
// Click the Create button
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
// Click 'Plan' menu option await page.getByRole('menuitem', { name: 'Plan' }).click();
await page.click(`li:text("Plan")`);
// Modify the name input field of the domain object to accept 'name' // Fill in the name of the object or generate a random one
const nameInput = page.getByLabel('Title', { exact: true }); if (!name) {
await nameInput.fill(''); name = `Plan:${genUuid()}`;
await nameInput.fill(name); }
await page.getByLabel('Title', { exact: true }).fill('');
await page.getByLabel('Title', { exact: true }).fill(name);
// Upload buffer from memory // Upload buffer from memory
await page.locator('input#fileElem').setInputFiles({ await page.getByLabel('Select File...').setInputFiles({
name: 'plan.txt', name: 'plan.txt',
mimeType: 'text/plain', mimeType: 'text/plain',
buffer: Buffer.from(JSON.stringify(json)) buffer: Buffer.from(JSON.stringify(json))
}); });
// Click OK button and wait for Navigate event await page.getByLabel('Save').click();
await Promise.all([
page.waitForLoadState(),
page.click('[aria-label="Save"]'),
// Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message')
]);
// Wait until the URL is updated // Wait until the URL is updated
await page.waitForURL(`**/${parent}/*`); await page.waitForURL(`**/${parent}/*`);
@ -233,10 +198,10 @@ async function createExampleTelemetryObject(page, parent = 'mine') {
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.locator('li:has-text("Sine Wave Generator")').click(); await page.getByRole('menuitem', { name: 'Sine Wave Generator' }).click();
const name = 'VIPER Rover Heading'; const name = 'VIPER Rover Heading';
await page.getByRole('dialog').locator('input[type="text"]').fill(name); await page.getByLabel('Title', { exact: true }).fill(name);
// Fill out the fields with default values // Fill out the fields with default values
await page.getByRole('spinbutton', { name: 'Period' }).fill('10'); await page.getByRole('spinbutton', { name: 'Period' }).fill('10');
@ -263,7 +228,40 @@ async function createExampleTelemetryObject(page, parent = 'mine') {
} }
/** /**
* Navigates directly to a given object url, in fixed time mode, with the given start and end bounds. * Create a Stable State Telemetry Object (State Generator) for use in visual tests
* and tests against plotting telemetry (e.g. logPlot tests). This will change state every 2 seconds.
* @param {import('@playwright/test').Page} page
* @param {string | import('../src/api/objects/ObjectAPI').Identifier} [parent] the uuid or identifier of the parent object. Defaults to 'mine'
* @returns {Promise<CreatedObjectInfo>} An object containing information about the telemetry object.
*/
async function createStableStateTelemetry(page, parent = 'mine') {
const parentUrl = await getHashUrlToDomainObject(page, parent);
await page.goto(`${parentUrl}`);
const createdObject = await createDomainObjectWithDefaults(page, {
type: 'State Generator',
name: 'Stable State Generator'
});
// edit the state generator to have a 1 second update rate
await page.getByLabel('More actions').click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page.getByLabel('State Duration (seconds)', { exact: true }).fill('2');
await page.getByLabel('Save').click();
// Wait until the URL is updated
const uuid = await getFocusedObjectUuid(page);
const url = await getHashUrlToDomainObject(page, uuid);
return {
name: createdObject.name,
uuid,
url
};
}
/**
* Navigates directly to a given object url, in fixed time mode, with the given start and end bounds. Note: does not set
* default view type.
*
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} url The url to the domainObject * @param {string} url The url to the domainObject
* @param {string | number} start The starting time bound in milliseconds since epoch * @param {string | number} start The starting time bound in milliseconds since epoch
@ -276,22 +274,25 @@ async function navigateToObjectWithFixedTimeBounds(page, url, start, end) {
} }
/** /**
* Open the given `domainObject`'s context menu from the object tree. * Navigates directly to a given object url, in real-time mode. Note: does not set
* Expands the path to the object and scrolls to it if necessary. * default view type.
* *
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} url the url to the object * @param {string} url The url to the domainObject
* @param {string | number} start The start offset in milliseconds
* @param {string | number} end The end offset in milliseconds
*/ */
async function openObjectTreeContextMenu(page, url) { async function navigateToObjectWithRealTime(page, url, start = '1800000', end = '30000') {
await page.goto(url); await page.goto(
await page.getByLabel('Show selected item in tree').click(); `${url}?tc.mode=local&tc.startDelta=${start}&tc.endDelta=${end}&tc.timeSystem=utc`
await page.locator('.is-navigated-object').click({ );
button: 'right'
});
} }
/** /**
* Expands the entire object tree (every expandable tree item). * Expands the entire object tree (every expandable tree item). Can be used to
* ensure that the tree is fully expanded before performing actions on objects.
* Can be applied to either the main tree or the create modal tree.
*
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {"Main Tree" | "Create Modal Tree"} [treeName="Main Tree"] * @param {"Main Tree" | "Create Modal Tree"} [treeName="Main Tree"]
*/ */
@ -303,9 +304,10 @@ async function expandEntireTree(page, treeName = 'Main Tree') {
.getByRole('treeitem', { .getByRole('treeitem', {
expanded: false expanded: false
}) })
.locator('span.c-disclosure-triangle.is-enabled'); .getByLabel(/Expand/);
while ((await collapsedTreeItems.count()) > 0) { while ((await collapsedTreeItems.count()) > 0) {
//eslint-disable-next-line playwright/no-nth-methods
await collapsedTreeItems.nth(0).click(); await collapsedTreeItems.nth(0).click();
// FIXME: Replace hard wait with something event-driven. // FIXME: Replace hard wait with something event-driven.
@ -342,7 +344,7 @@ async function getFocusedObjectUuid(page) {
* @returns {Promise<string>} the url of the object * @returns {Promise<string>} the url of the object
*/ */
async function getHashUrlToDomainObject(page, identifier) { async function getHashUrlToDomainObject(page, identifier) {
await page.waitForLoadState('load'); await page.waitForLoadState('domcontentloaded');
const hashUrl = await page.evaluate(async (objectIdentifier) => { const hashUrl = await page.evaluate(async (objectIdentifier) => {
const path = await window.openmct.objects.getOriginalPath(objectIdentifier); const path = await window.openmct.objects.getOriginalPath(objectIdentifier);
let url = let url =
@ -377,10 +379,11 @@ async function _isInEditMode(page, identifier) {
/** /**
* Set the time conductor mode to either fixed timespan or realtime mode. * Set the time conductor mode to either fixed timespan or realtime mode.
* @private
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {boolean} [isFixedTimespan=true] true for fixed timespan mode, false for realtime mode; default is true * @param {boolean} [isFixedTimespan=true] true for fixed timespan mode, false for realtime mode; default is true
*/ */
async function setTimeConductorMode(page, isFixedTimespan = true) { async function _setTimeConductorMode(page, isFixedTimespan = true) {
// Click 'mode' button // Click 'mode' button
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click(); await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await page.getByRole('button', { name: 'Time Conductor Mode Menu' }).click(); await page.getByRole('button', { name: 'Time Conductor Mode Menu' }).click();
@ -392,6 +395,8 @@ async function setTimeConductorMode(page, isFixedTimespan = true) {
await page.getByRole('menuitem', { name: /Real-Time/ }).click(); await page.getByRole('menuitem', { name: /Real-Time/ }).click();
await page.waitForURL(/tc\.mode=local/); await page.waitForURL(/tc\.mode=local/);
} }
//dismiss the time conductor popup
await page.getByLabel('Discard changes and close time popup').click();
} }
/** /**
@ -399,7 +404,7 @@ async function setTimeConductorMode(page, isFixedTimespan = true) {
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function setFixedTimeMode(page) { async function setFixedTimeMode(page) {
await setTimeConductorMode(page, true); await _setTimeConductorMode(page, true);
} }
/** /**
@ -407,7 +412,7 @@ async function setFixedTimeMode(page) {
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function setRealTimeMode(page) { async function setRealTimeMode(page) {
await setTimeConductorMode(page, false); await _setTimeConductorMode(page, false);
} }
/** /**
@ -423,61 +428,67 @@ async function setRealTimeMode(page) {
/** /**
* Set the values (hours, mins, secs) for the TimeConductor offsets when in realtime mode * Set the values (hours, mins, secs) for the TimeConductor offsets when in realtime mode
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {OffsetValues} offset * @param {OffsetValues} offset - Object containing offset values
* @param {import('@playwright/test').Locator} offsetButton * @param {boolean} [offset.submitChanges=true] - If true, submit the offset changes; otherwise, discard them
*/ */
async function setTimeConductorOffset( async function setTimeConductorOffset(
page, page,
{ startHours, startMins, startSecs, endHours, endMins, endSecs } { startHours, startMins, startSecs, endHours, endMins, endSecs, submitChanges = true }
) { ) {
if (startHours) { if (startHours) {
await page.getByRole('spinbutton', { name: 'Start offset hours' }).fill(startHours); await page.getByLabel('Start offset hours').fill(startHours);
} }
if (startMins) { if (startMins) {
await page.getByRole('spinbutton', { name: 'Start offset minutes' }).fill(startMins); await page.getByLabel('Start offset minutes').fill(startMins);
} }
if (startSecs) { if (startSecs) {
await page.getByRole('spinbutton', { name: 'Start offset seconds' }).fill(startSecs); await page.getByLabel('Start offset seconds').fill(startSecs);
} }
if (endHours) { if (endHours) {
await page.getByRole('spinbutton', { name: 'End offset hours' }).fill(endHours); await page.getByLabel('End offset hours').fill(endHours);
} }
if (endMins) { if (endMins) {
await page.getByRole('spinbutton', { name: 'End offset minutes' }).fill(endMins); await page.getByLabel('End offset minutes').fill(endMins);
} }
if (endSecs) { if (endSecs) {
await page.getByRole('spinbutton', { name: 'End offset seconds' }).fill(endSecs); await page.getByLabel('End offset seconds').fill(endSecs);
} }
// Click the check button // Click the check button
await page.locator('.pr-time-input--buttons .icon-check').click(); if (submitChanges) {
await page.getByLabel('Submit time offsets').click();
} else {
await page.getByLabel('Discard changes and close time popup').click();
}
} }
/** /**
* Set the values (hours, mins, secs) for the start time offset when in realtime mode * Set the values (hours, mins, secs) for the start time offset when in realtime mode
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {OffsetValues} offset * @param {OffsetValues} offset
* @param {boolean} [submit=true] If true, submit the offset changes; otherwise, discard them
*/ */
async function setStartOffset(page, offset) { async function setStartOffset(page, { submitChanges = true, ...offset }) {
// Click 'mode' button // Click 'mode' button
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click(); await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await setTimeConductorOffset(page, offset); await setTimeConductorOffset(page, { submitChanges, ...offset });
} }
/** /**
* Set the values (hours, mins, secs) for the end time offset when in realtime mode * Set the values (hours, mins, secs) for the end time offset when in realtime mode
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {OffsetValues} offset * @param {OffsetValues} offset
* @param {boolean} [submit=true] If true, submit the offset changes; otherwise, discard them
*/ */
async function setEndOffset(page, offset) { async function setEndOffset(page, { submitChanges = true, ...offset }) {
// Click 'mode' button // Click 'mode' button
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click(); await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await setTimeConductorOffset(page, offset); await setTimeConductorOffset(page, { submitChanges, ...offset });
} }
/** /**
@ -486,34 +497,61 @@ async function setEndOffset(page, offset) {
* NOTE: Unless explicitly testing the Time Conductor itself, it is advised to instead * NOTE: Unless explicitly testing the Time Conductor itself, it is advised to instead
* navigate directly to the object with the desired time bounds using `navigateToObjectWithFixedTimeBounds()`. * navigate directly to the object with the desired time bounds using `navigateToObjectWithFixedTimeBounds()`.
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} startDate * @param {Object} bounds - The time conductor bounds
* @param {string} endDate * @param {string} [bounds.startDate] - The start date in YYYY-MM-DD format
* @param {string} [bounds.startTime] - The start time in HH:mm:ss format
* @param {string} [bounds.endDate] - The end date in YYYY-MM-DD format
* @param {string} [bounds.endTime] - The end time in HH:mm:ss format
* @param {boolean} [bounds.submitChanges=true] - If true, submit the changes; otherwise, discard them.
*/ */
async function setTimeConductorBounds(page, startDate, endDate) { async function setTimeConductorBounds(page, { submitChanges = true, ...bounds }) {
// Bring up the time conductor popup const { startDate, endDate, startTime, endTime } = bounds;
expect(await page.locator('.l-shell__time-conductor.c-compact-tc').count()).toBe(1);
await page.click('.l-shell__time-conductor.c-compact-tc');
await setTimeBounds(page, startDate, endDate); // Open the time conductor popup
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
await page.keyboard.press('Enter'); // FIXME: https://github.com/nasa/openmct/pull/7818
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
if (startDate) {
await page.getByLabel('Start date').fill(startDate);
}
if (startTime) {
await page.getByLabel('Start time').fill(startTime);
}
if (endDate) {
await page.getByLabel('End date').fill(endDate);
}
if (endTime) {
await page.getByLabel('End time').fill(endTime);
}
if (submitChanges) {
await page.getByLabel('Submit time bounds').click();
} else {
await page.getByLabel('Discard changes and close time popup').click();
}
} }
/** /**
* Set the independent time conductor bounds in fixed time mode * Set the bounds of the visible conductor in fixed time mode.
* Requires that page already has an independent time conductor in view.
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} startDate * @param {string} start - The start date in 'YYYY-MM-DD HH:mm:ss.SSSZ' format
* @param {string} endDate * @param {string} end - The end date in 'YYYY-MM-DD HH:mm:ss.SSSZ' format
*/ */
async function setIndependentTimeConductorBounds(page, startDate, endDate) { async function setFixedIndependentTimeConductorBounds(page, { start, end }) {
// Activate Independent Time Conductor in Fixed Time Mode // Activate Independent Time Conductor
await page.getByRole('switch').click(); await page.getByLabel('Enable Independent Time Conductor').click();
// Bring up the time conductor popup // Bring up the time conductor popup
await page.click('.c-conductor-holder--compact .c-compact-tc'); await page.getByLabel('Independent Time Conductor Settings').click();
await expect(page.locator('.itc-popout')).toBeInViewport(); await expect(page.getByLabel('Time Conductor Options')).toBeInViewport();
await _setTimeBounds(page, start, end);
await setTimeBounds(page, startDate, endDate);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
} }
@ -522,10 +560,10 @@ async function setIndependentTimeConductorBounds(page, startDate, endDate) {
* Set the bounds of the visible conductor in fixed time mode * Set the bounds of the visible conductor in fixed time mode
* @private * @private
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} startDate * @param {string} start - The start date in 'YYYY-MM-DD HH:mm:ss.SSSZ' format
* @param {string} endDate * @param {string} end - The end date in 'YYYY-MM-DD HH:mm:ss.SSSZ' format
*/ */
async function setTimeBounds(page, startDate, endDate) { async function _setTimeBounds(page, startDate, endDate) {
if (startDate) { if (startDate) {
// Fill start time // Fill start time
await page await page
@ -555,11 +593,13 @@ async function setTimeBounds(page, startDate, endDate) {
* all plots on the page and waits up to the default timeout for the class to be * all plots on the page and waits up to the default timeout for the class to be
* attached to each plot. * attached to each plot.
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} [timeout] Provide a custom timeout in milliseconds to override the default timeout
*/ */
async function waitForPlotsToRender(page) { async function waitForPlotsToRender(page, { timeout } = {}) {
//eslint-disable-next-line playwright/no-raw-locators
const plotLocator = page.locator('.gl-plot'); const plotLocator = page.locator('.gl-plot');
for (const plot of await plotLocator.all()) { for (const plot of await plotLocator.all()) {
await expect(plot).toHaveClass(/js-series-data-loaded/); await expect(plot).toHaveClass(/js-series-data-loaded/, { timeout });
} }
} }
@ -580,9 +620,6 @@ async function waitForPlotsToRender(page) {
* @return {Promise<PlotPixel[]>} * @return {Promise<PlotPixel[]>}
*/ */
async function getCanvasPixels(page, canvasSelector) { async function getCanvasPixels(page, canvasSelector) {
const getTelemValuePromise = new Promise((resolve) =>
page.exposeFunction('getCanvasValue', resolve)
);
const canvasHandle = await page.evaluateHandle( const canvasHandle = await page.evaluateHandle(
(canvas) => document.querySelector(canvas), (canvas) => document.querySelector(canvas),
canvasSelector canvasSelector
@ -593,7 +630,7 @@ async function getCanvasPixels(page, canvasSelector) {
); );
await waitForPlotsToRender(page); await waitForPlotsToRender(page);
await page.evaluate( return page.evaluate(
([canvas, ctx]) => { ([canvas, ctx]) => {
// The document canvas is where the plot points and lines are drawn. // The document canvas is where the plot points and lines are drawn.
// The only way to access the canvas is using document (using page.evaluate) // The only way to access the canvas is using document (using page.evaluate)
@ -621,27 +658,43 @@ async function getCanvasPixels(page, canvasSelector) {
i = i + 4; i = i + 4;
} }
window.getCanvasValue(plotPixels); return plotPixels;
}, },
[canvasHandle, canvasContextHandle] [canvasHandle, canvasContextHandle]
); );
return getTelemValuePromise;
} }
/** /**
* Search for telemetry and link it to an object. objectName should come from the domainObject.name function.
* @param {import('@playwright/test').Page} page
* @param {string} parameterName
* @param {string} objectName
*/
async function linkParameterToObject(page, parameterName, objectName) {
await page.getByRole('searchbox', { name: 'Search Input' }).click();
await page.getByRole('searchbox', { name: 'Search Input' }).fill(parameterName);
await page.getByLabel('Object Results').getByText(parameterName).click();
await page.getByLabel('More actions').click();
await page.getByLabel('Create Link').click();
await page.getByLabel('Modal Overlay').getByLabel('Search Input').click();
await page.getByLabel('Modal Overlay').getByLabel('Search Input').fill(objectName);
await page.getByLabel('Modal Overlay').getByLabel(`Navigate to ${objectName}`).click();
await page.getByLabel('Save').click();
}
/**
* Rename the currently viewed `domainObject` from the browse bar.
*
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} myItemsFolderName
* @param {string} url
* @param {string} newName * @param {string} newName
*/ */
async function renameObjectFromContextMenu(page, url, newName) { async function renameCurrentObjectFromBrowseBar(page, newName) {
await openObjectTreeContextMenu(page, url); const nameInput = page.getByLabel('Browse bar object name');
await page.click('li:text("Edit Properties")'); await nameInput.click();
const nameInput = page.getByLabel('Title', { exact: true });
await nameInput.fill(''); await nameInput.fill('');
await nameInput.fill(newName); await nameInput.fill(newName);
await page.click('[aria-label="Save"]'); // Click the browse bar container to save changes
await page.getByLabel('Browse bar', { exact: true }).click();
} }
export { export {
@ -649,17 +702,16 @@ export {
createExampleTelemetryObject, createExampleTelemetryObject,
createNotification, createNotification,
createPlanFromJSON, createPlanFromJSON,
createStableStateTelemetry,
expandEntireTree, expandEntireTree,
expandTreePaneItemByName,
getCanvasPixels, getCanvasPixels,
getFocusedObjectUuid, linkParameterToObject,
getHashUrlToDomainObject,
navigateToObjectWithFixedTimeBounds, navigateToObjectWithFixedTimeBounds,
openObjectTreeContextMenu, navigateToObjectWithRealTime,
renameObjectFromContextMenu, renameCurrentObjectFromBrowseBar,
setEndOffset, setEndOffset,
setFixedIndependentTimeConductorBounds,
setFixedTimeMode, setFixedTimeMode,
setIndependentTimeConductorBounds,
setRealTimeMode, setRealTimeMode,
setStartOffset, setStartOffset,
setTimeConductorBounds, setTimeConductorBounds,

View File

@ -34,29 +34,90 @@
*/ */
import AxeBuilder from '@axe-core/playwright'; import AxeBuilder from '@axe-core/playwright';
import fs from 'fs'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
import { expect, test } from './pluginFixtures.js'; import { expect, test } from './pluginFixtures.js';
// Constants for repeated values // Constants for repeated values
const TEST_RESULTS_DIR = './test-results'; const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TEST_RESULTS_DIR = path.join(__dirname, './test-results');
const extendedTest = test.extend({
/**
* Overrides the default screenshot function to apply default options that should apply to all
* screenshots taken in the AVP tests.
*
* @param {import('@playwright/test').PlaywrightTestArgs} args - The Playwright test arguments.
* @param {Function} use - The function to use the page object.
* Defaults:
* - Disables animations
* - Masks the clock indicator
* - Masks the time conductor last update time in realtime mode
* - Masks the time conductor start bounds in fixed mode
* - Masks the time conductor end bounds in fixed mode
*/
page: async ({ page }, use) => {
const playwrightScreenshot = page.screenshot;
/**
* Override the screenshot function to always mask a given set of locators which will always
* show variance across screenshots. Defaults may be overridden by passing in options to the
* screenshot function.
* @param {import('@playwright/test').PageScreenshotOptions} options - The options for the screenshot.
* @returns {Promise<Buffer>} Returns the screenshot as a buffer.
*/
page.screenshot = async function (options = {}) {
const mask = [
this.getByLabel('Clock Indicator'), // Mask the clock indicator
this.getByLabel('Last update'), // Mask the time conductor last update time in realtime mode
this.getByLabel('Start bounds'), // Mask the time conductor start bounds in fixed mode
this.getByLabel('End bounds') // Mask the time conductor end bounds in fixed mode
];
const result = await playwrightScreenshot.call(this, {
animations: 'disabled',
mask,
...options // Pass through or override any options
});
return result;
};
await use(page);
}
});
/**
* Writes the accessibility report to the specified path.
*
* @param {string} reportPath - The path to write the report to.
* @param {Object} accessibilityScanResults - The results of the accessibility scan.
* @returns {Promise<Object>} The accessibility scan results.
* @throws Will throw an error if writing the report fails.
*/
async function writeAccessibilityReport(reportPath, accessibilityScanResults) {
try {
await fs.mkdir(path.dirname(reportPath), { recursive: true });
const data = JSON.stringify(accessibilityScanResults, null, 2);
await fs.writeFile(reportPath, data);
console.log(`Accessibility report with violations saved successfully as ${reportPath}`);
return accessibilityScanResults;
} catch (err) {
console.error(`Error writing the accessibility report to file ${reportPath}:`, err);
throw err;
}
}
/** /**
* Scans for accessibility violations on a page and writes a report to disk if violations are found. * Scans for accessibility violations on a page and writes a report to disk if violations are found.
* Automatically asserts that no violations should be present. * Automatically asserts that no violations should be present.
* *
* @typedef {object} GenerateReportOptions
* @property {string} [reportName] - The name for the report file.
*
* @param {import('playwright').Page} page - The page object from Playwright. * @param {import('playwright').Page} page - The page object from Playwright.
* @param {string} testCaseName - The name of the test case. * @param {string} testCaseName - The name of the test case.
* @param {GenerateReportOptions} [options={}] - The options for the report generation. * @param {{ reportName?: string }} [options={}] - The options for the report generation.
* * @returns {Promise<Object|null>} Returns the accessibility scan results if violations are found, otherwise returns null.
* @returns {Promise<object|null>} Returns the accessibility scan results if violations are found,
* otherwise returns null.
*/ */
/* eslint-disable no-undef */
export async function scanForA11yViolations(page, testCaseName, options = {}) { export async function scanForA11yViolations(page, testCaseName, options = {}) {
const builder = new AxeBuilder({ page }); const builder = new AxeBuilder({ page });
builder.withTags(['wcag2aa']); builder.withTags(['wcag2aa']);
@ -64,25 +125,29 @@ export async function scanForA11yViolations(page, testCaseName, options = {}) {
const accessibilityScanResults = await builder.analyze(); const accessibilityScanResults = await builder.analyze();
// Assert that no violations should be present // Assert that no violations should be present
expect( expect
.soft(
accessibilityScanResults.violations, accessibilityScanResults.violations,
`Accessibility violations found in test case: ${testCaseName}` `Accessibility violations found in test case: ${testCaseName}`
).toEqual([]); )
.toEqual([]);
// Check if there are any violations // Check if there are any violations
if (accessibilityScanResults.violations.length > 0) { if (accessibilityScanResults.violations.length > 0) {
let reportName = options.reportName || testCaseName; const reportName = options.reportName || testCaseName;
let sanitizedReportName = reportName.replace(/\//g, '_'); const sanitizedReportName = reportName.replace(/\//g, '_');
const reportPath = path.join(TEST_RESULTS_DIR, `${sanitizedReportName}.json`); const reportPath = path.join(
TEST_RESULTS_DIR,
'a11y-json-reports',
`${sanitizedReportName}.json`
);
try { try {
if (!fs.existsSync(TEST_RESULTS_DIR)) { await page.screenshot({
fs.mkdirSync(TEST_RESULTS_DIR); path: path.join(TEST_RESULTS_DIR, 'a11y-screenshots', `${sanitizedReportName}.png`)
} });
fs.writeFileSync(reportPath, JSON.stringify(accessibilityScanResults, null, 2)); return await writeAccessibilityReport(reportPath, accessibilityScanResults);
console.log(`Accessibility report with violations saved successfully as ${reportPath}`);
return accessibilityScanResults;
} catch (err) { } catch (err) {
console.error(`Error writing the accessibility report to file ${reportPath}:`, err); console.error(`Error writing the accessibility report to file ${reportPath}:`, err);
throw err; throw err;
@ -93,4 +158,4 @@ export async function scanForA11yViolations(page, testCaseName, options = {}) {
} }
} }
export { expect, test }; export { expect, extendedTest as test };

View File

@ -1,4 +1,3 @@
/* eslint-disable no-undef */
/***************************************************************************** /*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government * Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space * as represented by the Administrator of the National Aeronautics and Space
@ -31,7 +30,6 @@
import { expect, request, test } from '@playwright/test'; import { expect, request, test } from '@playwright/test';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import sinon from 'sinon';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@ -40,7 +38,7 @@ import { v4 as uuid } from 'uuid';
* @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion} * @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion}
* @private * @private
* @param {import('@playwright/test').ConsoleMessage} msg * @param {import('@playwright/test').ConsoleMessage} msg
* @returns {String} formatted string with message type, text, url, and line and column numbers * @returns {string} formatted string with message type, text, url, and line and column numbers
*/ */
function _consoleMessageToString(msg) { function _consoleMessageToString(msg) {
const { url, lineNumber, columnNumber } = msg.location(); const { url, lineNumber, columnNumber } = msg.location();
@ -61,71 +59,31 @@ function waitForAnimations(locator) {
); );
} }
/** const istanbulCLIOutput = fileURLToPath(new URL('.nyc_output', import.meta.url));
* This is part of our codecoverage shim.
* @see {@link https://github.com/mxschmitt/playwright-test-coverage Github Example Project}
* @constant {string}
*/
const istanbulCLIOutput = path.join(process.cwd(), '.nyc_output');
const extendedTest = test.extend({ const extendedTest = test.extend({
/** /**
* This allows the test to manipulate the browser clock. This is useful for Visual and Snapshot tests which need * Path to output raw coverage files. Can be overridden in Playwright config file.
* the Time Indicator Clock to be in a specific state. * @see {@link https://github.com/mxschmitt/playwright-test-coverage Github Example Project}
* * @constant {string}
* Warning: Has many limitations and secondary side effects in Open MCT.
* 1. The tree component does not render.
* 2. page.WaitForNavigation does not trigger.
*
* Usage:
* ```js
* test.use({
* clockOptions: {
* now: MISSION_TIME,
* shouldAdvanceTime: true
* ```
* If clockOptions are provided, will override the default clock with fake timers provided by SinonJS.
*
* Default: `undefined`
*
* @see {@link https://github.com/microsoft/playwright/issues/6347 Github RFE}
* @see {@link https://github.com/sinonjs/fake-timers/#var-clock--faketimersinstallconfig SinonJS FakeTimers Config}
* @type {import('@types/sinonjs__fake-timers').FakeTimerInstallOpts}
*/ */
clockOptions: [undefined, { option: true }],
overrideClock: [
async ({ context, clockOptions }, use) => {
if (clockOptions !== undefined) {
await context.addInitScript({
path: fileURLToPath(new URL('../node_modules/sinon/pkg/sinon.js', import.meta.url))
});
await context.addInitScript((options) => {
window.__clock = sinon.useFakeTimers(options);
}, clockOptions);
}
await use(context); coveragePath: [istanbulCLIOutput, { option: true }],
},
{
auto: true,
scope: 'test'
}
],
/** /**
* Extends the base context class to add codecoverage shim. * Extends the base context class to add codecoverage shim.
* @see {@link https://github.com/mxschmitt/playwright-test-coverage Github Project} * @see {@link https://github.com/mxschmitt/playwright-test-coverage Github Project}
*/ */
context: async ({ context }, use) => { context: async ({ context, coveragePath }, use) => {
await context.addInitScript(() => await context.addInitScript(() =>
window.addEventListener('beforeunload', () => window.addEventListener('beforeunload', () =>
window.collectIstanbulCoverage(JSON.stringify(window.__coverage__)) window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))
) )
); );
await fs.promises.mkdir(istanbulCLIOutput, { recursive: true }); await fs.promises.mkdir(coveragePath, { recursive: true });
await context.exposeFunction('collectIstanbulCoverage', (coverageJSON) => { await context.exposeFunction('collectIstanbulCoverage', (coverageJSON) => {
if (coverageJSON) { if (coverageJSON) {
fs.writeFileSync( fs.writeFileSync(
path.join(istanbulCLIOutput, `playwright_coverage_${uuid()}.json`), path.join(coveragePath, `playwright_coverage_${uuid()}.json`),
coverageJSON coverageJSON
); );
} }
@ -133,9 +91,9 @@ const extendedTest = test.extend({
await use(context); await use(context);
for (const page of context.pages()) { for (const page of context.pages()) {
await page.evaluate(() => await page.evaluate(() => {
window.collectIstanbulCoverage(JSON.stringify(window.__coverage__)) window.collectIstanbulCoverage(JSON.stringify(window.__coverage__));
); });
} }
}, },
/** /**
@ -149,24 +107,7 @@ const extendedTest = test.extend({
* Extends the base page class to enable console log error detection. * Extends the base page class to enable console log error detection.
* @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion} * @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion}
*/ */
page: async ({ page, failOnConsoleError, clockOptions }, use) => { page: async ({ page, failOnConsoleError }, use) => {
// If overriding the clock, we must also override the Date.now()
// function in the generatorWorker context. This is necessary
// to ensure that example telemetry data is generated for the new clock time.
if (clockOptions?.now !== undefined) {
page.on(
'worker',
(worker) => {
if (worker.url().includes('generatorWorker')) {
worker.evaluate((time) => {
self.Date.now = () => time;
});
}
},
clockOptions.now
);
}
// Capture any console errors during test execution // Capture any console errors during test execution
const messages = []; const messages = [];
page.on('console', (msg) => messages.push(msg)); page.on('console', (msg) => messages.push(msg));
@ -176,28 +117,12 @@ const extendedTest = test.extend({
// Assert against console errors during teardown // Assert against console errors during teardown
if (failOnConsoleError) { if (failOnConsoleError) {
messages.forEach((msg) => messages.forEach((msg) =>
// eslint-disable-next-line playwright/no-standalone-expect
expect expect
.soft(msg.type(), `Console error detected: ${_consoleMessageToString(msg)}`) .soft(msg.type(), `Console error detected: ${_consoleMessageToString(msg)}`)
.not.toEqual('error') .not.toEqual('error')
); );
} }
},
/**
* Extends the base browser class to enable CDP connection definition in playwright.config.js. Once
* that RFE is implemented, this function can be removed.
* @see {@link https://github.com/microsoft/playwright/issues/8379 Github RFE}
*/
browser: async ({ playwright, browser }, use, workerInfo) => {
// Use browserless if configured
if (workerInfo.project.name.match(/browserless/)) {
const vBrowser = await playwright.chromium.connectOverCDP({
endpointURL: 'ws://localhost:3003'
});
await use(vBrowser);
} else {
// Use Local Browser for testing.
await use(browser);
}
} }
}); });

View File

@ -1,4 +1,3 @@
/* eslint-disable prettier/prettier */
/** /**
* Constants which may be used across all e2e tests. * Constants which may be used across all e2e tests.
*/ */
@ -8,12 +7,30 @@
* - Used for overriding the browser clock in tests. * - Used for overriding the browser clock in tests.
*/ */
export const MISSION_TIME = 1732413600000; // Saturday, November 23, 2024 6:00:00 PM GMT-08:00 (Thanksgiving Dinner Time) export const MISSION_TIME = 1732413600000; // Saturday, November 23, 2024 6:00:00 PM GMT-08:00 (Thanksgiving Dinner Time)
// Subtracting 30 minutes from MISSION_TIME
export const MISSION_TIME_FIXED_START = 1732413600000 - 1800000; // 1732411800000
// Adding 1 minute to MISSION_TIME
export const MISSION_TIME_FIXED_END = 1732413600000 + 60000; // 1732413660000
/** /**
* URL Constants * URL Constants
* - This is the URL that the browser will be directed to when running visual tests. This URL * These constants are used for initial navigation in visual tests, in either fixed or realtime mode.
* - hides the tree and inspector to prevent visual noise * They navigate to the 'My Items' folder at MISSION_TIME.
* - sets the time bounds to a fixed range * They set the following url parameters:
* - tc.mode - The time conductor mode ('fixed' or 'local')
* - tc.startBound - The time conductor start bound (when in fixed mode)
* - tc.endBound - The time conductor end bound (when in fixed mode)
* - tc.startDelta - The time conductor start delta (when in realtime mode)
* - tc.endDelta - The time conductor end delta (when in realtime mode)
* - tc.timeSystem - The time conductor time system ('utc')
* - view - The view to display ('grid')
* - hideInspector - Whether to hide the inspector (true)
* - hideTree - Whether to hide the tree (true)
* @typedef {string} VisualUrl
*/ */
export const VISUAL_URL =
'./#/browse/mine?tc.mode=fixed&tc.startBound=1693592063607&tc.endBound=1693593893607&tc.timeSystem=utc&view=grid&hideInspector=true&hideTree=true'; /** @type {VisualUrl} */
export const VISUAL_FIXED_URL = `./#/browse/mine?tc.mode=fixed&tc.startBound=${MISSION_TIME_FIXED_START}&tc.endBound=${MISSION_TIME_FIXED_END}&tc.timeSystem=utc&view=grid&hideInspector=true&hideTree=true`;
/** @type {VisualUrl} */
export const VISUAL_REALTIME_URL =
'./#/browse/mine?tc.mode=local&tc.timeSystem=utc&view=grid&tc.startDelta=1800000&tc.endDelta=30000&hideTree=true&hideInspector=true';

View File

@ -21,10 +21,13 @@
*****************************************************************************/ *****************************************************************************/
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { expect } from '../pluginFixtures.js';
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/ */
async function navigateToFaultManagementWithExample(page) { export async function navigateToFaultManagementWithExample(page) {
await page.addInitScript({ await page.addInitScript({
path: fileURLToPath(new URL('./addInitExampleFaultProvider.js', import.meta.url)) path: fileURLToPath(new URL('./addInitExampleFaultProvider.js', import.meta.url))
}); });
@ -34,8 +37,9 @@ async function navigateToFaultManagementWithExample(page) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/ */
async function navigateToFaultManagementWithStaticExample(page) { export async function navigateToFaultManagementWithStaticExample(page) {
await page.addInitScript({ await page.addInitScript({
path: fileURLToPath(new URL('./addInitExampleFaultProviderStatic.js', import.meta.url)) path: fileURLToPath(new URL('./addInitExampleFaultProviderStatic.js', import.meta.url))
}); });
@ -45,8 +49,9 @@ async function navigateToFaultManagementWithStaticExample(page) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/ */
async function navigateToFaultManagementWithoutExample(page) { export async function navigateToFaultManagementWithoutExample(page) {
await page.addInitScript({ await page.addInitScript({
path: fileURLToPath(new URL('./addInitFaultManagementPlugin.js', import.meta.url)) path: fileURLToPath(new URL('./addInitFaultManagementPlugin.js', import.meta.url))
}); });
@ -56,9 +61,11 @@ async function navigateToFaultManagementWithoutExample(page) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @returns {Promise<void>}
*/ */
async function navigateToFaultItemInTree(page) { async function navigateToFaultItemInTree(page) {
await page.goto('./', { waitUntil: 'networkidle' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/#/browse/mine?**');
const faultManagementTreeItem = page const faultManagementTreeItem = page
.getByRole('tree', { .getByRole('tree', {
@ -74,152 +81,124 @@ async function navigateToFaultItemInTree(page) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/ */
async function acknowledgeFault(page, rowNumber) { export async function acknowledgeFault(page, rowNumber) {
await openFaultRowMenu(page, rowNumber); await openFaultRowMenu(page, rowNumber);
await page.locator('.c-menu >> text="Acknowledge"').click(); await page.getByLabel('Acknowledge', { exact: true }).click();
// Click [aria-label="Save"] await page.getByLabel('Save').click();
await page.locator('[aria-label="Save"]').click();
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {...number} nums
* @returns {Promise<void>}
*/ */
async function shelveMultipleFaults(page, ...nums) { export async function shelveMultipleFaults(page, ...nums) {
const selectRows = nums.map((num) => { const selectRows = nums.map((num) => {
return selectFaultItem(page, num); return selectFaultItem(page, num);
}); });
await Promise.all(selectRows); await Promise.all(selectRows);
await page.locator('button:has-text("Shelve")').click(); await page.getByLabel('Shelve selected faults').click();
await page.locator('[aria-label="Save"]').click(); await page.getByLabel('Save').click();
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {...number} nums
* @returns {Promise<void>}
*/ */
async function acknowledgeMultipleFaults(page, ...nums) { export async function acknowledgeMultipleFaults(page, ...nums) {
const selectRows = nums.map((num) => { const selectRows = nums.map((num) => {
return selectFaultItem(page, num); return selectFaultItem(page, num);
}); });
await Promise.all(selectRows); await Promise.all(selectRows);
await page.locator('button:has-text("Acknowledge")').click(); await page.getByLabel('Acknowledge selected faults').click();
await page.locator('[aria-label="Save"]').click(); await page.getByLabel('Save').click();
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/ */
async function shelveFault(page, rowNumber) { export async function shelveFault(page, rowNumber) {
await openFaultRowMenu(page, rowNumber); await openFaultRowMenu(page, rowNumber);
await page.locator('.c-menu >> text="Shelve"').click(); await page.getByLabel('Shelve', { exact: true }).click();
// Click [aria-label="Save"] await page.getByLabel('Save').click();
await page.locator('[aria-label="Save"]').click();
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {'severity' | 'newest-first' | 'oldest-first'} sort
* @returns {Promise<void>}
*/ */
async function changeViewTo(page, view) { export async function sortFaultsBy(page, sort) {
await page.locator('.c-fault-mgmt__search-row select').first().selectOption(view); await page.getByTitle('Sort By').getByRole('combobox').selectOption(sort);
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {'acknowledged' | 'shelved' | 'standard view'} view
* @returns {Promise<void>}
*/ */
async function sortFaultsBy(page, sort) { export async function changeViewTo(page, view) {
await page.locator('.c-fault-mgmt__list-header-sortButton select').selectOption(sort); await page.getByTitle('View Filter').getByRole('combobox').selectOption(view);
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/ */
async function enterSearchTerm(page, term) { export async function selectFaultItem(page, rowNumber) {
await page.locator('.c-fault-mgmt-search [aria-label="Search Input"]').fill(term); await page
.getByLabel('Select fault')
.nth(rowNumber - 1)
.check({
// Need force here because checkbox state is changed by an event emitted by the checkbox
// eslint-disable-next-line playwright/no-force-option
force: true
});
await expect(page.getByLabel('Select fault').nth(rowNumber - 1)).toBeChecked();
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {import('@playwright/test').Locator}
*/ */
async function clearSearch(page) { export function getFault(page, rowNumber) {
await enterSearchTerm(page, ''); const fault = page.getByLabel('Fault triggered at').nth(rowNumber - 1);
}
/**
* @param {import('@playwright/test').Page} page
*/
async function selectFaultItem(page, rowNumber) {
await page.locator(`.c-fault-mgmt-item > input >> nth=${rowNumber - 1}`).check();
}
/**
* @param {import('@playwright/test').Page} page
*/
async function getHighestSeverity(page) {
const criticalCount = await page.locator('[title=CRITICAL]').count();
const warningCount = await page.locator('[title=WARNING]').count();
if (criticalCount > 0) {
return 'CRITICAL';
} else if (warningCount > 0) {
return 'WARNING';
}
return 'WATCH';
}
/**
* @param {import('@playwright/test').Page} page
*/
async function getLowestSeverity(page) {
const warningCount = await page.locator('[title=WARNING]').count();
const watchCount = await page.locator('[title=WATCH]').count();
if (watchCount > 0) {
return 'WATCH';
} else if (warningCount > 0) {
return 'WARNING';
}
return 'CRITICAL';
}
/**
* @param {import('@playwright/test').Page} page
*/
async function getFaultResultCount(page) {
const count = await page.locator('.c-faults-list-view-item-body > .c-fault-mgmt__list').count();
return count;
}
/**
* @param {import('@playwright/test').Page} page
*/
function getFault(page, rowNumber) {
const fault = page.locator(
`.c-faults-list-view-item-body > .c-fault-mgmt__list >> nth=${rowNumber - 1}`
);
return fault; return fault;
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} name
* @returns {import('@playwright/test').Locator}
*/ */
function getFaultByName(page, name) { export function getFaultByName(page, name) {
const fault = page.locator(`.c-fault-mgmt__list-faultname:has-text("${name}")`); const fault = page.getByLabel('Fault triggered at').filter({
hasText: name
});
return fault; return fault;
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<string>}
*/ */
async function getFaultName(page, rowNumber) { export async function getFaultName(page, rowNumber) {
const faultName = await page const faultName = await page
.locator(`.c-fault-mgmt__list-faultname >> nth=${rowNumber - 1}`) .getByLabel('Fault name', { exact: true })
.nth(rowNumber - 1)
.textContent(); .textContent();
return faultName; return faultName;
@ -227,21 +206,13 @@ async function getFaultName(page, rowNumber) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<string>}
*/ */
async function getFaultSeverity(page, rowNumber) { export async function getFaultNamespace(page, rowNumber) {
const faultSeverity = await page
.locator(`.c-faults-list-view-item-body .c-fault-mgmt__list-severity >> nth=${rowNumber - 1}`)
.getAttribute('title');
return faultSeverity;
}
/**
* @param {import('@playwright/test').Page} page
*/
async function getFaultNamespace(page, rowNumber) {
const faultNamespace = await page const faultNamespace = await page
.locator(`.c-fault-mgmt__list-path >> nth=${rowNumber - 1}`) .getByLabel('Fault namespace')
.nth(rowNumber - 1)
.textContent(); .textContent();
return faultNamespace; return faultNamespace;
@ -249,10 +220,13 @@ async function getFaultNamespace(page, rowNumber) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<string>}
*/ */
async function getFaultTriggerTime(page, rowNumber) { export async function getFaultTriggerTime(page, rowNumber) {
const faultTriggerTime = await page const faultTriggerTime = await page
.locator(`.c-fault-mgmt__list-trigTime >> nth=${rowNumber - 1} >> .c-fault-mgmt-item__value`) .getByLabel('Last Trigger Time')
.nth(rowNumber - 1)
.textContent(); .textContent();
return faultTriggerTime.toString().trim(); return faultTriggerTime.toString().trim();
@ -260,36 +234,14 @@ async function getFaultTriggerTime(page, rowNumber) {
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {number} rowNumber
* @returns {Promise<void>}
*/ */
async function openFaultRowMenu(page, rowNumber) { export async function openFaultRowMenu(page, rowNumber) {
// select // select
await page await page
.locator(`.c-fault-mgmt-item > .c-fault-mgmt__list-action-button >> nth=${rowNumber - 1}`) .getByLabel('Fault triggered at')
.nth(rowNumber - 1)
.getByLabel('Disposition Actions')
.click(); .click();
} }
export {
acknowledgeFault,
acknowledgeMultipleFaults,
changeViewTo,
clearSearch,
enterSearchTerm,
getFault,
getFaultByName,
getFaultName,
getFaultNamespace,
getFaultResultCount,
getFaultSeverity,
getFaultTriggerTime,
getHighestSeverity,
getLowestSeverity,
navigateToFaultItemInTree,
navigateToFaultManagementWithExample,
navigateToFaultManagementWithoutExample,
navigateToFaultManagementWithStaticExample,
openFaultRowMenu,
selectFaultItem,
shelveFault,
shelveMultipleFaults,
sortFaultsBy
};

View File

@ -0,0 +1,47 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
const isMac = process.platform === 'darwin';
const modifier = isMac ? 'Meta' : 'Control';
/**
* @param {import('@playwright/test').Page} page
*/
async function selectAll(page) {
await page.keyboard.press(`${modifier}+KeyA`);
}
/**
* @param {import('@playwright/test').Page} page
*/
async function copy(page) {
await page.keyboard.press(`${modifier}+KeyC`);
}
/**
* @param {import('@playwright/test').Page} page
*/
async function paste(page) {
await page.keyboard.press(`${modifier}+KeyV`);
}
export { copy, paste, selectAll };

View File

@ -0,0 +1,23 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
export * from './clipboard.js';

View File

@ -0,0 +1,33 @@
import { createDomainObjectWithDefaults } from '../appActions.js';
import { expect } from '../pluginFixtures.js';
const IMAGE_LOAD_DELAY = 5 * 1000;
const FIVE_MINUTES = 1000 * 60 * 5;
const THIRTY_SECONDS = 1000 * 30;
const MOUSE_WHEEL_DELTA_Y = 120;
/**
* @param {import('@playwright/test').Page} page
*/
async function createImageryViewWithShortDelay(page, { name, parent }) {
await createDomainObjectWithDefaults(page, {
name,
type: 'Example Imagery',
parent
});
await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery');
await page.getByLabel('More actions').click();
await page.getByLabel('Edit Properties').click();
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
await page.getByLabel('Save').click();
}
export {
createImageryViewWithShortDelay,
FIVE_MINUTES,
IMAGE_LOAD_DELAY,
MOUSE_WHEEL_DELTA_Y,
THIRTY_SECONDS
};

View File

@ -28,16 +28,28 @@ import { fileURLToPath } from 'url';
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {string} text
*/ */
async function enterTextEntry(page, text) { async function enterTextEntry(page, text) {
// Click the 'Add Notebook Entry' area await addNotebookEntry(page);
await page.locator(NOTEBOOK_DROP_AREA).click(); await enterTextInLastEntry(page, text);
// enter text
await page.getByLabel('Notebook Entry Input').last().fill(text);
await commitEntry(page); await commitEntry(page);
} }
/**
* @param {import('@playwright/test').Page} page
*/
async function addNotebookEntry(page) {
await page.locator(NOTEBOOK_DROP_AREA).click();
}
/**
* @param {import('@playwright/test').Page} page
*/
async function enterTextInLastEntry(page, text) {
await page.getByLabel('Notebook Entry Input').last().fill(text);
}
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
@ -51,7 +63,7 @@ async function dragAndDropEmbed(page, notebookObject) {
// Expand the tree to reveal the notebook // Expand the tree to reveal the notebook
await page.getByLabel('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
// Drag and drop the SWG into the notebook // Drag and drop the SWG into the notebook
await page.dragAndDrop(`text=${swg.name}`, NOTEBOOK_DROP_AREA); await page.getByLabel(`Navigate to ${swg.name}`).dragTo(page.locator(NOTEBOOK_DROP_AREA));
await commitEntry(page); await commitEntry(page);
} }
@ -68,11 +80,11 @@ async function commitEntry(page) {
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function startAndAddRestrictedNotebookObject(page) { async function startAndAddRestrictedNotebookObject(page) {
// eslint-disable-next-line no-undef
await page.addInitScript({ await page.addInitScript({
path: fileURLToPath(new URL('./addInitRestrictedNotebook.js', import.meta.url)) path: fileURLToPath(new URL('./addInitRestrictedNotebook.js', import.meta.url))
}); });
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/browse/mine?**');
return createDomainObjectWithDefaults(page, { return createDomainObjectWithDefaults(page, {
type: CUSTOM_NAME, type: CUSTOM_NAME,
@ -84,9 +96,8 @@ async function startAndAddRestrictedNotebookObject(page) {
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function lockPage(page) { async function lockPage(page) {
const commitButton = page.locator('button:has-text("Commit Entries")'); // Click the Commit Entries button
await commitButton.click(); await page.getByLabel('Commit Entries').click();
// Wait until Lock Banner is visible // Wait until Lock Banner is visible
await page.locator('text=Lock Page').click(); await page.locator('text=Lock Page').click();
} }
@ -141,10 +152,13 @@ async function createNotebookEntryAndTags(page, iterations = 1) {
} }
export { export {
addNotebookEntry,
commitEntry,
createNotebookAndEntry, createNotebookAndEntry,
createNotebookEntryAndTags, createNotebookEntryAndTags,
dragAndDropEmbed, dragAndDropEmbed,
enterTextEntry, enterTextEntry,
enterTextInLastEntry,
lockPage, lockPage,
startAndAddRestrictedNotebookObject startAndAddRestrictedNotebookObject
}; };

View File

@ -20,6 +20,7 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../appActions.js';
import { expect } from '../pluginFixtures.js'; import { expect } from '../pluginFixtures.js';
/** /**
@ -28,10 +29,10 @@ import { expect } from '../pluginFixtures.js';
* for each activity in the plan data per group, using the earliest activity's * for each activity in the plan data per group, using the earliest activity's
* start time as the start bound and the current activity's end time as the end bound. * start time as the start bound and the current activity's end time as the end bound.
* @param {import('@playwright/test').Page} page the page * @param {import('@playwright/test').Page} page the page
* @param {object} plan The raw plan json to assert against * @param {Object} plan The raw plan json to assert against
* @param {string} objectUrl The URL of the object to assert against (plan or gantt chart) * @param {string} planObjectUrl The URL of the object to assert against (plan or gantt chart)
*/ */
export async function assertPlanActivities(page, plan, objectUrl) { export async function assertPlanActivities(page, plan, planObjectUrl) {
const groups = Object.keys(plan); const groups = Object.keys(plan);
for (const group of groups) { for (const group of groups) {
for (let i = 0; i < plan[group].length; i++) { for (let i = 0; i < plan[group].length; i++) {
@ -47,13 +48,12 @@ export async function assertPlanActivities(page, plan, objectUrl) {
// Switch to fixed time mode with all plan events within the bounds // Switch to fixed time mode with all plan events within the bounds
await page.goto( await page.goto(
`${objectUrl}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=plan.view` `${planObjectUrl}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=plan.view`
); );
// Assert that the number of activities in the plan view matches the number of // Assert that the number of activities in the plan view matches the number of
// activities in the plan data within the specified time bounds // activities in the plan data within the specified time bounds
const eventCount = await page.locator('.activity-bounds').count(); await expect(page.locator('.activity-bounds')).toHaveCount(
expect(eventCount).toEqual(
Object.values(plan) Object.values(plan)
.flat() .flat()
.filter((event) => .filter((event) =>
@ -85,7 +85,7 @@ function activitiesWithinTimeBounds(start1, end1, start2, end2) {
* Asserts that the swim lanes / groups in the plan view matches the order of * Asserts that the swim lanes / groups in the plan view matches the order of
* groups in the plan data. * groups in the plan data.
* @param {import('@playwright/test').Page} page the page * @param {import('@playwright/test').Page} page the page
* @param {object} plan The raw plan json to assert against * @param {Object} plan The raw plan json to assert against
* @param {string} objectUrl The URL of the object to assert against (plan or gantt chart) * @param {string} objectUrl The URL of the object to assert against (plan or gantt chart)
*/ */
export async function assertPlanOrderedSwimLanes(page, plan, objectUrl) { export async function assertPlanOrderedSwimLanes(page, plan, objectUrl) {
@ -100,8 +100,8 @@ export async function assertPlanOrderedSwimLanes(page, plan, objectUrl) {
for (let i = 0; i < groups.length; i++) { for (let i = 0; i < groups.length; i++) {
// Assert that the order of groups in the plan view matches the order of // Assert that the order of groups in the plan view matches the order of
// groups in the plan data // groups in the plan data
const groupName = await planGroups[i].innerText(); const groupName = planGroups[i];
expect(groupName).toEqual(groups[i].name); await expect(groupName).toHaveText(groups[i].name);
} }
} }
@ -109,7 +109,7 @@ export async function assertPlanOrderedSwimLanes(page, plan, objectUrl) {
* Navigate to the plan view, switch to fixed time mode, * Navigate to the plan view, switch to fixed time mode,
* and set the bounds to span all activities. * and set the bounds to span all activities.
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {object} planJson * @param {Object} planJson
* @param {string} planObjectUrl * @param {string} planObjectUrl
*/ */
export async function setBoundsToSpanAllActivities(page, planJson, planObjectUrl) { export async function setBoundsToSpanAllActivities(page, planJson, planObjectUrl) {
@ -124,24 +124,39 @@ export async function setBoundsToSpanAllActivities(page, planJson, planObjectUrl
} }
/** /**
* @param {object} planJson * @param {Object} planJson
* @returns {number} * @returns {number}
*/ */
export function getEarliestStartTime(planJson) { export function getEarliestStartTime(planJson) {
const activities = Object.values(planJson).flat(); const activities = Object.values(planJson).flat();
return Math.min(...activities.map((activity) => activity.start)); return Math.min(...activities.map((activity) => activity.start));
} }
/** /**
* *
* @param {object} planJson * @param {Object} planJson
* @returns {number} * @returns {number}
*/ */
export function getLatestEndTime(planJson) { export function getLatestEndTime(planJson) {
const activities = Object.values(planJson).flat(); const activities = Object.values(planJson).flat();
return Math.max(...activities.map((activity) => activity.end)); return Math.max(...activities.map((activity) => activity.end));
} }
/**
*
* @param {object} planJson
* @returns {object}
*/
export function getFirstActivity(planJson) {
const groups = Object.keys(planJson);
const firstGroupKey = groups[0];
const firstGroupItems = planJson[firstGroupKey];
return firstGroupItems[0];
}
/** /**
* Uses the Open MCT API to set the status of a plan to 'draft'. * Uses the Open MCT API to set the status of a plan to 'draft'.
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
@ -172,3 +187,55 @@ export async function addPlanGetInterceptor(page) {
}); });
}); });
} }
/**
* Create a Plan from JSON and add it to a Timelist and Navigate to the Plan view
* @param {import('@playwright/test').Page} page
*/
export async function createTimelistWithPlanAndSetActivityInProgress(page, planJson) {
await page.goto('./', { waitUntil: 'domcontentloaded' });
const timelist = await createDomainObjectWithDefaults(page, {
name: 'Time List',
type: 'Time List'
});
await createPlanFromJSON(page, {
name: 'Test Plan',
json: planJson,
parent: timelist.uuid
});
// Ensure that all activities are shown in the expanded view
const groups = Object.keys(planJson);
const firstGroupKey = groups[0];
const firstGroupItems = planJson[firstGroupKey];
const firstActivityForPlan = firstGroupItems[0];
const lastActivity = firstGroupItems[firstGroupItems.length - 1];
const startBound = firstActivityForPlan.start;
const endBound = lastActivity.end;
// Switch to fixed time mode with all plan events within the bounds
await page.goto(
`${timelist.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=timelist.view`
);
// Change the object to edit mode
await page.getByRole('button', { name: 'Edit Object' }).click();
// Find the display properties section in the inspector
await page.getByRole('tab', { name: 'View Properties' }).click();
// Switch to expanded view and save the setting
await page.getByLabel('Display Style').selectOption({ label: 'Expanded' });
// Click on the "Save" button
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
const anActivity = page.getByRole('row').nth(0);
// Set the activity to in progress
await anActivity.click();
await page.getByRole('tab', { name: 'Activity' }).click();
await page.getByLabel('Activity Status', { exact: true }).selectOption({ label: 'In progress' });
}

View File

@ -27,8 +27,8 @@ import { expect } from '../pluginFixtures.js';
* Given a canvas and a set of points, tags the points on the canvas. * Given a canvas and a set of points, tags the points on the canvas.
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {HTMLCanvasElement} canvas a telemetry item with a plot * @param {HTMLCanvasElement} canvas a telemetry item with a plot
* @param {Number} xEnd a telemetry item with a plot * @param {number} xEnd a telemetry item with a plot
* @param {Number} yEnd a telemetry item with a plot * @param {number} yEnd a telemetry item with a plot
* @returns {Promise} * @returns {Promise}
*/ */
export async function createTags({ page, canvas, xEnd = 700, yEnd = 520 }) { export async function createTags({ page, canvas, xEnd = 700, yEnd = 520 }) {

View File

@ -0,0 +1,29 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
// This should be used to install the Darkmatter theme for Open MCT.
// e.g.
// await page.addInitScript({ path: path.join(__dirname, 'useDarkmatterTheme.js') });
document.addEventListener('DOMContentLoaded', () => {
const openmct = window.openmct;
openmct.install(openmct.plugins.DarkmatterTheme());
});

8
e2e/index.js Normal file
View File

@ -0,0 +1,8 @@
// Import everything from the specific fixture files
import * as appActions from './appActions.js';
import * as avpFixtures from './avpFixtures.js';
import * as baseFixtures from './baseFixtures.js';
import * as pluginFixtures from './pluginFixtures.js';
// Export these as named exports
export { appActions, avpFixtures, baseFixtures, pluginFixtures };

1449
e2e/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
e2e/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "openmct-e2e",
"version": "4.1.0-next",
"description": "The Open MCT e2e framework",
"type": "module",
"module": "index.js",
"exports": {
".": {
"import": "./index.js"
}
},
"scripts": {
"test": "npx playwright test",
"test:visual": "percy exec"
},
"devDependencies": {
"@percy/cli": "1.27.4",
"@percy/playwright": "1.0.4",
"@playwright/test": "1.48.1",
"@axe-core/playwright": "4.8.5"
},
"author": {
"name": "National Aeronautics and Space Administration",
"url": "https://www.nasa.gov"
},
"license": "Apache-2.0"
}

View File

@ -3,6 +3,7 @@
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
import { devices } from '@playwright/test'; import { devices } from '@playwright/test';
import { fileURLToPath } from 'url';
const MAX_FAILURES = 5; const MAX_FAILURES = 5;
const NUM_WORKERS = 2; const NUM_WORKERS = 2;
@ -10,10 +11,12 @@ const NUM_WORKERS = 2;
const config = { const config = {
retries: 2, //Retries 2 times for a total of 3 runs. When running sharded and with max-failures=5, this should ensure that flake is managed without failing the full suite retries: 2, //Retries 2 times for a total of 3 runs. When running sharded and with max-failures=5, this should ensure that flake is managed without failing the full suite
testDir: 'tests', testDir: 'tests',
testIgnore: '**/*.perf.spec.js', //Ignore performance tests and define in playwright-perfromance.config.js grepInvert: /@mobile/, //Ignore mobile tests
testIgnore: '**/*.perf.spec.js', //Ignore performance tests and define in playwright-performance.config.js
timeout: 60 * 1000, timeout: 60 * 1000,
webServer: { webServer: {
command: 'npm run start:coverage', command: 'npm run start:coverage',
cwd: fileURLToPath(new URL('../', import.meta.url)), // Provide cwd for the root of the project
url: 'http://localhost:8080/#', url: 'http://localhost:8080/#',
timeout: 200 * 1000, timeout: 200 * 1000,
reuseExistingServer: true //This was originally disabled to prevent differences in local debugging vs. CI. However, it significantly speeds up local debugging. reuseExistingServer: true //This was originally disabled to prevent differences in local debugging vs. CI. However, it significantly speeds up local debugging.
@ -26,7 +29,9 @@ const config = {
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
trace: 'on-first-retry', trace: 'on-first-retry',
video: 'off' video: 'off',
// @ts-ignore - custom configuration option for nyc codecoverage output path
coveragePath: fileURLToPath(new URL('../.nyc_output', import.meta.url))
}, },
projects: [ projects: [
{ {

View File

@ -1,17 +1,16 @@
// playwright.config.js // playwright.config.js
// @ts-check // @ts-check
import { fileURLToPath } from 'url';
// eslint-disable-next-line no-unused-vars
import { devices } from '@playwright/test';
/** @type {import('@playwright/test').PlaywrightTestConfig} */ /** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = { const config = {
retries: 0, retries: 0,
testDir: 'tests', testDir: 'tests',
testMatch: '**/*.e2e.spec.js', // only run e2e tests
testIgnore: '**/*.perf.spec.js', testIgnore: '**/*.perf.spec.js',
timeout: 30 * 1000, timeout: 30 * 1000,
webServer: { webServer: {
command: 'npm run start:coverage', command: 'npm run start:coverage',
cwd: fileURLToPath(new URL('../', import.meta.url)), // Provide cwd for the root of the project
url: 'http://localhost:8080/#', url: 'http://localhost:8080/#',
timeout: 120 * 1000, timeout: 120 * 1000,
reuseExistingServer: true reuseExistingServer: true
@ -35,7 +34,6 @@ const config = {
}, },
{ {
name: 'MMOC', name: 'MMOC',
testMatch: '**/*.e2e.spec.js', // only run e2e tests
grepInvert: /@snapshot/, grepInvert: /@snapshot/,
use: { use: {
browserName: 'chromium', browserName: 'chromium',
@ -47,8 +45,6 @@ const config = {
}, },
{ {
name: 'safari', name: 'safari',
testMatch: '**/*.e2e.spec.js', // only run e2e tests
grep: /@ipad/, // only run ipad tests due to this bug https://github.com/microsoft/playwright/issues/8340
grepInvert: /@snapshot/, grepInvert: /@snapshot/,
use: { use: {
browserName: 'webkit' browserName: 'webkit'
@ -56,7 +52,6 @@ const config = {
}, },
{ {
name: 'firefox', name: 'firefox',
testMatch: '**/*.e2e.spec.js', // only run e2e tests
grepInvert: /@snapshot/, grepInvert: /@snapshot/,
use: { use: {
browserName: 'firefox' browserName: 'firefox'
@ -64,7 +59,6 @@ const config = {
}, },
{ {
name: 'canary', name: 'canary',
testMatch: '**/*.e2e.spec.js', // only run e2e tests
grepInvert: /@snapshot/, grepInvert: /@snapshot/,
use: { use: {
browserName: 'chromium', browserName: 'chromium',
@ -73,22 +67,11 @@ const config = {
}, },
{ {
name: 'chrome-beta', name: 'chrome-beta',
testMatch: '**/*.e2e.spec.js', // only run e2e tests
grepInvert: /@snapshot/, grepInvert: /@snapshot/,
use: { use: {
browserName: 'chromium', browserName: 'chromium',
channel: 'chrome-beta' channel: 'chrome-beta'
} }
},
{
name: 'ipad',
testMatch: '**/*.e2e.spec.js', // only run e2e tests
grep: /@ipad/,
grepInvert: /@snapshot/,
use: {
browserName: 'webkit',
...devices['iPad (gen 7) landscape'] // Complete List https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json
}
} }
], ],
reporter: [ reporter: [

View File

@ -0,0 +1,72 @@
// playwright.config.js
// @ts-check
import { devices } from '@playwright/test';
const MAX_FAILURES = 5;
import { fileURLToPath } from 'url';
/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
retries: 1, //Retries 2 times for a total of 3 runs. When running sharded and with max-failures=5, this should ensure that flake is managed without failing the full suite
testDir: 'tests',
testIgnore: '**/*.perf.spec.js', //Ignore performance tests and define in playwright-performance.config.js
timeout: 30 * 1000,
webServer: {
command: 'npm run start:coverage',
cwd: fileURLToPath(new URL('../', import.meta.url)), // Provide cwd for the root of the project
url: 'http://localhost:8080/#',
timeout: 200 * 1000,
reuseExistingServer: true //This was originally disabled to prevent differences in local debugging vs. CI. However, it significantly speeds up local debugging.
},
maxFailures: MAX_FAILURES, //Limits failures to 5 to reduce CI Waste
workers: 1, //Limit to 1 due to resource constraints similar to https://github.com/percy/cli/discussions/1067
use: {
baseURL: 'http://localhost:8080/',
headless: true,
ignoreHTTPSErrors: true,
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'off',
// @ts-ignore - custom configuration option for nyc codecoverage output path
coveragePath: fileURLToPath(new URL('../.nyc_output', import.meta.url))
},
projects: [
{
name: 'ipad',
grep: /@mobile/,
use: {
storageState: fileURLToPath(
new URL('./test-data/display_layout_with_child_layouts.json', import.meta.url)
),
browserName: 'webkit',
...devices['iPad (gen 7) landscape'] // Complete List https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json
}
},
{
name: 'iphone',
grep: /@mobile/,
use: {
storageState: fileURLToPath(
new URL('./test-data/display_layout_with_child_layouts.json', import.meta.url)
),
browserName: 'webkit',
...devices['iPhone 14 Pro'] // Complete List https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json
}
}
],
reporter: [
['list'],
[
'html',
{
open: 'never',
outputFolder: '../html-test-results' //Must be in different location due to https://github.com/microsoft/playwright/issues/12840
}
],
['junit', { outputFile: '../test-results/results.xml' }]
]
};
export default config;

View File

@ -1,6 +1,6 @@
// playwright.config.js // playwright.config.js
// @ts-check // @ts-check
import { fileURLToPath } from 'url';
/** @type {import('@playwright/test').PlaywrightTestConfig} */ /** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = { const config = {
retries: 1, //Only for debugging purposes for trace: 'on-first-retry' retries: 1, //Only for debugging purposes for trace: 'on-first-retry'
@ -10,6 +10,7 @@ const config = {
workers: 1, //Only run in serial with 1 worker workers: 1, //Only run in serial with 1 worker
webServer: { webServer: {
command: 'npm run start', //need development mode for performance.marks and others command: 'npm run start', //need development mode for performance.marks and others
cwd: fileURLToPath(new URL('../', import.meta.url)), // Provide cwd for the root of the project
url: 'http://localhost:8080/#', url: 'http://localhost:8080/#',
timeout: 200 * 1000, timeout: 200 * 1000,
reuseExistingServer: false reuseExistingServer: false

View File

@ -1,6 +1,6 @@
// playwright.config.js // playwright.config.js
// @ts-check // @ts-check
import { fileURLToPath } from 'url';
/** @type {import('@playwright/test').PlaywrightTestConfig} */ /** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = { const config = {
retries: 0, //Only for debugging purposes for trace: 'on-first-retry' retries: 0, //Only for debugging purposes for trace: 'on-first-retry'
@ -10,6 +10,7 @@ const config = {
workers: 1, //Only run in serial with 1 worker workers: 1, //Only run in serial with 1 worker
webServer: { webServer: {
command: 'npm run start:prod', //Production mode command: 'npm run start:prod', //Production mode
cwd: fileURLToPath(new URL('../', import.meta.url)), // Provide cwd for the root of the project
url: 'http://localhost:8080/#', url: 'http://localhost:8080/#',
timeout: 200 * 1000, timeout: 200 * 1000,
reuseExistingServer: false //Must be run with this option to prevent dev mode reuseExistingServer: false //Must be run with this option to prevent dev mode

View File

@ -1,7 +1,6 @@
/* eslint-disable no-undef */
// playwright.config.js // playwright.config.js
// @ts-check // @ts-check
import { fileURLToPath } from 'url';
/** @type {import('@playwright/test').PlaywrightTestConfig<{ theme: string }>} */ /** @type {import('@playwright/test').PlaywrightTestConfig<{ theme: string }>} */
const config = { const config = {
retries: 0, // Visual tests should never retry due to snapshot comparison errors. Leaving as a shim retries: 0, // Visual tests should never retry due to snapshot comparison errors. Leaving as a shim
@ -11,9 +10,10 @@ const config = {
workers: 1, //Lower stress on Circle CI Agent for Visual tests https://github.com/percy/cli/discussions/1067 workers: 1, //Lower stress on Circle CI Agent for Visual tests https://github.com/percy/cli/discussions/1067
webServer: { webServer: {
command: 'npm run start:coverage', command: 'npm run start:coverage',
cwd: fileURLToPath(new URL('../', import.meta.url)), // Provide cwd for the root of the project
url: 'http://localhost:8080/#', url: 'http://localhost:8080/#',
timeout: 200 * 1000, timeout: 200 * 1000,
reuseExistingServer: !process.env.CI reuseExistingServer: true //This was originally disabled to prevent differences in local debugging vs. CI. However, it significantly speeds up local debugging.
}, },
use: { use: {
baseURL: 'http://localhost:8080/', baseURL: 'http://localhost:8080/',
@ -36,6 +36,13 @@ const config = {
browserName: 'chromium', browserName: 'chromium',
theme: 'snow' theme: 'snow'
} }
},
{
name: 'darkmatter-theme', //Runs the same visual tests but with darkmatter-theme
use: {
browserName: 'chromium',
theme: 'darkmatter'
}
} }
], ],
reporter: [ reporter: [

View File

@ -1,5 +1,7 @@
// playwright.config.js // playwright.config.js
// @ts-check // @ts-check
import { devices } from '@playwright/test';
import { fileURLToPath } from 'url';
/** @type {import('@playwright/test').PlaywrightTestConfig} */ /** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = { const config = {
@ -8,6 +10,7 @@ const config = {
timeout: 60 * 1000, timeout: 60 * 1000,
webServer: { webServer: {
command: 'npm run start', //Start in dev mode for hot reloading command: 'npm run start', //Start in dev mode for hot reloading
cwd: fileURLToPath(new URL('../', import.meta.url)), // Provide cwd for the root of the project
url: 'http://localhost:8080/#', url: 'http://localhost:8080/#',
timeout: 200 * 1000, timeout: 200 * 1000,
reuseExistingServer: true //This was originally disabled to prevent differences in local debugging vs. CI. However, it significantly speeds up local debugging. reuseExistingServer: true //This was originally disabled to prevent differences in local debugging vs. CI. However, it significantly speeds up local debugging.
@ -28,6 +31,28 @@ const config = {
use: { use: {
browserName: 'chromium' browserName: 'chromium'
} }
},
{
name: 'ipad',
grep: /@mobile/,
use: {
storageState: fileURLToPath(
new URL('./test-data/display_layout_with_child_layouts.json', import.meta.url)
),
browserName: 'webkit',
...devices['iPad (gen 7) landscape'] // Complete List https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json
}
},
{
name: 'iphone',
grep: /@mobile/,
use: {
storageState: fileURLToPath(
new URL('./test-data/display_layout_with_child_layouts.json', import.meta.url)
),
browserName: 'webkit',
...devices['iPhone 14 Pro'] // Complete List https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json
}
} }
], ],
reporter: [ reporter: [

View File

@ -1,4 +1,3 @@
/* eslint-disable no-undef */
/***************************************************************************** /*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government * Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space * as represented by the Administrator of the National Aeronautics and Space
@ -123,12 +122,16 @@ const extendedTest = test.extend({
theme: [theme, { option: true }], theme: [theme, { option: true }],
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
page: async ({ page, theme }, use, testInfo) => { page: async ({ page, theme }, use, testInfo) => {
// eslint-disable-next-line playwright/no-conditional-in-test
if (theme === 'snow') { if (theme === 'snow') {
//inject snow theme //inject snow theme
await page.addInitScript({ await page.addInitScript({
path: fileURLToPath(new URL('./helper/useSnowTheme.js', import.meta.url)) path: fileURLToPath(new URL('./helper/useSnowTheme.js', import.meta.url))
}); });
} else if (theme === 'darkmatter') {
//inject darkmatter theme
await page.addInitScript({
path: fileURLToPath(new URL('./helper/useDarkmatterTheme.js', import.meta.url))
});
} }
// Attach info about the currently running test and its project. // Attach info about the currently running test and its project.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,15 +6,15 @@
"localStorage": [ "localStorage": [
{ {
"name": "mct", "name": "mct",
"value": "{\"mine\":{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"e78ca721-fb5e-409b-bf6d-597c87cb716f\",\"namespace\":\"\"},{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413602460,\"created\":1732413600960,\"persisted\":1732413602460},\"e78ca721-fb5e-409b-bf6d-597c87cb716f\":{\"identifier\":{\"key\":\"e78ca721-fb5e-409b-bf6d-597c87cb716f\",\"namespace\":\"\"},\"name\":\"Overlay Plot with Telemetry Object\",\"type\":\"telemetry.plot.overlay\",\"composition\":[{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}],\"configuration\":{\"series\":[{\"identifier\":{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}}]},\"notes\":\"framework/generateLocalStorageData.e2e.spec.js\\nGenerate Visual Test Data @localStorage @generatedata\\nGenerate Overlay Plot with Telemetry Object\\nchrome\",\"modified\":1732413603880,\"location\":\"mine\",\"created\":1732413601740,\"persisted\":1732413603880},\"c6100044-56be-44b3-acca-6b9fddfb3849\":{\"name\":\"VIPER Rover Heading\",\"type\":\"generator\",\"identifier\":{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"},\"telemetry\":{\"period\":10,\"amplitude\":1,\"offset\":0,\"dataRateInHz\":1,\"phase\":0,\"randomness\":0,\"loadDelay\":0,\"infinityValues\":false,\"exceedFloat32\":false,\"staleness\":false},\"modified\":1732413602460,\"location\":\"mine\",\"created\":1732413602460,\"persisted\":1732413602460}}" "value": "{\"mine\":{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"d3014736-1182-4b70-8122-6d0c6ef540e1\",\"namespace\":\"\"},{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413605677,\"created\":1732413603298,\"persisted\":1732413605677},\"d3014736-1182-4b70-8122-6d0c6ef540e1\":{\"identifier\":{\"key\":\"d3014736-1182-4b70-8122-6d0c6ef540e1\",\"namespace\":\"\"},\"name\":\"Overlay Plot with Telemetry Object\",\"type\":\"telemetry.plot.overlay\",\"composition\":[{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}],\"configuration\":{\"series\":[]},\"notes\":\"framework/generateLocalStorageData.e2e.spec.js\\nGenerate Visual Test Data @localStorage @generatedata @clock\\nGenerate Overlay Plot with Telemetry Object\\nchrome\",\"modified\":1732413607031,\"location\":\"mine\",\"created\":1732413605018,\"persisted\":1732413607031},\"8c53d61f-b514-4535-be87-0fb20eb56576\":{\"name\":\"VIPER Rover Heading\",\"type\":\"generator\",\"identifier\":{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"},\"telemetry\":{\"period\":10,\"amplitude\":1,\"offset\":0,\"dataRateInHz\":1,\"phase\":0,\"randomness\":0,\"loadDelay\":0,\"infinityValues\":false,\"exceedFloat32\":false,\"staleness\":false},\"modified\":1732413605677,\"location\":\"mine\",\"created\":1732413605677,\"persisted\":1732413605677}}"
},
{
"name": "mct-recent-objects",
"value": "[{\"objectPath\":[{\"identifier\":{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"},\"name\":\"VIPER Rover Heading\",\"type\":\"generator\",\"telemetry\":{\"period\":10,\"amplitude\":1,\"offset\":0,\"dataRateInHz\":1,\"phase\":0,\"randomness\":0,\"loadDelay\":0,\"infinityValues\":false,\"exceedFloat32\":false,\"staleness\":false},\"modified\":1732413602460,\"location\":\"mine\",\"created\":1732413602460,\"persisted\":1732413602460},{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"e78ca721-fb5e-409b-bf6d-597c87cb716f\",\"namespace\":\"\"},{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413602460,\"created\":1732413600960,\"persisted\":1732413602460},{\"identifier\":{\"key\":\"ROOT\",\"namespace\":\"\"},\"name\":\"Open MCT\",\"type\":\"root\",\"composition\":[{\"key\":\"mine\",\"namespace\":\"\"}]}],\"navigationPath\":\"/browse/mine/c6100044-56be-44b3-acca-6b9fddfb3849\",\"domainObject\":{\"identifier\":{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"},\"name\":\"VIPER Rover Heading\",\"type\":\"generator\",\"telemetry\":{\"period\":10,\"amplitude\":1,\"offset\":0,\"dataRateInHz\":1,\"phase\":0,\"randomness\":0,\"loadDelay\":0,\"infinityValues\":false,\"exceedFloat32\":false,\"staleness\":false},\"modified\":1732413602460,\"location\":\"mine\",\"created\":1732413602460,\"persisted\":1732413602460}},{\"objectPath\":[{\"identifier\":{\"key\":\"e78ca721-fb5e-409b-bf6d-597c87cb716f\",\"namespace\":\"\"},\"name\":\"Overlay Plot with Telemetry Object\",\"type\":\"telemetry.plot.overlay\",\"composition\":[{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}],\"configuration\":{\"series\":[{\"identifier\":{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}}]},\"notes\":\"framework/generateLocalStorageData.e2e.spec.js\\nGenerate Visual Test Data @localStorage @generatedata\\nGenerate Overlay Plot with Telemetry Object\\nchrome\",\"modified\":1732413603880,\"location\":\"mine\",\"created\":1732413601740,\"persisted\":1732413603880},{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"e78ca721-fb5e-409b-bf6d-597c87cb716f\",\"namespace\":\"\"},{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413602460,\"created\":1732413600960,\"persisted\":1732413602460},{\"identifier\":{\"key\":\"ROOT\",\"namespace\":\"\"},\"name\":\"Open MCT\",\"type\":\"root\",\"composition\":[{\"key\":\"mine\",\"namespace\":\"\"}]}],\"navigationPath\":\"/browse/mine/e78ca721-fb5e-409b-bf6d-597c87cb716f\",\"domainObject\":{\"identifier\":{\"key\":\"e78ca721-fb5e-409b-bf6d-597c87cb716f\",\"namespace\":\"\"},\"name\":\"Overlay Plot with Telemetry Object\",\"type\":\"telemetry.plot.overlay\",\"composition\":[{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}],\"configuration\":{\"series\":[{\"identifier\":{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}}]},\"notes\":\"framework/generateLocalStorageData.e2e.spec.js\\nGenerate Visual Test Data @localStorage @generatedata\\nGenerate Overlay Plot with Telemetry Object\\nchrome\",\"modified\":1732413603880,\"location\":\"mine\",\"created\":1732413601740,\"persisted\":1732413603880}},{\"objectPath\":[{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"e78ca721-fb5e-409b-bf6d-597c87cb716f\",\"namespace\":\"\"},{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413602460,\"created\":1732413600960,\"persisted\":1732413602460},{\"identifier\":{\"key\":\"ROOT\",\"namespace\":\"\"},\"name\":\"Open MCT\",\"type\":\"root\",\"composition\":[{\"key\":\"mine\",\"namespace\":\"\"}]}],\"navigationPath\":\"/browse/mine\",\"domainObject\":{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"e78ca721-fb5e-409b-bf6d-597c87cb716f\",\"namespace\":\"\"},{\"key\":\"c6100044-56be-44b3-acca-6b9fddfb3849\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413602460,\"created\":1732413600960,\"persisted\":1732413602460}}]"
}, },
{ {
"name": "mct-tree-expanded", "name": "mct-tree-expanded",
"value": "[]" "value": "[]"
},
{
"name": "mct-recent-objects",
"value": "[{\"objectPath\":[{\"identifier\":{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"},\"name\":\"VIPER Rover Heading\",\"type\":\"generator\",\"telemetry\":{\"period\":10,\"amplitude\":1,\"offset\":0,\"dataRateInHz\":1,\"phase\":0,\"randomness\":0,\"loadDelay\":0,\"infinityValues\":false,\"exceedFloat32\":false,\"staleness\":false},\"modified\":1732413605677,\"location\":\"mine\",\"created\":1732413605677,\"persisted\":1732413605677},{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"d3014736-1182-4b70-8122-6d0c6ef540e1\",\"namespace\":\"\"},{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413605677,\"created\":1732413603298,\"persisted\":1732413605677},{\"identifier\":{\"key\":\"ROOT\",\"namespace\":\"\"},\"name\":\"Open MCT\",\"type\":\"root\",\"composition\":[{\"key\":\"mine\",\"namespace\":\"\"}]}],\"navigationPath\":\"/browse/mine/8c53d61f-b514-4535-be87-0fb20eb56576\",\"domainObject\":{\"identifier\":{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"},\"name\":\"VIPER Rover Heading\",\"type\":\"generator\",\"telemetry\":{\"period\":10,\"amplitude\":1,\"offset\":0,\"dataRateInHz\":1,\"phase\":0,\"randomness\":0,\"loadDelay\":0,\"infinityValues\":false,\"exceedFloat32\":false,\"staleness\":false},\"modified\":1732413605677,\"location\":\"mine\",\"created\":1732413605677,\"persisted\":1732413605677}},{\"objectPath\":[{\"identifier\":{\"key\":\"d3014736-1182-4b70-8122-6d0c6ef540e1\",\"namespace\":\"\"},\"name\":\"Overlay Plot with Telemetry Object\",\"type\":\"telemetry.plot.overlay\",\"composition\":[{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}],\"configuration\":{\"series\":[{\"identifier\":{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}}]},\"notes\":\"framework/generateLocalStorageData.e2e.spec.js\\nGenerate Visual Test Data @localStorage @generatedata @clock\\nGenerate Overlay Plot with Telemetry Object\\nchrome\",\"modified\":1732413607031,\"location\":\"mine\",\"created\":1732413605018,\"persisted\":1732413607031},{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"d3014736-1182-4b70-8122-6d0c6ef540e1\",\"namespace\":\"\"},{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413605677,\"created\":1732413603298,\"persisted\":1732413605677},{\"identifier\":{\"key\":\"ROOT\",\"namespace\":\"\"},\"name\":\"Open MCT\",\"type\":\"root\",\"composition\":[{\"key\":\"mine\",\"namespace\":\"\"}]}],\"navigationPath\":\"/browse/mine/d3014736-1182-4b70-8122-6d0c6ef540e1\",\"domainObject\":{\"identifier\":{\"key\":\"d3014736-1182-4b70-8122-6d0c6ef540e1\",\"namespace\":\"\"},\"name\":\"Overlay Plot with Telemetry Object\",\"type\":\"telemetry.plot.overlay\",\"composition\":[{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}],\"configuration\":{\"series\":[{\"identifier\":{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}}]},\"notes\":\"framework/generateLocalStorageData.e2e.spec.js\\nGenerate Visual Test Data @localStorage @generatedata @clock\\nGenerate Overlay Plot with Telemetry Object\\nchrome\",\"modified\":1732413607031,\"location\":\"mine\",\"created\":1732413605018,\"persisted\":1732413607031}},{\"objectPath\":[{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"d3014736-1182-4b70-8122-6d0c6ef540e1\",\"namespace\":\"\"},{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413605677,\"created\":1732413603298,\"persisted\":1732413605677},{\"identifier\":{\"key\":\"ROOT\",\"namespace\":\"\"},\"name\":\"Open MCT\",\"type\":\"root\",\"composition\":[{\"key\":\"mine\",\"namespace\":\"\"}]}],\"navigationPath\":\"/browse/mine\",\"domainObject\":{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"d3014736-1182-4b70-8122-6d0c6ef540e1\",\"namespace\":\"\"},{\"key\":\"8c53d61f-b514-4535-be87-0fb20eb56576\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413605677,\"created\":1732413603298,\"persisted\":1732413605677}}]"
} }
] ]
} }

View File

@ -6,7 +6,7 @@
"localStorage": [ "localStorage": [
{ {
"name": "mct", "name": "mct",
"value": "{\"mine\":{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"67ca2e0a-b37e-4eda-86a4-ccdbb228bbc0\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413601720,\"created\":1732413600920,\"persisted\":1732413601720},\"67ca2e0a-b37e-4eda-86a4-ccdbb228bbc0\":{\"identifier\":{\"key\":\"67ca2e0a-b37e-4eda-86a4-ccdbb228bbc0\",\"namespace\":\"\"},\"name\":\"Overlay Plot with 5s Delay\",\"type\":\"telemetry.plot.overlay\",\"composition\":[{\"key\":\"8f524b49-ad06-47f9-98e0-087b31a2f3e0\",\"namespace\":\"\"}],\"configuration\":{\"series\":[{\"identifier\":{\"key\":\"8f524b49-ad06-47f9-98e0-087b31a2f3e0\",\"namespace\":\"\"}}]},\"notes\":\"framework/generateLocalStorageData.e2e.spec.js\\nGenerate Visual Test Data @localStorage @generatedata\\nGenerate Overlay Plot with 5s Delay\\nchrome\",\"modified\":1732413603020,\"location\":\"mine\",\"created\":1732413601720,\"persisted\":1732413603020},\"8f524b49-ad06-47f9-98e0-087b31a2f3e0\":{\"identifier\":{\"key\":\"8f524b49-ad06-47f9-98e0-087b31a2f3e0\",\"namespace\":\"\"},\"name\":\"VIPER Rover Heading\",\"type\":\"generator\",\"telemetry\":{\"period\":10,\"amplitude\":1,\"offset\":0,\"dataRateInHz\":1,\"phase\":0,\"randomness\":0,\"loadDelay\":5000,\"infinityValues\":false,\"exceedFloat32\":false,\"staleness\":false},\"modified\":1732413602920,\"location\":\"67ca2e0a-b37e-4eda-86a4-ccdbb228bbc0\",\"created\":1732413602420,\"persisted\":1732413602920}}" "value": "{\"mine\":{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"f34f457e-d7f4-4fc4-ba71-52e19e925646\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"modified\":1732413605044,\"created\":1732413603140,\"persisted\":1732413605044},\"f34f457e-d7f4-4fc4-ba71-52e19e925646\":{\"identifier\":{\"key\":\"f34f457e-d7f4-4fc4-ba71-52e19e925646\",\"namespace\":\"\"},\"name\":\"Overlay Plot with 5s Delay\",\"type\":\"telemetry.plot.overlay\",\"composition\":[{\"key\":\"c568fa66-62e0-4eee-97eb-cdbc7421e556\",\"namespace\":\"\"}],\"configuration\":{\"series\":[{\"identifier\":{\"key\":\"c568fa66-62e0-4eee-97eb-cdbc7421e556\",\"namespace\":\"\"}}]},\"notes\":\"framework/generateLocalStorageData.e2e.spec.js\\nGenerate Visual Test Data @localStorage @generatedata @clock\\nGenerate Overlay Plot with 5s Delay\\nchrome\",\"modified\":1732413606208.9,\"location\":\"mine\",\"created\":1732413605044,\"persisted\":1732413606208.9},\"c568fa66-62e0-4eee-97eb-cdbc7421e556\":{\"identifier\":{\"key\":\"c568fa66-62e0-4eee-97eb-cdbc7421e556\",\"namespace\":\"\"},\"name\":\"VIPER Rover Heading\",\"type\":\"generator\",\"telemetry\":{\"period\":10,\"amplitude\":1,\"offset\":0,\"dataRateInHz\":1,\"phase\":0,\"randomness\":0,\"loadDelay\":5000,\"infinityValues\":false,\"exceedFloat32\":false,\"staleness\":false},\"modified\":1732413606049,\"location\":\"f34f457e-d7f4-4fc4-ba71-52e19e925646\",\"created\":1732413605554,\"persisted\":1732413606049}}"
}, },
{ {
"name": "mct-tree-expanded", "name": "mct-tree-expanded",

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -19,19 +19,35 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import fs from 'fs';
import { import {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
createExampleTelemetryObject,
createNotification, createNotification,
createPlanFromJSON,
createStableStateTelemetry,
expandEntireTree, expandEntireTree,
openObjectTreeContextMenu getCanvasPixels,
linkParameterToObject,
navigateToObjectWithFixedTimeBounds,
navigateToObjectWithRealTime,
setEndOffset,
setFixedIndependentTimeConductorBounds,
setFixedTimeMode,
setRealTimeMode,
setStartOffset,
setTimeConductorBounds,
waitForPlotsToRender
} from '../../appActions.js'; } from '../../appActions.js';
import { assertPlanActivities, setBoundsToSpanAllActivities } from '../../helper/planningUtils.js';
import { expect, test } from '../../pluginFixtures.js'; import { expect, test } from '../../pluginFixtures.js';
test.describe('AppActions', () => { test.describe('AppActions @framework', () => {
test('createDomainObjectsWithDefaults', async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
});
test('createDomainObjectsWithDefaults', async ({ page }) => {
const e2eFolder = await createDomainObjectWithDefaults(page, { const e2eFolder = await createDomainObjectWithDefaults(page, {
type: 'Folder', type: 'Folder',
name: 'e2e folder' name: 'e2e folder'
@ -90,8 +106,39 @@ test.describe('AppActions', () => {
expect(folder3.url).toBe(`${e2eFolder.url}/${folder1.uuid}/${folder2.uuid}/${folder3.uuid}`); expect(folder3.url).toBe(`${e2eFolder.url}/${folder1.uuid}/${folder2.uuid}/${folder3.uuid}`);
}); });
}); });
test('createExampleTelemetryObject', async ({ page }) => {
const gauge = await createDomainObjectWithDefaults(page, {
type: 'Gauge',
name: 'Gauge with no data'
});
const swgWithParent = await createExampleTelemetryObject(page, gauge.uuid);
await page.goto(swgWithParent.url);
await expect(page.locator('.l-browse-bar__object-name')).toHaveText(swgWithParent.name);
await page.getByLabel('More actions').click();
await page.getByLabel('Edit Properties...').click();
// Check Default values of created object
await expect(page.getByLabel('Title', { exact: true })).toHaveValue('VIPER Rover Heading');
await expect(page.getByRole('spinbutton', { name: 'Period' })).toHaveValue('10');
await expect(page.getByRole('spinbutton', { name: 'Amplitude' })).toHaveValue('1');
await expect(page.getByRole('spinbutton', { name: 'Offset' })).toHaveValue('0');
await expect(page.getByRole('spinbutton', { name: 'Data Rate (hz)' })).toHaveValue('1');
await expect(page.getByRole('spinbutton', { name: 'Phase (radians)' })).toHaveValue('0');
await expect(page.getByRole('spinbutton', { name: 'Randomness' })).toHaveValue('0');
await expect(page.getByRole('spinbutton', { name: 'Loading Delay (ms)' })).toHaveValue('0');
await page.getByLabel('Cancel').click();
const swgWithoutParent = await createExampleTelemetryObject(page);
await page.getByLabel('Show selected item in tree').click();
expect(swgWithParent.url).toBe(`${gauge.url}/${swgWithParent.uuid}`);
expect(swgWithoutParent.url).toBe(`./#/browse/mine/${swgWithoutParent.uuid}`);
});
test('createNotification', async ({ page }) => { test('createNotification', async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
await createNotification(page, { await createNotification(page, {
message: 'Test info notification', message: 'Test info notification',
severity: 'info' severity: 'info'
@ -114,9 +161,20 @@ test.describe('AppActions', () => {
await expect(page.locator('.c-message-banner')).toHaveClass(/error/); await expect(page.locator('.c-message-banner')).toHaveClass(/error/);
await page.locator('[aria-label="Dismiss"]').click(); await page.locator('[aria-label="Dismiss"]').click();
}); });
test('createPlanFromJSON', async ({ page }) => {
const examplePlanSmall1 = JSON.parse(
fs.readFileSync(
new URL('../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url)
)
);
const plan = await createPlanFromJSON(page, {
name: 'Test Plan',
json: examplePlanSmall1
});
await setBoundsToSpanAllActivities(page, examplePlanSmall1, plan.url);
await assertPlanActivities(page, examplePlanSmall1, plan.url);
});
test('expandEntireTree', async ({ page }) => { test('expandEntireTree', async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
const rootFolder = await createDomainObjectWithDefaults(page, { const rootFolder = await createDomainObjectWithDefaults(page, {
type: 'Folder' type: 'Folder'
}); });
@ -152,28 +210,154 @@ test.describe('AppActions', () => {
name: 'Main Tree' name: 'Main Tree'
}); });
const treePaneCollapsedItems = treePane.getByRole('treeitem', { expanded: false }); const treePaneCollapsedItems = treePane.getByRole('treeitem', { expanded: false });
expect(await treePaneCollapsedItems.count()).toBe(0); await expect(treePaneCollapsedItems).toHaveCount(0);
await page.goto('./#/browse/mine'); await page.goto('./#/browse/mine');
//Click the Create button //Click the Create button
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
// Click the object specified by 'type' // Click the object specified by 'type'
await page.click(`li[role='menuitem']:text("Clock")`); await page.getByRole('menuitem', { name: 'Clock' }).click();
await expandEntireTree(page, 'Create Modal Tree'); await expandEntireTree(page, 'Create Modal Tree');
const locatorTree = page.getByRole('tree', { const locatorTree = page.getByRole('tree', {
name: 'Create Modal Tree' name: 'Create Modal Tree'
}); });
const locatorTreeCollapsedItems = locatorTree.locator('role=treeitem[expanded=false]'); const locatorTreeCollapsedItems = locatorTree.locator('role=treeitem[expanded=false]');
expect(await locatorTreeCollapsedItems.count()).toBe(0); await expect(locatorTreeCollapsedItems).toHaveCount(0);
});
test('getCanvasPixels', async ({ page }) => {
let overlayPlot = await createDomainObjectWithDefaults(page, {
type: 'Overlay Plot'
}); });
test('openObjectTreeContextMenu', async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
const folder = await createDomainObjectWithDefaults(page, { await createExampleTelemetryObject(page, overlayPlot.uuid);
type: 'Folder'
await page.goto(overlayPlot.url);
//Get pixel data from Canvas
const plotPixels = await getCanvasPixels(page, 'canvas');
const plotPixelSize = plotPixels.length;
expect(plotPixelSize).toBeGreaterThan(0);
}); });
await openObjectTreeContextMenu(page, folder.url); test('navigateToObjectWithFixedTimeBounds', async ({ page }) => {
await expect(page.getByLabel('Menu')).toBeVisible(); const exampleTelemetry = await createExampleTelemetryObject(page);
//Navigate without explicit bounds
await navigateToObjectWithFixedTimeBounds(page, exampleTelemetry.url);
await expect(page.getByLabel('Start bounds:')).toBeVisible();
await expect(page.getByLabel('End bounds:')).toBeVisible();
//Navigate with explicit bounds
await navigateToObjectWithFixedTimeBounds(
page,
exampleTelemetry.url,
1693592063607,
1693593893607
);
await expect(page.getByLabel('Start bounds: 2023-09-01 18:')).toBeVisible();
await expect(page.getByLabel('End bounds: 2023-09-01 18:44:')).toBeVisible();
});
test('navigateToObjectWithRealTime', async ({ page }) => {
const exampleTelemetry = await createExampleTelemetryObject(page);
//Navigate without explicit bounds
await navigateToObjectWithRealTime(page, exampleTelemetry.url);
await expect(page.getByLabel('Start offset:')).toBeVisible();
await expect(page.getByLabel('End offset: 00:00:')).toBeVisible();
//Navigate with explicit bounds
await navigateToObjectWithRealTime(page, exampleTelemetry.url, 1693592063607, 1693593893607);
await expect(page.getByLabel('Start offset: 18:14:')).toBeVisible();
await expect(page.getByLabel('End offset: 18:44:')).toBeVisible();
});
test('setTimeConductorMode', async ({ page }) => {
await test.step('setFixedTimeMode', async () => {
await setFixedTimeMode(page);
await expect(page.getByLabel('Start bounds:')).toBeVisible();
await expect(page.getByLabel('End bounds:')).toBeVisible();
});
await test.step('setTimeConductorBounds', async () => {
await setTimeConductorBounds(page, {
startDate: '2024-01-01',
endDate: '2024-01-02',
startTime: '00:00:00',
endTime: '23:59:59'
});
await expect(page.getByLabel('Start bounds: 2024-01-01 00:00:00')).toBeVisible();
await expect(page.getByLabel('End bounds: 2024-01-02 23:59:59')).toBeVisible();
});
await test.step('setRealTimeMode', async () => {
await setRealTimeMode(page);
await expect(page.getByLabel('Start offset')).toBeVisible();
await expect(page.getByLabel('End offset')).toBeVisible();
});
await test.step('setStartOffset', async () => {
await setStartOffset(page, {
startHours: '04',
startMins: '20',
startSecs: '22'
});
await expect(page.getByLabel('Start offset: 04:20:22')).toBeVisible();
});
await test.step('setEndOffset', async () => {
await setEndOffset(page, {
endHours: '04',
endMins: '20',
endSecs: '22'
});
await expect(page.getByLabel('End offset: 04:20:22')).toBeVisible();
});
});
test('setFixedIndependentTimeConductorBounds', async ({ page }) => {
// Create a Display Layout
const displayLayout = await createDomainObjectWithDefaults(page, {
type: 'Display Layout'
});
await createDomainObjectWithDefaults(page, {
type: 'Example Imagery',
parent: displayLayout.uuid
});
const startDate = '2021-12-30 01:01:00.000Z';
const endDate = '2021-12-30 01:11:00.000Z';
await setFixedIndependentTimeConductorBounds(page, { start: startDate, end: endDate });
// check image date
await expect(page.getByText('2021-12-30 01:11:00.000Z').first()).toBeVisible();
// flip it off
await page.getByRole('switch').click();
// timestamp shouldn't be in the past anymore
await expect(page.getByText('2021-12-30 01:11:00.000Z')).toBeHidden();
});
test.fail('waitForPlotsToRender', async ({ page }) => {
// Create a SWG
await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator'
});
// Edit the SWG
await page.getByLabel('More actions').click();
await page.getByLabel('Edit Properties...').click();
// Set loading delay to 10 seconds
await page.getByLabel('Loading Delay (ms)', { exact: true }).fill('10000');
await page.getByLabel('Save').click();
// Reload the page
await page.reload();
// Expect this step to fail
await waitForPlotsToRender(page, { timeout: 1000 });
});
test('createStableStateTelemetry', async ({ page }) => {
const stableStateTelemetry = await createStableStateTelemetry(page);
expect(stableStateTelemetry.name).toBe('Stable State Generator');
expect(stableStateTelemetry.url).toBe(`./#/browse/mine/${stableStateTelemetry.uuid}`);
expect(stableStateTelemetry.uuid).toBeDefined();
});
test('linkParameterToObject', async ({ page }) => {
const displayLayout = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Test Display Layout'
});
const exampleTelemetry = await createExampleTelemetryObject(page);
await linkParameterToObject(page, exampleTelemetry.name, displayLayout.name);
await page.goto(displayLayout.url);
await expect(page.getByRole('main').getByText('Test Display Layout')).toBeVisible();
await expandEntireTree(page);
await expect(page.getByLabel('Navigate to VIPER Rover').first()).toBeVisible();
}); });
}); });

View File

@ -27,10 +27,11 @@ relates to how we've extended it (i.e. ./e2e/baseFixtures.js) and assumptions ma
*/ */
import { test } from '../../baseFixtures.js'; import { test } from '../../baseFixtures.js';
import { MISSION_TIME } from '../../constants.js';
test.describe('baseFixtures tests', () => { test.describe('baseFixtures tests', () => {
//Skip this test for now https://github.com/nasa/openmct/issues/6785 //Skip this test for now https://github.com/nasa/openmct/issues/6785
test.fixme('Verify that tests fail if console.error is thrown', async ({ page }) => { test('Verify that tests fail if console.error is thrown', async ({ page }) => {
test.fail(); test.fail();
//Go to baseURL //Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
@ -41,6 +42,21 @@ test.describe('baseFixtures tests', () => {
page.waitForEvent('console') // always wait for the event to happen while triggering it! page.waitForEvent('console') // always wait for the event to happen while triggering it!
]); ]);
}); });
test('Verify that tests fail if console.error is thrown with clock override @clock', async ({
page
}) => {
test.fail();
//Set clock time
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
//Verify that ../fixtures.js detects console log errors
await Promise.all([
page.evaluate(() => console.error('This should result in a failure')),
page.waitForEvent('console') // always wait for the event to happen while triggering it!
]);
});
test('Verify that tests pass if console.warn is thrown', async ({ page }) => { test('Verify that tests pass if console.warn is thrown', async ({ page }) => {
//Go to baseURL //Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });

View File

@ -22,7 +22,7 @@
/* /*
* This test suite template is to be used when creating new test suites. It will be kept up to date with the latest improvements * This test suite template is to be used when creating new test suites. It will be kept up to date with the latest improvements
* made by the Open MCT team. It will also follow our best pratices as those evolve. Please use this structure as a _reference_ and clear * made by the Open MCT team. It will also follow our best practices as those evolve. Please use this structure as a _reference_ and clear
* or update any references when creating a new test suite! * or update any references when creating a new test suite!
* *
* To illustrate current best practices, we've included a mocked up test suite for Renaming a Timer domain object. * To illustrate current best practices, we've included a mocked up test suite for Renaming a Timer domain object.
@ -30,7 +30,6 @@
* Demonstrated: * Demonstrated:
* - Using appActions to leverage existing functions * - Using appActions to leverage existing functions
* - Structure * - Structure
* - @unstable annotation
* - await, expect, test, describe syntax * - await, expect, test, describe syntax
* - Writing a custom function for a test suite * - Writing a custom function for a test suite
* - Test stub for unfinished test coverage (test.fixme) * - Test stub for unfinished test coverage (test.fixme)
@ -45,7 +44,7 @@
*/ */
// Structure: Some standard Imports. Please update the required pathing. // Structure: Some standard Imports. Please update the required pathing.
import { createDomainObjectWithDefaults } from '../../appActions.js'; import { createDomainObjectWithDefaults, createExampleTelemetryObject } from '../../appActions.js';
import { expect, test } from '../../pluginFixtures.js'; import { expect, test } from '../../pluginFixtures.js';
/** /**
@ -53,11 +52,8 @@ import { expect, test } from '../../pluginFixtures.js';
* Try to keep a single describe block per logical groups of tests. * Try to keep a single describe block per logical groups of tests.
* If your test runtime exceeds 5 minutes or 500 lines, it's likely that it will need to be split. * If your test runtime exceeds 5 minutes or 500 lines, it's likely that it will need to be split.
* *
* Annotations:
* Please use the @unstable tag at the end of the test title so that our automation can pick it up
* as a part of our test promotion pipeline.
*/ */
test.describe('Renaming Timer Object', () => { test.describe('Example - Renaming Timer Object', () => {
// Top-level declaration of the Timer object created in beforeEach(). // Top-level declaration of the Timer object created in beforeEach().
// We can then use this throughout the entire test suite. // We can then use this throughout the entire test suite.
let timer; let timer;
@ -70,7 +66,7 @@ test.describe('Renaming Timer Object', () => {
timer = await createDomainObjectWithDefaults(page, { type: 'Timer' }); timer = await createDomainObjectWithDefaults(page, { type: 'Timer' });
// Assert the object to be created and check its name in the title // Assert the object to be created and check its name in the title
await expect(page.locator('.l-browse-bar__object-name')).toContainText(timer.name); await expect(page.getByRole('main')).toContainText(timer.name);
}); });
/** /**
@ -81,11 +77,11 @@ test.describe('Renaming Timer Object', () => {
test('An existing Timer object can be renamed via the 3dot actions menu', async ({ page }) => { test('An existing Timer object can be renamed via the 3dot actions menu', async ({ page }) => {
const newObjectName = 'Renamed Timer'; const newObjectName = 'Renamed Timer';
// We've created an example of a shared function which pases the page and newObjectName values // We've created an example of a shared function which passes the page and newObjectName values
await renameTimerFrom3DotMenu(page, timer.url, newObjectName); await renameTimerFrom3DotMenu(page, timer.url, newObjectName);
// Assert that the name has changed in the browser bar to the value we assigned above // Assert that the name has changed in the browser bar to the value we assigned above
await expect(page.locator('.l-browse-bar__object-name')).toContainText(newObjectName); await expect(page.getByRole('main')).toContainText(newObjectName);
}); });
test('An existing Timer object can be renamed twice', async ({ page }) => { test('An existing Timer object can be renamed twice', async ({ page }) => {
@ -95,13 +91,13 @@ test.describe('Renaming Timer Object', () => {
await renameTimerFrom3DotMenu(page, timer.url, newObjectName); await renameTimerFrom3DotMenu(page, timer.url, newObjectName);
// Assert that the name has changed in the browser bar to the value we assigned above // Assert that the name has changed in the browser bar to the value we assigned above
await expect(page.locator('.l-browse-bar__object-name')).toContainText(newObjectName); await expect(page.getByRole('main')).toContainText(newObjectName);
// Rename the Timer object again // Rename the Timer object again
await renameTimerFrom3DotMenu(page, timer.url, newObjectName2); await renameTimerFrom3DotMenu(page, timer.url, newObjectName2);
// Assert that the name has changed in the browser bar to the second value // Assert that the name has changed in the browser bar to the second value
await expect(page.locator('.l-browse-bar__object-name')).toContainText(newObjectName2); await expect(page.getByRole('main')).toContainText(newObjectName2);
}); });
/** /**
@ -121,7 +117,7 @@ test.describe('Renaming Timer Object', () => {
* The next most important concept in our testing is working with telemetry objects. Telemetry is at the core of Open MCT * The next most important concept in our testing is working with telemetry objects. Telemetry is at the core of Open MCT
* and we have developed a great pattern for working with it. * and we have developed a great pattern for working with it.
*/ */
test.describe('Advanced: Working with telemetry objects', () => { test.describe('Advanced Example - Working with telemetry objects', () => {
let displayLayout; let displayLayout;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
@ -132,17 +128,14 @@ test.describe('Advanced: Working with telemetry objects', () => {
name: 'Display Layout with Embedded SWG' name: 'Display Layout with Embedded SWG'
}); });
// Create Telemetry object within the parent object created above // Create Telemetry object within the parent object created above
await createDomainObjectWithDefaults(page, { //reference the display layout in the creation process
type: 'Sine Wave Generator', await createExampleTelemetryObject(page, displayLayout.uuid);
name: 'Telemetry',
parent: displayLayout.uuid //reference the display layout in the creation process
});
}); });
test('Can directly navigate to a Display Layout with embedded telemetry', async ({ page }) => { test('Can directly navigate to a Display Layout with embedded telemetry', async ({ page }) => {
//Now you can directly navigate to the displayLayout created in the beforeEach with the embedded telemetry //Now you can directly navigate to the displayLayout created in the beforeEach with the embedded telemetry
await page.goto(displayLayout.url); await page.goto(displayLayout.url);
//Expect the created Telemetry Object to be visible when directly navigating to the displayLayout //Expect the created Telemetry Object to be visible when directly navigating to the displayLayout
await expect(page.getByTitle('Sine')).toBeVisible(); await expect(page.getByLabel('Alpha-numeric telemetry name')).toBeVisible();
}); });
}); });
@ -160,18 +153,14 @@ test.describe('Advanced: Working with telemetry objects', () => {
* @param {string} newNameForTimer New name for object * @param {string} newNameForTimer New name for object
*/ */
async function renameTimerFrom3DotMenu(page, timerUrl, newNameForTimer) { async function renameTimerFrom3DotMenu(page, timerUrl, newNameForTimer) {
// Navigate to the timer object // Navigate to the timer object directly
await page.goto(timerUrl); await page.goto(timerUrl);
// Click on 3 Dot Menu await page.getByLabel('More actions').click();
await page.locator('button[title="More actions"]').click(); await page.getByLabel('Edit Properties...').click();
// Click text=Edit Properties...
await page.locator('text=Edit Properties...').click();
// Rename the timer object // Rename the timer object
await page.locator('text=Properties Title Notes >> input[type="text"]').fill(newNameForTimer); await page.getByLabel('Title', { exact: true }).fill(newNameForTimer);
// Click Ok button to Save await page.getByLabel('Save').click();
await page.locator('button:has-text("OK")').click();
} }

View File

@ -33,21 +33,22 @@
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { createDomainObjectWithDefaults, createExampleTelemetryObject } from '../../appActions.js'; import {
createDomainObjectWithDefaults,
createExampleTelemetryObject,
setFixedIndependentTimeConductorBounds,
setTimeConductorBounds
} from '../../appActions.js';
import { MISSION_TIME } from '../../constants.js'; import { MISSION_TIME } from '../../constants.js';
import { expect, test } from '../../pluginFixtures.js'; import { expect, test } from '../../pluginFixtures.js';
const overlayPlotName = 'Overlay Plot with Telemetry Object'; const overlayPlotName = 'Overlay Plot with Telemetry Object';
test.describe('Generate Visual Test Data @localStorage @generatedata', () => { test.describe('Generate Visual Test Data @localStorage @generatedata @clock', () => {
test.use({
clockOptions: {
now: MISSION_TIME,
shouldAdvanceTime: true
}
});
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Override the clock
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
// Go to baseURL // Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
}); });
@ -89,6 +90,60 @@ test.describe('Generate Visual Test Data @localStorage @generatedata', () => {
}); });
}); });
test('Generate display layout with 1 child overlay plot', async ({ page, context }) => {
const parent = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Parent Display Layout'
});
const overlayPlot = await createDomainObjectWithDefaults(page, {
type: 'Overlay Plot',
name: 'Child Overlay Plot 1',
parent: parent.uuid
});
await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator',
name: 'Child SWG 1',
parent: overlayPlot.uuid
});
await page.goto(parent.url, { waitUntil: 'domcontentloaded' });
await setFixedIndependentTimeConductorBounds(page, {
start: '2024-11-12 19:11:11.000Z',
end: '2024-11-12 20:11:11.000Z'
});
const NEW_GLOBAL_START_DATE = '2024-11-11';
const NEW_GLOBAL_START_TIME = '19:11:11';
const NEW_GLOBAL_END_DATE = '2024-11-11';
const NEW_GLOBAL_END_TIME = '20:11:11';
await setTimeConductorBounds(page, {
startDate: NEW_GLOBAL_START_DATE,
startTime: NEW_GLOBAL_START_TIME,
endDate: NEW_GLOBAL_END_DATE,
endTime: NEW_GLOBAL_END_TIME
});
// Verify that the global time conductor bounds have been updated
await expect(
page.getByLabel(`Start bounds: ${NEW_GLOBAL_START_DATE} ${NEW_GLOBAL_START_TIME}.000Z`)
).toBeVisible();
await expect(
page.getByLabel(`End bounds: ${NEW_GLOBAL_END_DATE} ${NEW_GLOBAL_END_TIME}.000Z`)
).toBeVisible();
//Save localStorage for future test execution
await context.storageState({
path: fileURLToPath(
new URL(
'../../../e2e/test-data/display_layout_with_child_overlay_plot.json',
import.meta.url
)
)
});
});
test('Generate flexible layout with 2 child display layouts', async ({ page, context }) => { test('Generate flexible layout with 2 child display layouts', async ({ page, context }) => {
// Create Display Layout // Create Display Layout
const parent = await createDomainObjectWithDefaults(page, { const parent = await createDomainObjectWithDefaults(page, {
@ -152,11 +207,7 @@ test.describe('Generate Visual Test Data @localStorage @generatedata', () => {
// TODO: Flesh Out Assertions against created Objects // TODO: Flesh Out Assertions against created Objects
await expect(page.locator('.l-browse-bar__object-name')).toContainText(overlayPlotName); await expect(page.locator('.l-browse-bar__object-name')).toContainText(overlayPlotName);
await page.getByRole('tab', { name: 'Config' }).click(); await page.getByRole('tab', { name: 'Config' }).click();
await page await page.getByLabel('Plot Series Items').getByLabel('Expand').click();
.getByRole('list', { name: 'Plot Series Properties' })
.locator('span')
.first()
.click();
// TODO: Modify the Overlay Plot to use fixed Scaling // TODO: Modify the Overlay Plot to use fixed Scaling
// TODO: Verify Autoscaling. // TODO: Verify Autoscaling.
@ -215,7 +266,7 @@ test.describe('Generate Visual Test Data @localStorage @generatedata', () => {
page.waitForNavigation(), page.waitForNavigation(),
page.locator('text=OK').click(), page.locator('text=OK').click(),
//Wait for Save Banner to appear //Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message') page.locator('.c-message-banner__message').hover({ trial: true })
]); ]);
// focus the overlay plot // focus the overlay plot
@ -225,7 +276,7 @@ test.describe('Generate Visual Test Data @localStorage @generatedata', () => {
// Clear Recently Viewed // Clear Recently Viewed
await page.getByRole('button', { name: 'Clear Recently Viewed' }).click(); await page.getByRole('button', { name: 'Clear Recently Viewed' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click(); await page.getByRole('button', { name: 'Ok', exact: true }).click();
//Save localStorage for future test execution //Save localStorage for future test execution
await context.storageState({ await context.storageState({
path: fileURLToPath( path: fileURLToPath(
@ -235,6 +286,55 @@ test.describe('Generate Visual Test Data @localStorage @generatedata', () => {
}); });
}); });
test.describe('Generate Conditional Styling Data @localStorage @generatedata', () => {
test('Generate basic condition set', async ({ page, context }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create a Condition Set
const conditionSet = await createDomainObjectWithDefaults(page, {
type: 'Condition Set',
name: 'Test Condition Set'
});
// Create a Telemetry Object (Sine Wave Generator)
const swg = await createExampleTelemetryObject(page, conditionSet.uuid);
// Edit the Telemetry Object to have a 10hz data rate (Gotta go fast!)
await page.goto(swg.url);
await page.getByLabel('More actions').click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page.getByLabel('Period', { exact: true }).fill('5');
await page.getByLabel('Save').click();
// Edit the Condition Set
await page.goto(conditionSet.url);
await page.getByLabel('Edit Object').click();
// Add a Condition to the Condition Set
await page.getByLabel('Add Condition').click();
await page.getByLabel('Condition Name Input').first().fill('Test Condition');
await page.getByLabel('Condition Output Type').first().selectOption('String');
await page.getByLabel('Condition Output String').first().fill('Test Condition Met');
// Condition: True if sine value > 0 (half the time)
await page.getByLabel('Criterion Telemetry Selection').selectOption(swg.name);
await page.getByLabel('Criterion Metadata Selection').selectOption('Sine');
await page.getByLabel('Criterion Comparison Selection').selectOption('is greater than');
await page.getByLabel('Criterion Input').first().fill('0');
// Rename default condition
await page.getByLabel('Condition Output String').nth(1).fill('Test Condition Unmet');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Save localStorage for future test execution
await context.storageState({
path: fileURLToPath(
new URL('../../../e2e/test-data/condition_set_storage.json', import.meta.url)
)
});
});
});
test.describe('Validate Overlay Plot with Telemetry Object @localStorage @generatedata', () => { test.describe('Validate Overlay Plot with Telemetry Object @localStorage @generatedata', () => {
test.use({ test.use({
storageState: fileURLToPath( storageState: fileURLToPath(
@ -248,11 +348,7 @@ test.describe('Validate Overlay Plot with Telemetry Object @localStorage @genera
// TODO: Flesh Out Assertions against created Objects // TODO: Flesh Out Assertions against created Objects
await expect(page.locator('.l-browse-bar__object-name')).toContainText(overlayPlotName); await expect(page.locator('.l-browse-bar__object-name')).toContainText(overlayPlotName);
await page.getByRole('tab', { name: 'Config' }).click(); await page.getByRole('tab', { name: 'Config' }).click();
await page await page.getByLabel('Plot Series Items').getByLabel('Expand').click();
.getByRole('list', { name: 'Plot Series Properties' })
.locator('span')
.first()
.click();
// TODO: Modify the Overlay Plot to use fixed Scaling // TODO: Modify the Overlay Plot to use fixed Scaling
// TODO: Verify Autoscaling. // TODO: Verify Autoscaling.
@ -293,11 +389,7 @@ test.describe('Validate Overlay Plot with 5s Delay Telemetry Object @localStorag
// TODO: Flesh Out Assertions against created Objects // TODO: Flesh Out Assertions against created Objects
await expect(page.locator('.l-browse-bar__object-name')).toContainText(plotName); await expect(page.locator('.l-browse-bar__object-name')).toContainText(plotName);
await page.getByRole('tab', { name: 'Config' }).click(); await page.getByRole('tab', { name: 'Config' }).click();
await page await page.getByLabel('Plot Series Items').getByLabel('Expand').click();
.getByRole('list', { name: 'Plot Series Properties' })
.locator('span')
.first()
.click();
// TODO: Modify the Overlay Plot to use fixed Scaling // TODO: Modify the Overlay Plot to use fixed Scaling
// TODO: Verify Autoscaling. // TODO: Verify Autoscaling.

View File

@ -31,7 +31,7 @@ import { test } from '../../pluginFixtures.js';
test.describe.skip('pluginFixtures tests', () => { test.describe.skip('pluginFixtures tests', () => {
// test.use({ domainObjectName: 'Timer' }); // test.use({ domainObjectName: 'Timer' });
// let timerUUID; // let timerUUID;
// test('Creates a timer object @framework @unstable', ({ domainObject }) => { // test('Creates a timer object @framework', ({ domainObject }) => {
// const { uuid } = domainObject; // const { uuid } = domainObject;
// const uuidRegexp = /[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/; // const uuidRegexp = /[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/;
// expect(uuid).toMatch(uuidRegexp); // expect(uuid).toMatch(uuidRegexp);

View File

@ -59,6 +59,6 @@ test.describe('Clear Data Action', () => {
// Verify that the background image is no longer visible // Verify that the background image is no longer visible
await expect(page.locator(backgroundImageSelector)).toBeHidden(); await expect(page.locator(backgroundImageSelector)).toBeHidden();
expect(await page.locator('.c-thumb__image').count()).toBe(0); await expect(page.locator('.c-thumb__image')).toHaveCount(0);
}); });
}); });

View File

@ -41,7 +41,7 @@ test.describe('CouchDB Status Indicator with mocked responses @couchdb', () => {
//Go to baseURL //Go to baseURL
await page.goto('./#/browse/mine?hideTree=true&hideInspector=true', { await page.goto('./#/browse/mine?hideTree=true&hideInspector=true', {
waitUntil: 'networkidle' waitUntil: 'domcontentloaded'
}); });
await expect(page.locator('div:has-text("CouchDB is connected")').nth(3)).toBeVisible(); await expect(page.locator('div:has-text("CouchDB is connected")').nth(3)).toBeVisible();
}); });
@ -56,7 +56,7 @@ test.describe('CouchDB Status Indicator with mocked responses @couchdb', () => {
//Go to baseURL //Go to baseURL
await page.goto('./#/browse/mine?hideTree=true&hideInspector=true', { await page.goto('./#/browse/mine?hideTree=true&hideInspector=true', {
waitUntil: 'networkidle' waitUntil: 'domcontentloaded'
}); });
await expect(page.locator('div:has-text("CouchDB is offline")').nth(3)).toBeVisible(); await expect(page.locator('div:has-text("CouchDB is offline")').nth(3)).toBeVisible();
}); });
@ -71,7 +71,7 @@ test.describe('CouchDB Status Indicator with mocked responses @couchdb', () => {
//Go to baseURL //Go to baseURL
await page.goto('./#/browse/mine?hideTree=true&hideInspector=true', { await page.goto('./#/browse/mine?hideTree=true&hideInspector=true', {
waitUntil: 'networkidle' waitUntil: 'domcontentloaded'
}); });
await expect(page.locator('div:has-text("CouchDB connectivity unknown")').nth(3)).toBeVisible(); await expect(page.locator('div:has-text("CouchDB connectivity unknown")').nth(3)).toBeVisible();
}); });

View File

@ -0,0 +1,67 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { createDomainObjectWithDefaults, setRealTimeMode } from '../../../appActions.js';
import { MISSION_TIME } from '../../../constants.js';
import { expect, test } from '../../../pluginFixtures.js';
const TELEMETRY_RATE = 2500;
test.describe('Example Event Generator Acknowledge with Controlled Clock @clock', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
await setRealTimeMode(page);
await createDomainObjectWithDefaults(page, {
type: 'Event Message Generator with Acknowledge'
});
});
test('Rows are updatable in place', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7938'
});
await test.step('First telemetry datum gets added as new row', async () => {
await page.clock.fastForward(TELEMETRY_RATE);
const rows = page.getByLabel('table content').getByLabel('Table Row');
const acknowledgeCell = rows.first().getByLabel('acknowledge table cell');
await expect(rows).toHaveCount(1);
await expect(acknowledgeCell).not.toHaveAttribute('title', 'OK');
});
await test.step('Incoming Telemetry datum matching an existing rows in place update key has data merged to existing row', async () => {
await page.clock.fastForward(TELEMETRY_RATE * 2);
const rows = page.getByLabel('table content').getByLabel('Table Row');
const acknowledgeCell = rows.first().getByLabel('acknowledge table cell');
await expect(rows).toHaveCount(1);
await expect(acknowledgeCell).toHaveAttribute('title', 'OK');
});
});
});

View File

@ -41,7 +41,7 @@ test.describe('Sine Wave Generator', () => {
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
// Click Sine Wave Generator // Click Sine Wave Generator
await page.click('text=Sine Wave Generator'); await page.getByRole('menuitem', { name: 'Sine Wave Generator' }).click();
// Verify that the each required field has required indicator // Verify that the each required field has required indicator
// Title // Title
@ -107,11 +107,11 @@ test.describe('Sine Wave Generator', () => {
await page.locator('.field.control.l-input-sm input').first().press('ArrowUp'); await page.locator('.field.control.l-input-sm input').first().press('ArrowUp');
await page.locator('.field.control.l-input-sm input').first().press('ArrowUp'); await page.locator('.field.control.l-input-sm input').first().press('ArrowUp');
const value = await page.locator('.field.control.l-input-sm input').first().inputValue(); const value = page.locator('.field.control.l-input-sm input').first();
await expect(value).toBe('6'); await expect(value).toHaveValue('6');
//Click text=OK //Click save button
await Promise.all([page.waitForNavigation(), page.click('button:has-text("OK")')]); await page.getByLabel('Save').click();
// Verify that the Sine Wave Generator is displayed and correct // Verify that the Sine Wave Generator is displayed and correct
// Verify object properties // Verify object properties

View File

@ -31,8 +31,8 @@ import { createDomainObjectWithDefaults } from '../../appActions.js';
import { expect, test } from '../../pluginFixtures.js'; import { expect, test } from '../../pluginFixtures.js';
const TEST_FOLDER = 'test folder'; const TEST_FOLDER = 'test folder';
const jsonFilePath = 'e2e/test-data/ExampleLayouts.json'; const jsonFilePath = 'test-data/ExampleLayouts.json';
const imageFilePath = 'e2e/test-data/rick.jpg'; const imageFilePath = 'test-data/rick.jpg';
test.describe('Form Validation Behavior', () => { test.describe('Form Validation Behavior', () => {
test('Required Field indicators appear if title is empty and can be corrected', async ({ test('Required Field indicators appear if title is empty and can be corrected', async ({
@ -45,25 +45,23 @@ test.describe('Form Validation Behavior', () => {
await page.getByRole('menuitem', { name: 'Folder' }).click(); await page.getByRole('menuitem', { name: 'Folder' }).click();
// Fill in empty string into title and trigger validation with 'Tab' // Fill in empty string into title and trigger validation with 'Tab'
await page.click('text=Properties Title Notes >> input[type="text"]'); await page.getByLabel('Title', { exact: true }).fill('');
await page.fill('text=Properties Title Notes >> input[type="text"]', ''); await page.getByLabel('Title', { exact: true }).press('Tab');
await page.press('text=Properties Title Notes >> input[type="text"]', 'Tab');
//Required Field Form Validation //Required Field Form Validation
await expect(page.locator('button:has-text("OK")')).toBeDisabled(); await expect(page.getByLabel('Save')).toBeDisabled();
await expect(page.locator('.c-form-row__state-indicator').first()).toHaveClass(/invalid/); await expect(page.locator('.c-form-row__state-indicator').first()).toHaveClass(/invalid/);
//Correct Form Validation for missing title and trigger validation with 'Tab' //Correct Form Validation for missing title and trigger validation with 'Tab'
await page.click('text=Properties Title Notes >> input[type="text"]'); await page.getByLabel('Title', { exact: true }).fill(TEST_FOLDER);
await page.fill('text=Properties Title Notes >> input[type="text"]', TEST_FOLDER); await page.getByLabel('Title', { exact: true }).press('Tab');
await page.press('text=Properties Title Notes >> input[type="text"]', 'Tab');
//Required Field Form Validation is corrected //Required Field Form Validation is corrected
await expect(page.locator('button:has-text("OK")')).toBeEnabled(); await expect(page.getByLabel('Save')).toBeEnabled();
await expect(page.locator('.c-form-row__state-indicator').first()).not.toHaveClass(/invalid/); await expect(page.locator('.c-form-row__state-indicator').first()).not.toHaveClass(/invalid/);
//Finish Creating Domain Object //Finish Creating Domain Object
await Promise.all([page.waitForNavigation(), page.click('button:has-text("OK")')]); await page.getByLabel('Save').click();
//Verify that the Domain Object has been created with the corrected title property //Verify that the Domain Object has been created with the corrected title property
await expect(page.locator('.l-browse-bar__object-name')).toContainText(TEST_FOLDER); await expect(page.locator('.l-browse-bar__object-name')).toContainText(TEST_FOLDER);
@ -87,8 +85,8 @@ test.describe('Form File Input Behavior', () => {
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
const type = await page.locator('#file-input-type').textContent(); const type = page.locator('#file-input-type');
await expect(type).toBe(`"string"`); await expect(type).toHaveText(`"string"`);
}); });
test('Can select an image file type', async ({ page }) => { test('Can select an image file type', async ({ page }) => {
@ -101,8 +99,8 @@ test.describe('Form File Input Behavior', () => {
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
const type = await page.locator('#file-input-type').textContent(); const type = page.locator('#file-input-type');
await expect(type).toBe(`"object"`); await expect(type).toHaveText(`"object"`);
}); });
}); });
@ -123,16 +121,16 @@ test.describe('Persistence operations @addInit', () => {
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.click('text=Condition Set'); await page.getByRole('menuitem', { name: 'Condition Set' }).click();
await page.locator('form[name="mctForm"] >> text=Persistence Testing').click(); await page.locator('form[name="mctForm"] >> text=Persistence Testing').click();
const okButton = page.locator('button:has-text("OK")'); const okButton = page.getByLabel('Save');
await expect(okButton).toBeDisabled(); await expect(okButton).toBeDisabled();
}); });
}); });
test.describe('Persistence operations @couchdb', () => { test.describe('Persistence operations @couchdb @network', () => {
test.use({ failOnConsoleError: false }); test.use({ failOnConsoleError: false });
test('Editing object properties should generate a single persistence operation', async ({ test('Editing object properties should generate a single persistence operation', async ({
page page
@ -158,14 +156,12 @@ test.describe('Persistence operations @couchdb', () => {
}); });
// Open the edit form for the clock object // Open the edit form for the clock object
await page.click('button[title="More actions"]'); await page.getByLabel('More actions').click();
await page.click('li[title="Edit properties of this object."]'); await page.getByLabel('Edit Properties...').click();
// Modify the display format from default 12hr -> 24hr and click 'Save' // Modify the display format from default 12hr -> 24hr and click 'Save'
await page await page.getByLabel('12 or 24 hour clock').selectOption({ value: 'clock24' });
.locator('select[aria-label="12 or 24 hour clock"]') await page.getByLabel('Save').click();
.selectOption({ value: 'clock24' });
await page.click('button[aria-label="Save"]');
await expect await expect
.poll(() => putRequestCount, { .poll(() => putRequestCount, {
@ -174,7 +170,7 @@ test.describe('Persistence operations @couchdb', () => {
}) })
.toEqual(1); .toEqual(1);
}); });
test('Can create an object after a conflict error @couchdb @2p', async ({ test('Can create an object after a conflict error @couchdb @network @2p', async ({
page, page,
openmctConfig openmctConfig
}) => { }) => {
@ -188,8 +184,8 @@ test.describe('Persistence operations @couchdb', () => {
// Both pages: Go to baseURL // Both pages: Go to baseURL
await Promise.all([ await Promise.all([
page.goto('./', { waitUntil: 'networkidle' }), page.goto('./', { waitUntil: 'domcontentloaded' }),
page2.goto('./', { waitUntil: 'networkidle' }) page2.goto('./', { waitUntil: 'domcontentloaded' })
]); ]);
//Slow down the test a bit //Slow down the test a bit
@ -202,14 +198,14 @@ test.describe('Persistence operations @couchdb', () => {
// Both pages: Click the Create button // Both pages: Click the Create button
await Promise.all([ await Promise.all([
page.click('button:has-text("Create")'), page.getByRole('button', { name: 'Create' }).click(),
page2.click('button:has-text("Create")') page2.getByRole('button', { name: 'Create' }).click()
]); ]);
// Both pages: Click "Clock" in the Create menu // Both pages: Click "Clock" in the Create menu
await Promise.all([ await Promise.all([
page.click(`li[role='menuitem']:text("Clock")`), page.getByRole('menuitem', { name: 'Clock' }).click(),
page2.click(`li[role='menuitem']:text("Clock")`) page2.getByRole('menuitem', { name: 'Clock' }).click()
]); ]);
// Generate unique names for both objects // Generate unique names for both objects
@ -236,9 +232,9 @@ test.describe('Persistence operations @couchdb', () => {
// conditions for a conflict error from the first page. // conditions for a conflict error from the first page.
await Promise.all([ await Promise.all([
page2.waitForLoadState(), page2.waitForLoadState(),
page2.click('[aria-label="Save"]'), page2.getByLabel('Save').click(),
// Wait for Save Banner to appear // Wait for Save Banner to appear
page2.waitForSelector('.c-message-banner__message') page2.locator('.c-message-banner__message').hover({ trial: true })
]); ]);
// Close Page 2, we're done with it. // Close Page 2, we're done with it.
@ -249,9 +245,9 @@ test.describe('Persistence operations @couchdb', () => {
// the composition of the parent folder. // the composition of the parent folder.
await Promise.all([ await Promise.all([
page.waitForLoadState(), page.waitForLoadState(),
page.click('[aria-label="Save"]'), page.getByLabel('Save').click(),
// Wait for Save Banner to appear // Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message') page.locator('.c-message-banner__message').hover({ trial: true })
]); ]);
// Page 1: Verify that the conflict has occurred and an error notification is displayed. // Page 1: Verify that the conflict has occurred and an error notification is displayed.

View File

@ -50,13 +50,13 @@ test.describe('Notifications List', () => {
}); });
// Verify that there is a button with aria-label "Review 2 Notifications" // Verify that there is a button with aria-label "Review 2 Notifications"
expect(await page.locator('button[aria-label="Review 2 Notifications"]').count()).toBe(1); await expect(page.locator('button[aria-label="Review 2 Notifications"]')).toHaveCount(1);
// Click on button with aria-label "Review 2 Notifications" // Click on button with aria-label "Review 2 Notifications"
await page.click('button[aria-label="Review 2 Notifications"]'); await page.getByLabel('Review 2 Notifications').click();
// Click on button with aria-label="Dismiss notification of Error message" // Click on button with aria-label="Dismiss notification of Error message"
await page.click('button[aria-label="Dismiss notification of Error message"]'); await page.getByLabel('Dismiss notification of Error message').click();
// Verify there is no a notification (listitem) with the text "Error message" since it was dismissed // Verify there is no a notification (listitem) with the text "Error message" since it was dismissed
expect(await page.locator('div[role="dialog"] div[role="listitem"]').innerText()).not.toContain( expect(await page.locator('div[role="dialog"] div[role="listitem"]').innerText()).not.toContain(
@ -69,10 +69,10 @@ test.describe('Notifications List', () => {
); );
// Click on button with aria-label="Dismiss notification of Alert message" // Click on button with aria-label="Dismiss notification of Alert message"
await page.click('button[aria-label="Dismiss notification of Alert message"]'); await page.getByLabel('Dismiss notification of Alert message').click();
// Verify that there is no dialog since the notification overlay was closed automatically after all notifications were dismissed // Verify that there is no dialog since the notification overlay was closed automatically after all notifications were dismissed
expect(await page.locator('div[role="dialog"]').count()).toBe(0); await expect(page.locator('div[role="dialog"]')).toHaveCount(0);
}); });
}); });

View File

@ -21,7 +21,6 @@
*****************************************************************************/ *****************************************************************************/
import fs from 'fs'; import fs from 'fs';
import { getPreciseDuration } from '../../../../src/utils/duration.js';
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../../appActions.js'; import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../../appActions.js';
import { import {
assertPlanActivities, assertPlanActivities,
@ -132,3 +131,58 @@ test.describe('Gantt Chart', () => {
); );
}); });
}); });
const ONE_SECOND = 1000;
const ONE_MINUTE = 60 * ONE_SECOND;
const ONE_HOUR = ONE_MINUTE * 60;
const ONE_DAY = ONE_HOUR * 24;
function normalizeAge(num) {
const hundredtized = num * 100;
const isWhole = hundredtized % 100 === 0;
return isWhole ? hundredtized / 100 : num;
}
function padLeadingZeros(num, numOfLeadingZeros) {
return num.toString().padStart(numOfLeadingZeros, '0');
}
function toDoubleDigits(num) {
return padLeadingZeros(num, 2);
}
function toTripleDigits(num) {
return padLeadingZeros(num, 3);
}
function getPreciseDuration(value, { excludeMilliSeconds, useDayFormat } = {}) {
let preciseDuration;
const ms = value || 0;
const duration = [
Math.floor(normalizeAge(ms / ONE_DAY)),
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_DAY) / ONE_HOUR))),
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_HOUR) / ONE_MINUTE))),
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_MINUTE) / ONE_SECOND)))
];
if (!excludeMilliSeconds) {
duration.push(toTripleDigits(Math.floor(normalizeAge(ms % ONE_SECOND))));
}
if (useDayFormat) {
// Format days as XD
const days = duration.shift();
if (days > 0) {
preciseDuration = `${days}D ${duration.join(':')}`;
} else {
preciseDuration = duration.join(':');
}
} else {
const days = toDoubleDigits(duration.shift());
duration.unshift(days);
preciseDuration = duration.join(':');
}
return preciseDuration;
}

View File

@ -21,7 +21,7 @@
*****************************************************************************/ *****************************************************************************/
import fs from 'fs'; import fs from 'fs';
import { createPlanFromJSON } from '../../../appActions.js'; import { createPlanFromJSON, navigateToObjectWithFixedTimeBounds } from '../../../appActions.js';
import { import {
addPlanGetInterceptor, addPlanGetInterceptor,
assertPlanActivities, assertPlanActivities,
@ -81,9 +81,7 @@ test.describe('Plan', () => {
} }
// Switch to fixed time mode with all plan events within the bounds // Switch to fixed time mode with all plan events within the bounds
await page.goto( await navigateToObjectWithFixedTimeBounds(page, plan.url, startBound, endBound);
`${plan.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=plan.view`
);
// select the first activity in the list // select the first activity in the list
await page.getByText('Past event 1').click(); await page.getByText('Past event 1').click();

View File

@ -21,30 +21,18 @@
*****************************************************************************/ *****************************************************************************/
import fs from 'fs'; import fs from 'fs';
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../../appActions.js'; import {
import { getEarliestStartTime } from '../../../helper/planningUtils'; createDomainObjectWithDefaults,
createPlanFromJSON,
navigateToObjectWithFixedTimeBounds
} from '../../../appActions.js';
import { expect, test } from '../../../pluginFixtures.js'; import { expect, test } from '../../../pluginFixtures.js';
const examplePlanSmall3 = JSON.parse(
fs.readFileSync(
new URL('../../../test-data/examplePlans/ExamplePlan_Small3.json', import.meta.url)
)
);
const examplePlanSmall1 = JSON.parse( const examplePlanSmall1 = JSON.parse(
fs.readFileSync( fs.readFileSync(
new URL('../../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url) new URL('../../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url)
) )
); );
// eslint-disable-next-line no-unused-vars
const START_TIME_COLUMN = 0;
// eslint-disable-next-line no-unused-vars
const END_TIME_COLUMN = 1;
const TIME_TO_FROM_COLUMN = 2;
// eslint-disable-next-line no-unused-vars
const ACTIVITY_COLUMN = 3;
const HEADER_ROW = 0;
const NUM_COLUMNS = 5;
test.describe('Time List', () => { test.describe('Time List', () => {
test("Create a Time List, add a single Plan to it, verify all the activities are displayed with no milliseconds and selecting an activity shows it's properties", async ({ test("Create a Time List, add a single Plan to it, verify all the activities are displayed with no milliseconds and selecting an activity shows it's properties", async ({
page page
@ -75,14 +63,12 @@ test.describe('Time List', () => {
const endBound = lastActivity.end; const endBound = lastActivity.end;
// Switch to fixed time mode with all plan events within the bounds // Switch to fixed time mode with all plan events within the bounds
await page.goto( await navigateToObjectWithFixedTimeBounds(page, timelist.url, startBound, endBound);
`${timelist.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=timelist.view`
);
// Verify all events are displayed // Verify all events are displayed
const eventCount = await page.getByRole('row').count(); const eventCount = await page.getByRole('row').count();
// subtracting one for the header // subtracting one for the header
await expect(eventCount - 1).toEqual(firstGroupItems.length); expect(eventCount - 1).toEqual(firstGroupItems.length);
}); });
await test.step('Does not show milliseconds in times', async () => { await test.step('Does not show milliseconds in times', async () => {
@ -140,9 +126,7 @@ test("View a timelist in expanded view, verify all the activities are displayed
const endBound = lastActivity.end; const endBound = lastActivity.end;
// Switch to fixed time mode with all plan events within the bounds // Switch to fixed time mode with all plan events within the bounds
await page.goto( await navigateToObjectWithFixedTimeBounds(page, timelist.url, startBound, endBound);
`${timelist.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=timelist.view`
);
// Change the object to edit mode // Change the object to edit mode
await page.getByRole('button', { name: 'Edit Object' }).click(); await page.getByRole('button', { name: 'Edit Object' }).click();
@ -158,10 +142,10 @@ test("View a timelist in expanded view, verify all the activities are displayed
// Verify all events are displayed // Verify all events are displayed
const eventCount = await page.getByRole('row').count(); const eventCount = await page.getByRole('row').count();
await expect(eventCount).toEqual(firstGroupItems.length); expect(eventCount).toEqual(firstGroupItems.length);
}); });
await test.step('Shows activity properties when a row is selected', async () => { await test.step('Shows activity properties when a row is selected in the expanded view', async () => {
await page.getByRole('row').nth(2).click(); await page.getByRole('row').nth(2).click();
// Find the activity state section in the inspector // Find the activity state section in the inspector
@ -171,167 +155,10 @@ test("View a timelist in expanded view, verify all the activities are displayed
'Not started' 'Not started'
); );
}); });
});
/** await test.step("Verify absence of progress indication for an activity that's not in progress", async () => {
* The regular expression used to parse the countdown string. // When an activity is not in progress, the progress pie is not visible
* Some examples of valid Countdown strings: const hidden = page.getByRole('row').locator('path').nth(1);
* ``` await expect(hidden).toBeHidden();
* '35D 02:03:04'
* '-1D 01:02:03'
* '01:02:03'
* '-05:06:07'
* ```
*/
const COUNTDOWN_REGEXP = /(-)?(\d+D\s)?(\d{2}):(\d{2}):(\d{2})/;
/**
* @typedef {Object} CountdownOrUpObject
* @property {string} sign - The sign of the countdown ('-' if the countdown is negative, '+' otherwise).
* @property {string} days - The number of days in the countdown (undefined if there are no days).
* @property {string} hours - The number of hours in the countdown.
* @property {string} minutes - The number of minutes in the countdown.
* @property {string} seconds - The number of seconds in the countdown.
* @property {string} toString - The countdown string.
*/
/**
* Object representing the indices of the capture groups in a countdown regex match.
*
* @typedef {{ SIGN: number, DAYS: number, HOURS: number, MINUTES: number, SECONDS: number, REGEXP: RegExp }}
* @property {number} SIGN - The index for the sign capture group (1 if a '-' sign is present, otherwise undefined).
* @property {number} DAYS - The index for the days capture group (2 for the number of days, otherwise undefined).
* @property {number} HOURS - The index for the hours capture group (3 for the hour part of the time).
* @property {number} MINUTES - The index for the minutes capture group (4 for the minute part of the time).
* @property {number} SECONDS - The index for the seconds capture group (5 for the second part of the time).
*/
const COUNTDOWN = Object.freeze({
SIGN: 1,
DAYS: 2,
HOURS: 3,
MINUTES: 4,
SECONDS: 5
});
test.describe('Time List with controlled clock', () => {
test.use({
clockOptions: {
now: getEarliestStartTime(examplePlanSmall3),
shouldAdvanceTime: true
}
});
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
});
test('Time List shows current events and counts down correctly in real-time mode', async ({
page
}) => {
await test.step('Create a Time List, add a Plan to it, and switch to real-time mode', async () => {
// Create Time List
const timelist = await createDomainObjectWithDefaults(page, {
type: 'Time List'
});
// Create a Plan with events that count down and up.
// Add it as a child to the Time List.
await createPlanFromJSON(page, {
json: examplePlanSmall3,
parent: timelist.uuid
});
// Navigate to the Time List in real-time mode
await page.goto(
`${timelist.url}?tc.mode=local&tc.startDelta=900000&tc.endDelta=1800000&tc.timeSystem=utc&view=grid`
);
});
const countUpCells = [
getCellByIndex(page, 1, TIME_TO_FROM_COLUMN),
getCellByIndex(page, 2, TIME_TO_FROM_COLUMN)
];
const countdownCells = [
getCellByIndex(page, 3, TIME_TO_FROM_COLUMN),
getCellByIndex(page, 4, TIME_TO_FROM_COLUMN)
];
// Verify that the countdown cells are counting down
for (let i = 0; i < countdownCells.length; i++) {
await test.step(`Countdown cell ${i + 1} counts down`, async () => {
const countdownCell = countdownCells[i];
// Get the initial countdown timestamp object
const beforeCountdown = await getAndAssertCountdownOrUpObject(page, i + 3);
// should not have a '-' sign
await expect(countdownCell).not.toHaveText('-');
// Wait until it changes
await expect(countdownCell).not.toHaveText(beforeCountdown.toString());
// Get the new countdown timestamp object
const afterCountdown = await getAndAssertCountdownOrUpObject(page, i + 3);
// Verify that the new countdown timestamp object is less than the old one
expect(Number(afterCountdown.seconds)).toBeLessThan(Number(beforeCountdown.seconds));
});
}
// Verify that the count-up cells are counting up
for (let i = 0; i < countUpCells.length; i++) {
await test.step(`Count-up cell ${i + 1} counts up`, async () => {
const countUpCell = countUpCells[i];
// Get the initial count-up timestamp object
const beforeCountUp = await getAndAssertCountdownOrUpObject(page, i + 1);
// should not have a '+' sign
await expect(countUpCell).not.toHaveText('+');
// Wait until it changes
await expect(countUpCell).not.toHaveText(beforeCountUp.toString());
// Get the new count-up timestamp object
const afterCountUp = await getAndAssertCountdownOrUpObject(page, i + 1);
// Verify that the new count-up timestamp object is greater than the old one
expect(Number(afterCountUp.seconds)).toBeGreaterThan(Number(beforeCountUp.seconds));
});
}
}); });
}); });
/**
* Get the cell at the given row and column indices.
* @param {import('@playwright/test').Page} page
* @param {number} rowIndex
* @param {number} columnIndex
* @returns {import('@playwright/test').Locator} cell
*/
function getCellByIndex(page, rowIndex, columnIndex) {
return page.getByRole('cell').nth(rowIndex * NUM_COLUMNS + columnIndex);
}
/**
* Return the innerText of the cell at the given row and column indices.
* @param {import('@playwright/test').Page} page
* @param {number} rowIndex
* @param {number} columnIndex
* @returns {Promise<string>} text
*/
async function getCellTextByIndex(page, rowIndex, columnIndex) {
const text = await getCellByIndex(page, rowIndex, columnIndex).innerText();
return text;
}
/**
* Get the text from the countdown (or countup) cell in the given row, assert that it matches the countdown/countup
* regex, and return an object representing the countdown.
* @param {import('@playwright/test').Page} page
* @param {number} rowIndex the row index
* @returns {Promise<CountdownOrUpObject>} The countdown (or countup) object
*/
async function getAndAssertCountdownOrUpObject(page, rowIndex) {
const timeToFrom = await getCellTextByIndex(page, HEADER_ROW + rowIndex, TIME_TO_FROM_COLUMN);
expect(timeToFrom).toMatch(COUNTDOWN_REGEXP);
const match = timeToFrom.match(COUNTDOWN_REGEXP);
return {
sign: match[COUNTDOWN.SIGN],
days: match[COUNTDOWN.DAYS],
hours: match[COUNTDOWN.HOURS],
minutes: match[COUNTDOWN.MINUTES],
seconds: match[COUNTDOWN.SECONDS],
toString: () => timeToFrom
};
}

View File

@ -0,0 +1,272 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*
Collection of Time List tests set to run with browser clock manipulate made possible with the
page.clock() API.
*/
import fs from 'fs';
import {
createDomainObjectWithDefaults,
createPlanFromJSON,
navigateToObjectWithRealTime
} from '../../../appActions.js';
import {
createTimelistWithPlanAndSetActivityInProgress,
getEarliestStartTime,
getFirstActivity
} from '../../../helper/planningUtils';
import { expect, test } from '../../../pluginFixtures.js';
const examplePlanSmall3 = JSON.parse(
fs.readFileSync(
new URL('../../../test-data/examplePlans/ExamplePlan_Small3.json', import.meta.url)
)
);
const examplePlanSmall1 = JSON.parse(
fs.readFileSync(
new URL('../../../test-data/examplePlans/ExamplePlan_Small1.json', import.meta.url)
)
);
const TIME_TO_FROM_COLUMN = 2;
const HEADER_ROW = 0;
const NUM_COLUMNS = 5;
const FULL_CIRCLE_PATH =
'M3.061616997868383e-15,-50A50,50,0,1,1,-3.061616997868383e-15,50A50,50,0,1,1,3.061616997868383e-15,-50Z';
/**
* The regular expression used to parse the countdown string.
* Some examples of valid Countdown strings:
* ```
* '35D 02:03:04'
* '-1D 01:02:03'
* '01:02:03'
* '-05:06:07'
* ```
*/
const COUNTDOWN_REGEXP = /(-)?(\d+D\s)?(\d{2}):(\d{2}):(\d{2})/;
/**
* @typedef {Object} CountdownOrUpObject
* @property {string} sign - The sign of the countdown ('-' if the countdown is negative, '+' otherwise).
* @property {string} days - The number of days in the countdown (undefined if there are no days).
* @property {string} hours - The number of hours in the countdown.
* @property {string} minutes - The number of minutes in the countdown.
* @property {string} seconds - The number of seconds in the countdown.
* @property {string} toString - The countdown string.
*/
/**
* Object representing the indices of the capture groups in a countdown regex match.
*
* @typedef {{ SIGN: number, DAYS: number, HOURS: number, MINUTES: number, SECONDS: number, REGEXP: RegExp }}
* @property {number} SIGN - The index for the sign capture group (1 if a '-' sign is present, otherwise undefined).
* @property {number} DAYS - The index for the days capture group (2 for the number of days, otherwise undefined).
* @property {number} HOURS - The index for the hours capture group (3 for the hour part of the time).
* @property {number} MINUTES - The index for the minutes capture group (4 for the minute part of the time).
* @property {number} SECONDS - The index for the seconds capture group (5 for the second part of the time).
*/
const COUNTDOWN = Object.freeze({
SIGN: 1,
DAYS: 2,
HOURS: 3,
MINUTES: 4,
SECONDS: 5
});
const FIRST_ACTIVITY_SMALL_1 = getFirstActivity(examplePlanSmall1);
test.describe('Time List with controlled clock @clock', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: getEarliestStartTime(examplePlanSmall3) });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create Time List
const timelist = await createDomainObjectWithDefaults(page, {
type: 'Time List'
});
// Create a Plan with events that count down and up.
// Add it as a child to the Time List.
await createPlanFromJSON(page, {
json: examplePlanSmall3,
parent: timelist.uuid
});
// Navigate to the Time List in real-time mode
await navigateToObjectWithRealTime(page, timelist.url, 900000, 1800000);
//Expand the viewport to show the entire time list
await page.getByLabel('Collapse Inspect Pane').click();
await page.getByLabel('Collapse Browse Pane').click();
});
test('Time List shows current events and counts down correctly in real-time mode', async ({
page
}) => {
const countUpCells = [
getTimeListCellByIndex(page, 1, TIME_TO_FROM_COLUMN),
getTimeListCellByIndex(page, 2, TIME_TO_FROM_COLUMN)
];
const countdownCells = [
getTimeListCellByIndex(page, 3, TIME_TO_FROM_COLUMN),
getTimeListCellByIndex(page, 4, TIME_TO_FROM_COLUMN)
];
// Verify that the countdown cells are counting down
for (let i = 0; i < countdownCells.length; i++) {
await test.step(`Countdown cell ${i + 1} counts down`, async () => {
const countdownCell = countdownCells[i];
// Get the initial countdown timestamp object
const beforeCountdown = await getAndAssertCountdownOrUpObject(page, i + 3);
// should not have a '-' sign
await expect(countdownCell).not.toHaveText('-');
// Wait until it changes
await expect(countdownCell).not.toHaveText(beforeCountdown.toString());
// Get the new countdown timestamp object
const afterCountdown = await getAndAssertCountdownOrUpObject(page, i + 3);
// Verify that the new countdown timestamp object is less than the old one
expect(Number(afterCountdown.seconds)).toBeLessThan(Number(beforeCountdown.seconds));
});
}
// Verify that the count-up cells are counting up
for (let i = 0; i < countUpCells.length; i++) {
await test.step(`Count-up cell ${i + 1} counts up`, async () => {
const countUpCell = countUpCells[i];
// Get the initial count-up timestamp object
const beforeCountUp = await getAndAssertCountdownOrUpObject(page, i + 1);
// should not have a '+' sign
await expect(countUpCell).not.toHaveText('+');
// Wait until it changes
await expect(countUpCell).not.toHaveText(beforeCountUp.toString());
// Get the new count-up timestamp object
const afterCountUp = await getAndAssertCountdownOrUpObject(page, i + 1);
// Verify that the new count-up timestamp object is greater than the old one
expect(Number(afterCountUp.seconds)).toBeGreaterThan(Number(beforeCountUp.seconds));
});
}
});
});
test.describe('Activity progress when activity is in the future @clock', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_1.start - 1 });
await page.clock.resume();
await createTimelistWithPlanAndSetActivityInProgress(page, examplePlanSmall1);
});
test('progress pie is empty', async ({ page }) => {
const anActivity = page.getByRole('row').nth(0);
// Progress pie shows no progress when now is less than the start time
await expect(anActivity.getByLabel('Activity in progress').locator('path')).not.toHaveAttribute(
'd'
);
});
});
test.describe('Activity progress when now is between start and end of the activity @clock', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_1.start + 50000 });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
await createTimelistWithPlanAndSetActivityInProgress(page, examplePlanSmall1);
});
test('progress pie is partially filled', async ({ page }) => {
const anActivity = page.getByRole('row').nth(0);
const pathElement = anActivity.getByLabel('Activity in progress').locator('path');
// Progress pie shows progress when now is greater than the start time
await expect(pathElement).toHaveAttribute('d');
});
});
test.describe('Activity progress when now is after end of the activity @clock', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_1.end + 10000 });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
await createTimelistWithPlanAndSetActivityInProgress(page, examplePlanSmall1);
});
test('progress pie is full', async ({ page }) => {
const anActivity = page.getByRole('row').nth(0);
// Progress pie is completely full and doesn't update if now is greater than the end time
await expect(anActivity.getByLabel('Activity in progress').locator('path')).toHaveAttribute(
'd',
FULL_CIRCLE_PATH
);
});
});
/**
* Get the cell at the given row and column indices.
* @param {import('@playwright/test').Page} page
* @param {number} rowIndex
* @param {number} columnIndex
* @returns {import('@playwright/test').Locator} cell
*/
function getTimeListCellByIndex(page, rowIndex, columnIndex) {
return page.getByRole('cell').nth(rowIndex * NUM_COLUMNS + columnIndex);
}
/**
* Return the innerText of the cell at the given row and column indices.
* @param {import('@playwright/test').Page} page
* @param {number} rowIndex
* @param {number} columnIndex
* @returns {Promise<string>} text
*/
async function getTimeListCellTextByIndex(page, rowIndex, columnIndex) {
const text = await getTimeListCellByIndex(page, rowIndex, columnIndex).innerText();
return text;
}
/**
* Get the text from the countdown (or countup) cell in the given row, assert that it matches the countdown/countup
* regex, and return an object representing the countdown.
* @param {import('@playwright/test').Page} page
* @param {number} rowIndex the row index
* @returns {Promise<CountdownOrUpObject>} The countdown (or countup) object
*/
async function getAndAssertCountdownOrUpObject(page, rowIndex) {
const timeToFrom = await getTimeListCellTextByIndex(
page,
HEADER_ROW + rowIndex,
TIME_TO_FROM_COLUMN
);
expect(timeToFrom).toMatch(COUNTDOWN_REGEXP);
const match = timeToFrom.match(COUNTDOWN_REGEXP);
return {
sign: match[COUNTDOWN.SIGN],
days: match[COUNTDOWN.DAYS],
hours: match[COUNTDOWN.HOURS],
minutes: match[COUNTDOWN.MINUTES],
seconds: match[COUNTDOWN.SECONDS],
toString: () => timeToFrom
};
}

View File

@ -23,7 +23,10 @@
import { import {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
createPlanFromJSON, createPlanFromJSON,
setIndependentTimeConductorBounds navigateToObjectWithFixedTimeBounds,
setFixedIndependentTimeConductorBounds,
setFixedTimeMode,
setTimeConductorBounds
} from '../../../appActions.js'; } from '../../../appActions.js';
import { expect, test } from '../../../pluginFixtures.js'; import { expect, test } from '../../../pluginFixtures.js';
@ -73,7 +76,40 @@ const testPlan = {
}; };
test.describe('Time Strip', () => { test.describe('Time Strip', () => {
test('Create two Time Strips, add a single Plan to both, and verify they can have separate Independent Time Contexts @unstable', async ({ let timestrip;
let plan;
test.beforeEach(async ({ page }) => {
// Goto baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
timestrip = await test.step('Create a Time Strip', async () => {
const createdTimeStrip = await createDomainObjectWithDefaults(page, { type: 'Time Strip' });
const objectName = await page.locator('.l-browse-bar__object-name').innerText();
expect(objectName).toBe(createdTimeStrip.name);
return createdTimeStrip;
});
plan = await test.step('Create a Plan and add it to the timestrip', async () => {
const createdPlan = await createPlanFromJSON(page, {
name: 'Test Plan',
json: testPlan
});
await page.goto(timestrip.url);
// Expand the tree to show the plan
await page.getByLabel('Show selected item in tree').click();
await page
.getByLabel(`Navigate to ${createdPlan.name}`)
.dragTo(page.getByLabel('Object View'));
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
return createdPlan;
});
});
test('Create two Time Strips, add a single Plan to both, and verify they can have separate Independent Time Contexts', async ({
page page
}) => { }) => {
test.info().annotations.push({ test.info().annotations.push({
@ -84,42 +120,16 @@ test.describe('Time Strip', () => {
// Constant locators // Constant locators
const activityBounds = page.locator('.activity-bounds'); const activityBounds = page.locator('.activity-bounds');
// Goto baseURL await test.step('Set time strip to fixed timespan mode and verify activities', async () => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
const timestrip = await test.step('Create a Time Strip', async () => {
const createdTimeStrip = await createDomainObjectWithDefaults(page, { type: 'Time Strip' });
const objectName = await page.locator('.l-browse-bar__object-name').innerText();
expect(objectName).toBe(createdTimeStrip.name);
return createdTimeStrip;
});
const plan = await test.step('Create a Plan and add it to the timestrip', async () => {
const createdPlan = await createPlanFromJSON(page, {
name: 'Test Plan',
json: testPlan
});
await page.goto(timestrip.url);
// Expand the tree to show the plan
await page.click("button[title='Show selected item in tree']");
await page.dragAndDrop(`role=treeitem[name=/${createdPlan.name}/]`, '.c-object-view');
await page.click("button[title='Save']");
await page.click("li[title='Save and Finish Editing']");
const startBound = testPlan.TEST_GROUP[0].start; const startBound = testPlan.TEST_GROUP[0].start;
const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end; const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end;
// Switch to fixed time mode with all plan events within the bounds // Switch to fixed time mode with all plan events within the bounds
await page.goto( await navigateToObjectWithFixedTimeBounds(page, timestrip.url, startBound, endBound);
`${timestrip.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=time-strip.view`
);
// Verify all events are displayed // Verify all events are displayed
const eventCount = await page.locator('.activity-bounds').count(); const eventCount = await page.locator('.activity-bounds').count();
expect(eventCount).toEqual(testPlan.TEST_GROUP.length); expect(eventCount).toEqual(testPlan.TEST_GROUP.length);
return createdPlan;
}); });
await test.step('TimeStrip can use the Independent Time Conductor', async () => { await test.step('TimeStrip can use the Independent Time Conductor', async () => {
@ -131,7 +141,10 @@ test.describe('Time Strip', () => {
const startBoundString = new Date(startBound).toISOString().replace('T', ' '); const startBoundString = new Date(startBound).toISOString().replace('T', ' ');
const endBoundString = new Date(endBound).toISOString().replace('T', ' '); const endBoundString = new Date(endBound).toISOString().replace('T', ' ');
await setIndependentTimeConductorBounds(page, startBoundString, endBoundString); await setFixedIndependentTimeConductorBounds(page, {
start: startBoundString,
end: endBoundString
});
expect(await activityBounds.count()).toEqual(1); expect(await activityBounds.count()).toEqual(1);
}); });
@ -146,9 +159,9 @@ test.describe('Time Strip', () => {
expect(objectName).toBe(createdTimeStrip.name); expect(objectName).toBe(createdTimeStrip.name);
// Drag the existing Plan onto the newly created Time Strip, and save. // Drag the existing Plan onto the newly created Time Strip, and save.
await page.dragAndDrop(`role=treeitem[name=/${plan.name}/]`, '.c-object-view'); await page.getByLabel(`Navigate to ${plan.name}`).dragTo(page.getByLabel('Object View'));
await page.click("button[title='Save']"); await page.getByLabel('Save').click();
await page.click("li[title='Save and Finish Editing']"); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// All events should be displayed at this point because the // All events should be displayed at this point because the
// initial independent context bounds will match the global bounds // initial independent context bounds will match the global bounds
@ -160,7 +173,10 @@ test.describe('Time Strip', () => {
const startBoundString = new Date(startBound).toISOString().replace('T', ' '); const startBoundString = new Date(startBound).toISOString().replace('T', ' ');
const endBoundString = new Date(endBound).toISOString().replace('T', ' '); const endBoundString = new Date(endBound).toISOString().replace('T', ' ');
await setIndependentTimeConductorBounds(page, startBoundString, endBoundString); await setFixedIndependentTimeConductorBounds(page, {
start: startBoundString,
end: endBoundString
});
// Verify that two events are displayed // Verify that two events are displayed
expect(await activityBounds.count()).toEqual(2); expect(await activityBounds.count()).toEqual(2);
@ -170,4 +186,48 @@ test.describe('Time Strip', () => {
expect(await activityBounds.count()).toEqual(1); expect(await activityBounds.count()).toEqual(1);
}); });
}); });
test('Time strip now line', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7817'
});
await test.step('Is displayed in realtime mode', async () => {
await expect(page.getByLabel('Now Marker')).toBeVisible();
});
await test.step('Is hidden when out of bounds of the time axis', async () => {
// Switch to fixed timespan mode
await setFixedTimeMode(page);
// Get the end bounds
const endBounds = await page.getByLabel('End bounds').textContent();
// Add 2 minutes to end bound datetime and use it as the new end time
let endTimeStamp = new Date(endBounds);
endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() + 2);
const endDate = endTimeStamp.toISOString().split('T')[0];
const milliseconds = endTimeStamp.getMilliseconds();
const endTime = endTimeStamp.toISOString().split('T')[1].replace(`.${milliseconds}Z`, '');
// Subtract 1 minute from the end bound and use it as the new start time
let startTimeStamp = new Date(endBounds);
startTimeStamp.setUTCMinutes(startTimeStamp.getUTCMinutes() + 1);
const startDate = startTimeStamp.toISOString().split('T')[0];
const startMilliseconds = startTimeStamp.getMilliseconds();
const startTime = startTimeStamp
.toISOString()
.split('T')[1]
.replace(`.${startMilliseconds}Z`, '');
// Set fixed timespan mode to the future so that "now" is out of bounds.
await setTimeConductorBounds(page, {
startDate,
endDate,
startTime,
endTime
});
await expect(page.getByLabel('Now Marker')).toBeHidden();
});
});
}); });

View File

@ -41,11 +41,10 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage @2p', () =>
const context = await browser.newContext(); const context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Create' }).click(); const conditionSet = await createDomainObjectWithDefaults(page, {
type: 'Condition Set',
await page.locator('li[role="menuitem"]:has-text("Condition Set")').click(); name: 'Unnamed Condition Set'
});
await Promise.all([page.waitForNavigation(), page.click('button:has-text("OK")')]);
//Save localStorage for future test execution //Save localStorage for future test execution
await context.storageState({ await context.storageState({
@ -55,7 +54,7 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage @2p', () =>
}); });
//Set object identifier from url //Set object identifier from url
conditionSetUrl = page.url(); conditionSetUrl = conditionSet.url;
await page.close(); await page.close();
}); });
@ -68,44 +67,39 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage @2p', () =>
}); });
//Begin suite of tests again localStorage //Begin suite of tests again localStorage
test.fixme( test('Condition set object properties persist in main view and inspector after reload @localStorage', async ({
'Condition set object properties persist in main view and inspector @localStorage', page
async ({ page }) => { }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7421'
});
//Navigate to baseURL with injected localStorage //Navigate to baseURL with injected localStorage
await page.goto(conditionSetUrl, { waitUntil: 'networkidle' }); await page.goto(conditionSetUrl, { waitUntil: 'domcontentloaded' });
//Assertions on loaded Condition Set in main view. This is a stateful transition step after page.goto() //Assertions on loaded Condition Set in main view. This is a stateful transition step after page.goto()
await expect await expect.soft(page.getByRole('main')).toContainText('Unnamed Condition Set');
.soft(page.locator('.l-browse-bar__object-name'))
.toContainText('Unnamed Condition Set');
//Assertions on loaded Condition Set in Inspector //Assertions on loaded Condition Set in Inspector
expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy(); await expect(
page.getByLabel('Title inspector properties').getByLabel('inspector property value')
).toContainText('Unnamed Condition Set');
//Reload Page //Reload Page
await Promise.all([page.reload(), page.waitForLoadState('networkidle')]); await page.reload({ waitUntil: 'domcontentloaded' });
//Re-verify after reload //Re-verify after reload
await expect await expect(page.getByRole('main')).toContainText('Unnamed Condition Set');
.soft(page.locator('.l-browse-bar__object-name'))
.toContainText('Unnamed Condition Set');
//Assertions on loaded Condition Set in Inspector //Assertions on loaded Condition Set in Inspector
expect.soft(page.locator('_vue=item.name=Unnamed Condition Set')).toBeTruthy(); await expect(
} page.getByLabel('Title inspector properties').getByLabel('inspector property value')
); ).toContainText('Unnamed Condition Set');
});
test('condition set object can be modified on @localStorage', async ({ page, openmctConfig }) => { test('condition set object can be modified on @localStorage', async ({ page, openmctConfig }) => {
const { myItemsFolderName } = openmctConfig; const { myItemsFolderName } = openmctConfig;
await page.goto(conditionSetUrl, { waitUntil: 'networkidle' }); await page.goto(conditionSetUrl, { waitUntil: 'domcontentloaded' });
//Assertions on loaded Condition Set in main view. This is a stateful transition step after page.goto() //Assertions on loaded Condition Set in main view. This is a stateful transition step after page.goto()
await expect await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set');
.soft(page.locator('.l-browse-bar__object-name'))
.toContainText('Unnamed Condition Set');
//Update the Condition Set properties //Update the Condition Set properties
// Click Edit Button // Click Edit Button
@ -151,7 +145,7 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage @2p', () =>
expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy();
//Reload Page //Reload Page
await Promise.all([page.reload(), page.waitForLoadState('networkidle')]); await Promise.all([page.reload(), page.waitForLoadState('domcontentloaded')]);
//Verify Main section reflects updated Name Property //Verify Main section reflects updated Name Property
await expect await expect
@ -213,7 +207,7 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage @2p', () =>
//Feature? //Feature?
//Domain Object is still available by direct URL after delete //Domain Object is still available by direct URL after delete
await page.goto(conditionSetUrl, { waitUntil: 'networkidle' }); await page.goto(conditionSetUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set'); await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set');
}); });
}); });
@ -267,7 +261,7 @@ test.describe('Basic Condition Set Use', () => {
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
// Expand the 'My Items' folder in the left tree // Expand the 'My Items' folder in the left tree
page.click('button[title="Show selected item in tree"]'); await page.getByLabel('Show selected item in tree').click();
// Add the Alpha & Beta Sine Wave Generator to the Condition Set and save changes // Add the Alpha & Beta Sine Wave Generator to the Condition Set and save changes
const treePane = page.getByRole('tree', { const treePane = page.getByRole('tree', {
name: 'Main Tree' name: 'Main Tree'
@ -286,19 +280,29 @@ test.describe('Basic Condition Set Use', () => {
await page.locator('button[title="Save"]').click(); await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
await page.click('button[title="Change the current view"]'); await page.getByLabel('Open the View Switcher Menu').click();
await expect(page.getByRole('menuitem', { name: /Lad Table/ })).toBeHidden(); await expect(page.getByRole('menuitem', { name: /Lad Table/ })).toBeHidden();
await expect(page.getByRole('menuitem', { name: /Conditions View/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /Conditions View/ })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /Plot/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /Plot/ })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /Telemetry Table/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /Telemetry Table/ })).toBeVisible();
await page.getByLabel('Plot').click();
await expect(
page.getByLabel('Plot Legend Collapsed').getByText('Test Condition Set')
).toBeVisible();
await page.getByLabel('Open the View Switcher Menu').click();
await page.getByLabel('Telemetry Table').click();
await expect(page.getByRole('searchbox', { name: 'output filter input' })).toBeVisible();
await page.getByLabel('Open the View Switcher Menu').click();
await page.getByLabel('Conditions View').click();
await expect(page.getByText('Current Output')).toBeVisible();
}); });
test('ConditionSet has correct outputs when telemetry is and is not available', async ({ test('ConditionSet has correct outputs when telemetry is and is not available', async ({
page page
}) => { }) => {
const exampleTelemetry = await createExampleTelemetryObject(page); const exampleTelemetry = await createExampleTelemetryObject(page);
await page.getByTitle('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
await page.goto(conditionSet.url); await page.goto(conditionSet.url);
// Change the object to edit mode // Change the object to edit mode
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
@ -361,7 +365,7 @@ test.describe('Basic Condition Set Use', () => {
// Validate that the condition set is evaluating and outputting // Validate that the condition set is evaluating and outputting
// the correct value when the underlying telemetry subscription is active. // the correct value when the underlying telemetry subscription is active.
let outputValue = page.locator('[aria-label="Current Output Value"]'); let outputValue = page.getByLabel('Current Output Value');
await expect(outputValue).toHaveText('false'); await expect(outputValue).toHaveText('false');
await page.goto(exampleTelemetry.url); await page.goto(exampleTelemetry.url);
@ -378,4 +382,168 @@ test.describe('Basic Condition Set Use', () => {
await page.goto(conditionSet.url); await page.goto(conditionSet.url);
await expect(outputValue).toHaveText('---'); await expect(outputValue).toHaveText('---');
}); });
test('ConditionSet has correct outputs when test data is enabled', async ({ page }) => {
const exampleTelemetry = await createExampleTelemetryObject(page);
await page.getByLabel('Show selected item in tree').click();
await page.goto(conditionSet.url);
// Change the object to edit mode
await page.getByLabel('Edit Object').click();
// Create two conditions
await page.locator('#addCondition').click();
await page.locator('#addCondition').click();
await page.locator('#conditionCollection').getByRole('textbox').nth(0).fill('First Condition');
await page.locator('#conditionCollection').getByRole('textbox').nth(1).fill('Second Condition');
// Add Telemetry to ConditionSet
const sineWaveGeneratorTreeItem = page
.getByRole('tree', {
name: 'Main Tree'
})
.getByRole('treeitem', {
name: exampleTelemetry.name
});
const conditionCollection = page.locator('#conditionCollection');
await sineWaveGeneratorTreeItem.dragTo(conditionCollection);
// Modify First Criterion
const firstCriterionTelemetry = page.locator(
'[aria-label="Criterion Telemetry Selection"] >> nth=0'
);
firstCriterionTelemetry.selectOption({ label: exampleTelemetry.name });
const firstCriterionMetadata = page.locator(
'[aria-label="Criterion Metadata Selection"] >> nth=0'
);
firstCriterionMetadata.selectOption({ label: 'Sine' });
const firstCriterionComparison = page.locator(
'[aria-label="Criterion Comparison Selection"] >> nth=0'
);
firstCriterionComparison.selectOption({ label: 'is greater than or equal to' });
const firstCriterionInput = page.locator('[aria-label="Criterion Input"] >> nth=0');
await firstCriterionInput.fill('0');
// Modify Second Criterion
const secondCriterionTelemetry = page.locator(
'[aria-label="Criterion Telemetry Selection"] >> nth=1'
);
await secondCriterionTelemetry.selectOption({ label: exampleTelemetry.name });
const secondCriterionMetadata = page.locator(
'[aria-label="Criterion Metadata Selection"] >> nth=1'
);
await secondCriterionMetadata.selectOption({ label: 'Sine' });
const secondCriterionComparison = page.locator(
'[aria-label="Criterion Comparison Selection"] >> nth=1'
);
await secondCriterionComparison.selectOption({ label: 'is less than' });
const secondCriterionInput = page.locator('[aria-label="Criterion Input"] >> nth=1');
await secondCriterionInput.fill('0');
// Enable test data
await page.getByLabel('Apply Test Data').nth(1).click();
const testDataTelemetry = page.locator('[aria-label="Test Data Telemetry Selection"] >> nth=0');
await testDataTelemetry.selectOption({ label: exampleTelemetry.name });
const testDataMetadata = page.locator('[aria-label="Test Data Metadata Selection"] >> nth=0');
await testDataMetadata.selectOption({ label: 'Sine' });
const testInput = page.locator('[aria-label="Test Data Input"] >> nth=0');
await testInput.fill('0');
// Validate that the condition set is evaluating and outputting
// the correct value when the underlying telemetry subscription is active.
let outputValue = page.getByLabel('Current Output Value');
await expect(outputValue).toHaveText('false');
await page.goto(exampleTelemetry.url);
});
test.fixme('Ensure condition sets work with telemetry like operator status', ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7484'
});
});
});
test.describe('Condition Set Composition', () => {
let conditionSet;
let exampleTelemetry;
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create Condition Set
conditionSet = await createDomainObjectWithDefaults(page, {
type: 'Condition Set'
});
// Create Telemetry Object as child to Condition Set
exampleTelemetry = await createExampleTelemetryObject(page, conditionSet.uuid);
// Edit Condition Set
await page.goto(conditionSet.url);
await page.getByRole('button', { name: 'Edit Object' }).click();
// Add Condition to Condition Set
await page.getByRole('button', { name: 'Add Condition' }).click();
// Enter Condition Output
await page.getByLabel('Condition Name Input').first().fill('Negative');
await page.getByLabel('Condition Output Type').first().selectOption({ value: 'string' });
await page.getByLabel('Condition Output String').first().fill('Negative');
// Condition Trigger default is okay so no change needed to form
// Enter Condition Criterion
await page.getByLabel('Criterion Telemetry Selection').first().selectOption({ value: 'all' });
await page.getByLabel('Criterion Metadata Selection').first().selectOption({ value: 'sin' });
await page
.locator('select[aria-label="Criterion Comparison Selection"]')
.first()
.selectOption({ value: 'lessThan' });
await page.getByLabel('Criterion Input').first().fill('0');
// Save the Condition Set
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
});
test('You can remove telemetry from a condition set with existing conditions', async ({
page
}) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7710'
});
await page.getByLabel('Expand My Items folder').click();
await page.getByLabel(`Expand ${conditionSet.name} conditionSet`).click();
await page
.getByLabel(`Navigate to ${exampleTelemetry.name}`, { exact: false })
.click({ button: 'right' });
await page
.getByLabel(`${exampleTelemetry.name} Context Menu`)
.getByRole('menuitem', { name: 'Remove' })
.click();
await page.getByRole('button', { name: 'Ok', exact: true }).click();
await page
.getByLabel(`Navigate to ${conditionSet.name} conditionSet Object`, { exact: true })
.click();
await page.getByRole('button', { name: 'Edit Object' }).click();
await page.getByRole('tab', { name: 'Elements' }).click();
expect(
await page
.getByRole('tabpanel', { name: 'Inspector Views' })
.getByRole('listitem', { name: exampleTelemetry.name })
.count()
).toEqual(0);
});
}); });

View File

@ -0,0 +1,403 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*
This test suite is dedicated to tests which verify the basic operations surrounding conditionSets. Note: this
suite is sharing state between tests which is considered an anti-pattern. Implementing in this way to
demonstrate some playwright for test developers. This pattern should not be re-used in other CRUD suites.
*/
import {
createDomainObjectWithDefaults,
createExampleTelemetryObject
} from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Basic Condition Set Use', () => {
let conditionSet;
test.beforeEach(async ({ page }) => {
// Open a browser, navigate to the main page, and wait until all network events to resolve
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create a new condition set
conditionSet = await createDomainObjectWithDefaults(page, {
type: 'Condition Set',
name: 'Test Condition Set'
});
});
test('Creating a condition defaults the condition name to "Unnamed Condition"', async ({
page
}) => {
await page.goto(conditionSet.url);
// Change the object to edit mode
await page.getByLabel('Edit Object').click();
// Click Add Condition button
await page.locator('#addCondition').click();
// Check that the new Unnamed Condition section appears
const numOfUnnamedConditions = await page
.locator('.c-condition__name', { hasText: 'Unnamed Condition' })
.count();
expect(numOfUnnamedConditions).toEqual(1);
});
test('ConditionSet should display appropriate view options', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5924'
});
await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator',
name: 'Alpha Sine Wave Generator'
});
await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator',
name: 'Beta Sine Wave Generator'
});
await page.goto(conditionSet.url);
// Change the object to edit mode
await page.getByLabel('Edit Object').click();
// Expand the 'My Items' folder in the left tree
await page.getByLabel('Show selected item in tree').click();
// Add the Alpha & Beta Sine Wave Generator to the Condition Set and save changes
const treePane = page.getByRole('tree', {
name: 'Main Tree'
});
const alphaGeneratorTreeItem = treePane.getByRole('treeitem', {
name: 'Alpha Sine Wave Generator'
});
const betaGeneratorTreeItem = treePane.getByRole('treeitem', {
name: 'Beta Sine Wave Generator'
});
const conditionCollection = page.locator('#conditionCollection');
await alphaGeneratorTreeItem.dragTo(conditionCollection);
await betaGeneratorTreeItem.dragTo(conditionCollection);
await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
await page.getByLabel('Open the View Switcher Menu').click();
await expect(page.getByRole('menuitem', { name: /Lad Table/ })).toBeHidden();
await expect(page.getByRole('menuitem', { name: /Conditions View/ })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /Plot/ })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /Telemetry Table/ })).toBeVisible();
await page.getByLabel('Plot').click();
await expect(
page.getByLabel('Plot Legend Collapsed').getByText('Test Condition Set')
).toBeVisible();
await page.getByLabel('Open the View Switcher Menu').click();
await page.getByLabel('Telemetry Table').click();
await expect(page.getByRole('searchbox', { name: 'output filter input' })).toBeVisible();
await page.getByLabel('Open the View Switcher Menu').click();
await page.getByLabel('Conditions View').click();
await expect(page.getByText('Current Output')).toBeVisible();
});
test('ConditionSet has correct outputs when telemetry is and is not available', async ({
page
}) => {
const exampleTelemetry = await createExampleTelemetryObject(page);
await page.getByLabel('Show selected item in tree').click();
await page.goto(conditionSet.url);
// Change the object to edit mode
await page.getByLabel('Edit Object').click();
// Create two conditions
await page.locator('#addCondition').click();
await page.locator('#addCondition').click();
await page.locator('#conditionCollection').getByRole('textbox').nth(0).fill('First Condition');
await page.locator('#conditionCollection').getByRole('textbox').nth(1).fill('Second Condition');
// Add Telemetry to ConditionSet
const sineWaveGeneratorTreeItem = page
.getByRole('tree', {
name: 'Main Tree'
})
.getByRole('treeitem', {
name: exampleTelemetry.name
});
const conditionCollection = page.locator('#conditionCollection');
await sineWaveGeneratorTreeItem.dragTo(conditionCollection);
// Modify First Criterion
const firstCriterionTelemetry = page.locator(
'[aria-label="Criterion Telemetry Selection"] >> nth=0'
);
firstCriterionTelemetry.selectOption({ label: exampleTelemetry.name });
const firstCriterionMetadata = page.locator(
'[aria-label="Criterion Metadata Selection"] >> nth=0'
);
firstCriterionMetadata.selectOption({ label: 'Sine' });
const firstCriterionComparison = page.locator(
'[aria-label="Criterion Comparison Selection"] >> nth=0'
);
firstCriterionComparison.selectOption({ label: 'is greater than or equal to' });
const firstCriterionInput = page.locator('[aria-label="Criterion Input"] >> nth=0');
await firstCriterionInput.fill('0');
// Modify First Criterion
const secondCriterionTelemetry = page.locator(
'[aria-label="Criterion Telemetry Selection"] >> nth=1'
);
secondCriterionTelemetry.selectOption({ label: exampleTelemetry.name });
const secondCriterionMetadata = page.locator(
'[aria-label="Criterion Metadata Selection"] >> nth=1'
);
secondCriterionMetadata.selectOption({ label: 'Sine' });
const secondCriterionComparison = page.locator(
'[aria-label="Criterion Comparison Selection"] >> nth=1'
);
secondCriterionComparison.selectOption({ label: 'is less than' });
const secondCriterionInput = page.locator('[aria-label="Criterion Input"] >> nth=1');
await secondCriterionInput.fill('0');
// Save ConditionSet
await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Validate that the condition set is evaluating and outputting
// the correct value when the underlying telemetry subscription is active.
let outputValue = page.getByLabel('Current Output Value');
await expect(outputValue).toHaveText('false');
await page.goto(exampleTelemetry.url);
// Edit SWG to add 8 second loading delay to simulate the case
// where telemetry is not available.
await page.getByTitle('More actions').click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page.getByRole('spinbutton', { name: 'Loading Delay (ms)' }).fill('8000');
await page.getByLabel('Save').click();
// Expect that the output value is blank or '---' if the
// underlying telemetry subscription is not active.
await page.goto(conditionSet.url);
await expect(outputValue).toHaveText('---');
});
test('ConditionSet has correct outputs when test data is enabled', async ({ page }) => {
const exampleTelemetry = await createExampleTelemetryObject(page);
await page.getByLabel('Show selected item in tree').click();
await page.goto(conditionSet.url);
// Change the object to edit mode
await page.getByLabel('Edit Object').click();
// Create two conditions
await page.locator('#addCondition').click();
await page.locator('#addCondition').click();
await page.locator('#conditionCollection').getByRole('textbox').nth(0).fill('First Condition');
await page.locator('#conditionCollection').getByRole('textbox').nth(1).fill('Second Condition');
// Add Telemetry to ConditionSet
const sineWaveGeneratorTreeItem = page
.getByRole('tree', {
name: 'Main Tree'
})
.getByRole('treeitem', {
name: exampleTelemetry.name
});
const conditionCollection = page.locator('#conditionCollection');
await sineWaveGeneratorTreeItem.dragTo(conditionCollection);
// Modify First Criterion
const firstCriterionTelemetry = page.locator(
'[aria-label="Criterion Telemetry Selection"] >> nth=0'
);
firstCriterionTelemetry.selectOption({ label: exampleTelemetry.name });
const firstCriterionMetadata = page.locator(
'[aria-label="Criterion Metadata Selection"] >> nth=0'
);
firstCriterionMetadata.selectOption({ label: 'Sine' });
const firstCriterionComparison = page.locator(
'[aria-label="Criterion Comparison Selection"] >> nth=0'
);
firstCriterionComparison.selectOption({ label: 'is greater than or equal to' });
const firstCriterionInput = page.locator('[aria-label="Criterion Input"] >> nth=0');
await firstCriterionInput.fill('0');
// Modify Second Criterion
const secondCriterionTelemetry = page.locator(
'[aria-label="Criterion Telemetry Selection"] >> nth=1'
);
await secondCriterionTelemetry.selectOption({ label: exampleTelemetry.name });
const secondCriterionMetadata = page.locator(
'[aria-label="Criterion Metadata Selection"] >> nth=1'
);
await secondCriterionMetadata.selectOption({ label: 'Sine' });
const secondCriterionComparison = page.locator(
'[aria-label="Criterion Comparison Selection"] >> nth=1'
);
await secondCriterionComparison.selectOption({ label: 'is less than' });
const secondCriterionInput = page.locator('[aria-label="Criterion Input"] >> nth=1');
await secondCriterionInput.fill('0');
// Enable test data
await page.getByLabel('Apply Test Data').nth(1).click();
const testDataTelemetry = page.locator('[aria-label="Test Data Telemetry Selection"] >> nth=0');
await testDataTelemetry.selectOption({ label: exampleTelemetry.name });
const testDataMetadata = page.locator('[aria-label="Test Data Metadata Selection"] >> nth=0');
await testDataMetadata.selectOption({ label: 'Sine' });
const testInput = page.locator('[aria-label="Test Data Input"] >> nth=0');
await testInput.fill('0');
// Validate that the condition set is evaluating and outputting
// the correct value when the underlying telemetry subscription is active.
let outputValue = page.getByLabel('Current Output Value');
await expect(outputValue).toHaveText('false');
await page.goto(exampleTelemetry.url);
});
test.fixme('Ensure condition sets work with telemetry like operator status', ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7484'
});
});
test('ConditionSet has add criteria button enabled/disabled when composition is and is not available', async ({
page
}) => {
const exampleTelemetry = await createExampleTelemetryObject(page);
await page.getByLabel('Show selected item in tree').click();
await page.goto(conditionSet.url);
// Change the object to edit mode
await page.getByLabel('Edit Object').click();
// Create a condition
await page.locator('#addCondition').click();
await page.locator('#conditionCollection').getByRole('textbox').nth(0).fill('First Condition');
// Validate that the add criteria button is disabled
await expect(page.getByLabel('Add Criteria - Disabled')).toHaveAttribute('disabled');
// Add Telemetry to ConditionSet
const sineWaveGeneratorTreeItem = page
.getByRole('tree', {
name: 'Main Tree'
})
.getByRole('treeitem', {
name: exampleTelemetry.name
});
const conditionCollection = page.locator('#conditionCollection');
await sineWaveGeneratorTreeItem.dragTo(conditionCollection);
// Validate that the add criteria button is enabled and adds a new criterion
await expect(page.getByLabel('Add Criteria - Enabled')).not.toHaveAttribute('disabled');
await page.getByLabel('Add Criteria - Enabled').click();
const numOfUnnamedCriteria = await page.getByLabel('Criterion Telemetry Selection').count();
expect(numOfUnnamedCriteria).toEqual(2);
});
});
test.describe('Condition Set Composition', () => {
let conditionSet;
let exampleTelemetry;
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create Condition Set
conditionSet = await createDomainObjectWithDefaults(page, {
type: 'Condition Set'
});
// Create Telemetry Object as child to Condition Set
exampleTelemetry = await createExampleTelemetryObject(page, conditionSet.uuid);
// Edit Condition Set
await page.goto(conditionSet.url);
await page.getByRole('button', { name: 'Edit Object' }).click();
// Add Condition to Condition Set
await page.getByRole('button', { name: 'Add Condition' }).click();
// Enter Condition Output
await page.getByLabel('Condition Name Input').first().fill('Negative');
await page.getByLabel('Condition Output Type').first().selectOption({ value: 'string' });
await page.getByLabel('Condition Output String').first().fill('Negative');
// Condition Trigger default is okay so no change needed to form
// Enter Condition Criterion
await page.getByLabel('Criterion Telemetry Selection').first().selectOption({ value: 'all' });
await page.getByLabel('Criterion Metadata Selection').first().selectOption({ value: 'sin' });
await page
.locator('select[aria-label="Criterion Comparison Selection"]')
.first()
.selectOption({ value: 'lessThan' });
await page.getByLabel('Criterion Input').first().fill('0');
// Save the Condition Set
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
});
test('You can remove telemetry from a condition set with existing conditions', async ({
page
}) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7710'
});
await page.getByLabel('Expand My Items folder').click();
await page.getByLabel(`Expand ${conditionSet.name} conditionSet`).click();
await page
.getByLabel(`Navigate to ${exampleTelemetry.name}`, { exact: false })
.click({ button: 'right' });
await page
.getByLabel(`${exampleTelemetry.name} Context Menu`)
.getByRole('menuitem', { name: 'Remove' })
.click();
await page.getByRole('button', { name: 'Ok', exact: true }).click();
await page
.getByLabel(`Navigate to ${conditionSet.name} conditionSet Object`, { exact: true })
.click();
await page.getByRole('button', { name: 'Edit Object' }).click();
await page.getByRole('tab', { name: 'Elements' }).click();
expect(
await page
.getByRole('tabpanel', { name: 'Inspector Views' })
.getByRole('listitem', { name: exampleTelemetry.name })
.count()
).toEqual(0);
});
});

View File

@ -23,19 +23,110 @@ import { fileURLToPath } from 'url';
import { import {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
navigateToObjectWithFixedTimeBounds,
setFixedIndependentTimeConductorBounds,
setFixedTimeMode, setFixedTimeMode,
setIndependentTimeConductorBounds,
setRealTimeMode, setRealTimeMode,
setStartOffset setStartOffset
} from '../../../../appActions.js'; } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
const LOCALSTORAGE_PATH = fileURLToPath( const CHILD_LAYOUT_STORAGE_STATE_PATH = fileURLToPath(
new URL('../../../../test-data/display_layout_with_child_layouts.json', import.meta.url) new URL('../../../../test-data/display_layout_with_child_layouts.json', import.meta.url)
); );
const CHILD_PLOT_STORAGE_STATE_PATH = fileURLToPath(
new URL('../../../../test-data/display_layout_with_child_overlay_plot.json', import.meta.url)
);
const TINY_IMAGE_BASE64 = const TINY_IMAGE_BASE64 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII'; 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
test.describe('Display Layout Sub-object Actions @localStorage', () => {
const INIT_ITC_START_BOUNDS = '2024-11-12 19:11:11.000Z';
const INIT_ITC_END_BOUNDS = '2024-11-12 20:11:11.000Z';
const NEW_GLOBAL_START_BOUNDS = '2024-11-11 19:11:11.000Z';
const NEW_GLOBAL_END_BOUNDS = '2024-11-11 20:11:11.000Z';
test.use({
storageState: CHILD_PLOT_STORAGE_STATE_PATH
});
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
await page.getByLabel('Expand My Items folder').click();
const waitForMyItemsNavigation = page.waitForURL(`**/mine/?*`);
await page
.getByLabel('Main Tree')
.getByLabel('Navigate to Parent Display Layout layout Object')
.click();
// Wait for the URL to change to the display layout
await waitForMyItemsNavigation;
});
test('Open in New Tab action preserves time bounds @2p', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7524'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/6982'
});
const TEST_FIXED_START_TIME = 1731352271000; // 2024-11-11 19:11:11.000Z
const TEST_FIXED_END_TIME = TEST_FIXED_START_TIME + 3600000; // 2024-11-11 20:11:11.000Z
// Verify the ITC has the expected initial bounds
await expect(
page.getByLabel('Child Overlay Plot 1 Frame Controls').getByLabel('Start bounds')
).toHaveText(INIT_ITC_START_BOUNDS);
await expect(
page.getByLabel('Child Overlay Plot 1 Frame Controls').getByLabel('End bounds')
).toHaveText(INIT_ITC_END_BOUNDS);
// Update the global fixed bounds to 2024-11-11 19:11:11.000Z / 2024-11-11 20:11:11.000Z
const url = page.url().split('?')[0];
await navigateToObjectWithFixedTimeBounds(
page,
url,
TEST_FIXED_START_TIME,
TEST_FIXED_END_TIME
);
// ITC bounds should still match the initial ITC bounds
await expect(
page.getByLabel('Child Overlay Plot 1 Frame Controls').getByLabel('Start bounds')
).toHaveText(INIT_ITC_START_BOUNDS);
await expect(
page.getByLabel('Child Overlay Plot 1 Frame Controls').getByLabel('End bounds')
).toHaveText(INIT_ITC_END_BOUNDS);
// Open the Child Overlay Plot 1 in a new tab
await page.getByLabel('View menu items').click();
const pagePromise = page.context().waitForEvent('page');
await page.getByLabel('Open In New Tab').click();
const newPage = await pagePromise;
await newPage.waitForLoadState('domcontentloaded');
// Verify that the global time conductor bounds in the new page match the updated global bounds
await expect(newPage.getByLabel('Global Time Conductor').getByLabel('Start bounds')).toHaveText(
NEW_GLOBAL_START_BOUNDS
);
await expect(newPage.getByLabel('Global Time Conductor').getByLabel('End bounds')).toHaveText(
NEW_GLOBAL_END_BOUNDS
);
// Verify that the ITC is enabled in the new page
await expect(newPage.getByLabel('Disable Independent Time Conductor')).toBeVisible();
// Verify that the ITC bounds in the new page match the original ITC bounds
await expect(
newPage.getByLabel('Independent Time Conductor Panel').getByLabel('Start bounds')
).toHaveText(INIT_ITC_START_BOUNDS);
await expect(
newPage.getByLabel('Independent Time Conductor Panel').getByLabel('End bounds')
).toHaveText(INIT_ITC_END_BOUNDS);
});
});
test.describe('Display Layout Toolbar Actions @localStorage', () => { test.describe('Display Layout Toolbar Actions @localStorage', () => {
const PARENT_DISPLAY_LAYOUT_NAME = 'Parent Display Layout'; const PARENT_DISPLAY_LAYOUT_NAME = 'Parent Display Layout';
const CHILD_DISPLAY_LAYOUT_NAME1 = 'Child Layout 1'; const CHILD_DISPLAY_LAYOUT_NAME1 = 'Child Layout 1';
@ -50,7 +141,7 @@ test.describe('Display Layout Toolbar Actions @localStorage', () => {
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
}); });
test.use({ test.use({
storageState: LOCALSTORAGE_PATH storageState: CHILD_LAYOUT_STORAGE_STATE_PATH
}); });
test('can add/remove Text element to a single layout', async ({ page }) => { test('can add/remove Text element to a single layout', async ({ page }) => {
@ -65,17 +156,17 @@ test.describe('Display Layout Toolbar Actions @localStorage', () => {
test('can add/remove Image to a single layout', async ({ page }) => { test('can add/remove Image to a single layout', async ({ page }) => {
const layoutObject = 'Image'; const layoutObject = 'Image';
await test.step("Add and remove image element from the parent's layout", async () => { await test.step("Add and remove image element from the parent's layout", async () => {
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(0); await expect(page.getByLabel(`Move ${layoutObject} Frame`)).toHaveCount(0);
await addLayoutObject(page, PARENT_DISPLAY_LAYOUT_NAME, layoutObject); await addLayoutObject(page, PARENT_DISPLAY_LAYOUT_NAME, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(1); await expect(page.getByLabel(`Move ${layoutObject} Frame`)).toHaveCount(1);
await removeLayoutObject(page, layoutObject); await removeLayoutObject(page, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(0); await expect(page.getByLabel(`Move ${layoutObject} Frame`)).toHaveCount(0);
}); });
await test.step("Add and remove image from the child's layout", async () => { await test.step("Add and remove image from the child's layout", async () => {
await addLayoutObject(page, CHILD_DISPLAY_LAYOUT_NAME1, layoutObject); await addLayoutObject(page, CHILD_DISPLAY_LAYOUT_NAME1, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(1); await expect(page.getByLabel(`Move ${layoutObject} Frame`)).toHaveCount(1);
await removeLayoutObject(page, layoutObject); await removeLayoutObject(page, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(0); await expect(page.getByLabel(`Move ${layoutObject} Frame`)).toHaveCount(0);
}); });
}); });
test(`can add/remove Box to a single layout`, async ({ page }) => { test(`can add/remove Box to a single layout`, async ({ page }) => {
@ -144,26 +235,23 @@ test.describe('Display Layout', () => {
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
name: new RegExp(sineWaveObject.name) name: new RegExp(sineWaveObject.name)
}); });
const layoutGridHolder = page.locator('.l-layout__grid-holder'); await sineWaveGeneratorTreeItem.dragTo(page.getByLabel('Layout Grid'));
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder);
await page.locator('button[title="Save"]').click(); await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Subscribe to the Sine Wave Generator data // Subscribe to the Sine Wave Generator data
// On getting data, check if the value found in the Display Layout is the most recent value // On getting data, check if the value found in the Display Layout is the most recent value
// from the Sine Wave Generator // from the Sine Wave Generator
const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); const getTelemValuePromise = subscribeToTelemetry(page, sineWaveObject.uuid);
const formattedTelemetryValue = getTelemValuePromise; const formattedTelemetryValue = await getTelemValuePromise;
const displayLayoutValuePromise = await page.waitForSelector( await expect(page.getByText(formattedTelemetryValue)).toBeVisible();
`text="${formattedTelemetryValue}"` const displayLayoutValue = await page.getByText(formattedTelemetryValue).textContent();
);
const displayLayoutValue = await displayLayoutValuePromise.textContent();
const trimmedDisplayValue = displayLayoutValue.trim(); const trimmedDisplayValue = displayLayoutValue.trim();
expect(trimmedDisplayValue).toBe(formattedTelemetryValue); expect(trimmedDisplayValue).toBe(formattedTelemetryValue);
// ensure we can right click on the alpha-numeric widget and view historical data // ensure we can right click on the alpha-numeric widget and view historical data
await page.getByLabel('Sine', { exact: true }).click({ await page.getByLabel(/Alpha-numeric telemetry value of.*/).click({
button: 'right' button: 'right'
}); });
await page.getByLabel('View Historical Data').click(); await page.getByLabel('View Historical Data').click();
@ -189,24 +277,21 @@ test.describe('Display Layout', () => {
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
name: new RegExp(sineWaveObject.name) name: new RegExp(sineWaveObject.name)
}); });
const layoutGridHolder = page.locator('.l-layout__grid-holder'); await sineWaveGeneratorTreeItem.dragTo(page.getByLabel('Layout Grid'));
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder);
await page.locator('button[title="Save"]').click(); await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Subscribe to the Sine Wave Generator data // Subscribe to the Sine Wave Generator data
const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); const getTelemValuePromise = subscribeToTelemetry(page, sineWaveObject.uuid);
// Set an offset of 1 minute and then change the time mode to fixed to set a 1 minute historical window // Set an offset of 1 minute and then change the time mode to fixed to set a 1 minute historical window
await setStartOffset(page, { mins: '1' }); await setStartOffset(page, { startMins: '1' });
await setFixedTimeMode(page); await setFixedTimeMode(page);
// On getting data, check if the value found in the Display Layout is the most recent value // On getting data, check if the value found in the Display Layout is the most recent value
// from the Sine Wave Generator // from the Sine Wave Generator
const formattedTelemetryValue = getTelemValuePromise; const formattedTelemetryValue = await getTelemValuePromise;
const displayLayoutValuePromise = await page.waitForSelector( await expect(page.getByText(formattedTelemetryValue)).toBeVisible();
`text="${formattedTelemetryValue}"` const displayLayoutValue = await page.getByText(formattedTelemetryValue).textContent();
);
const displayLayoutValue = await displayLayoutValuePromise.textContent();
const trimmedDisplayValue = displayLayoutValue.trim(); const trimmedDisplayValue = displayLayoutValue.trim();
expect(trimmedDisplayValue).toBe(formattedTelemetryValue); expect(trimmedDisplayValue).toBe(formattedTelemetryValue);
@ -231,8 +316,7 @@ test.describe('Display Layout', () => {
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
name: new RegExp(sineWaveObject.name) name: new RegExp(sineWaveObject.name)
}); });
const layoutGridHolder = page.locator('.l-layout__grid-holder'); await sineWaveGeneratorTreeItem.dragTo(page.getByLabel('Layout Grid'));
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder);
await page.locator('button[title="Save"]').click(); await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
@ -273,8 +357,7 @@ test.describe('Display Layout', () => {
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
name: new RegExp(sineWaveObject.name) name: new RegExp(sineWaveObject.name)
}); });
const layoutGridHolder = page.locator('.l-layout__grid-holder'); await sineWaveGeneratorTreeItem.dragTo(page.getByLabel('Layout Grid'));
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder);
await page.locator('button[title="Save"]').click(); await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
@ -319,8 +402,7 @@ test.describe('Display Layout', () => {
const exampleImageryTreeItem = treePane.getByRole('treeitem', { const exampleImageryTreeItem = treePane.getByRole('treeitem', {
name: new RegExp(exampleImageryObject.name) name: new RegExp(exampleImageryObject.name)
}); });
let layoutGridHolder = page.locator('.l-layout__grid-holder'); await exampleImageryTreeItem.dragTo(page.getByLabel('Layout Grid'));
await exampleImageryTreeItem.dragTo(layoutGridHolder);
//adjust so that we can see the independent time conductor toggle //adjust so that we can see the independent time conductor toggle
// Adjust object height // Adjust object height
@ -336,7 +418,7 @@ test.describe('Display Layout', () => {
const startDate = '2021-12-30 01:01:00.000Z'; const startDate = '2021-12-30 01:01:00.000Z';
const endDate = '2021-12-30 01:11:00.000Z'; const endDate = '2021-12-30 01:11:00.000Z';
await setIndependentTimeConductorBounds(page, startDate, endDate); await setFixedIndependentTimeConductorBounds(page, { start: startDate, end: endDate });
// check image date // check image date
await expect(page.getByText('2021-12-30 01:11:00.000Z').first()).toBeVisible(); await expect(page.getByText('2021-12-30 01:11:00.000Z').first()).toBeVisible();
@ -347,7 +429,7 @@ test.describe('Display Layout', () => {
await expect(page.getByText('2021-12-30 01:11:00.000Z')).toBeHidden(); await expect(page.getByText('2021-12-30 01:11:00.000Z')).toBeHidden();
}); });
test('When multiple plots are contained in a layout, we only ask for annotations once @couchdb', async ({ test('When multiple plots are contained in a layout, we only ask for annotations once @couchdb @network', async ({
page page
}) => { }) => {
await setFixedTimeMode(page); await setFixedTimeMode(page);
@ -376,9 +458,8 @@ test.describe('Display Layout', () => {
name: new RegExp(sineWaveObject.name) name: new RegExp(sineWaveObject.name)
}); });
let layoutGridHolder = page.locator('.l-layout__grid-holder');
// eslint-disable-next-line playwright/no-force-option // eslint-disable-next-line playwright/no-force-option
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true }); await sineWaveGeneratorTreeItem.dragTo(page.getByLabel('Layout Grid'), { force: true });
await page.getByText('View type').click(); await page.getByText('View type').click();
await page.getByText('Overlay Plot').click(); await page.getByText('Overlay Plot').click();
@ -386,14 +467,13 @@ test.describe('Display Layout', () => {
const anotherSineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { const anotherSineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
name: new RegExp(anotherSineWaveObject.name) name: new RegExp(anotherSineWaveObject.name)
}); });
layoutGridHolder = page.locator('.l-layout__grid-holder');
// eslint-disable-next-line playwright/no-force-option // eslint-disable-next-line playwright/no-force-option
await anotherSineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true }); await anotherSineWaveGeneratorTreeItem.dragTo(page.getByLabel('Layout Grid'), { force: true });
await page.getByText('View type').click(); await page.getByText('View type').click();
await page.getByText('Overlay Plot').click(); await page.getByText('Overlay Plot').click();
await page.locator('button[title="Save"]').click(); await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Time to inspect some network traffic // Time to inspect some network traffic
@ -410,10 +490,10 @@ test.describe('Display Layout', () => {
await page.reload(); await page.reload();
// wait for annotations requests to be batched and requested // wait for annotations requests to be batched and requested
await page.waitForLoadState('networkidle'); await page.waitForLoadState('domcontentloaded');
// Network requests for the composite telemetry with multiple items should be: // Network requests for the composite telemetry with multiple items should be:
// 1. a single batched request for annotations // 1. a single batched request for annotations
expect(networkRequests.length).toBe(1); await expect.poll(() => networkRequests, { timeout: 10000 }).toHaveLength(1);
await setRealTimeMode(page); await setRealTimeMode(page);
@ -422,15 +502,147 @@ test.describe('Display Layout', () => {
await page.reload(); await page.reload();
// wait for annotations to not load (if we have any, we've got a problem) // wait for annotations to not load (if we have any, we've got a problem)
await page.waitForLoadState('networkidle'); await page.waitForLoadState('domcontentloaded');
// In real time mode, we don't fetch annotations at all // In real time mode, we don't fetch annotations at all
expect(networkRequests.length).toBe(0); await expect.poll(() => networkRequests, { timeout: 10000 }).toHaveLength(0);
});
test('Same objects with different request options have unique subscriptions', async ({
page
}) => {
// Expand My Items
await page.getByLabel('Expand My Items folder').click();
// Create a Display Layout
const displayLayout = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Test Display'
});
// Create a State Generator, set to higher frequency updates
const stateGenerator = await createDomainObjectWithDefaults(page, {
type: 'State Generator',
name: 'State Generator'
});
const stateGeneratorTreeItem = page.getByRole('treeitem', {
name: stateGenerator.name
});
await stateGeneratorTreeItem.click({ button: 'right' });
await page.getByLabel('Edit Properties...').click();
await page.getByLabel('State Duration (seconds)', { exact: true }).fill('0.1');
await page.getByLabel('Save').click();
// Create a Table for filtering ON values
const tableFilterOnValue = await createDomainObjectWithDefaults(page, {
type: 'Telemetry Table',
name: 'Table Filter On Value'
});
const tableFilterOnTreeItem = page.getByRole('treeitem', {
name: tableFilterOnValue.name
});
// Create a Table for filtering OFF values
const tableFilterOffValue = await createDomainObjectWithDefaults(page, {
type: 'Telemetry Table',
name: 'Table Filter Off Value'
});
const tableFilterOffTreeItem = page.getByRole('treeitem', {
name: tableFilterOffValue.name
});
// Navigate to ON filtering table and add state generator and setup filters
await page.goto(tableFilterOnValue.url);
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
await selectFilterOption(page, '1');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Navigate to OFF filtering table and add state generator and setup filters
await page.goto(tableFilterOffValue.url);
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
await selectFilterOption(page, '0');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Navigate to the display layout and edit it
await page.goto(displayLayout.url);
// Add the tables to the display layout
await page.getByLabel('Edit Object').click();
await tableFilterOffTreeItem.dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 10, y: 300 }
});
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 400, y: 500 },
// eslint-disable-next-line playwright/no-force-option
force: true
});
await tableFilterOnTreeItem.dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 10, y: 100 }
});
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 400, y: 300 },
// eslint-disable-next-line playwright/no-force-option
force: true
});
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Get the tables so we can verify filtering is working as expected
const tableFilterOn = page.getByLabel(`${tableFilterOnValue.name} Frame`, {
exact: true
});
const tableFilterOff = page.getByLabel(`${tableFilterOffValue.name} Frame`, {
exact: true
});
// Verify filtering is working correctly
// Check that no filtered values appear for at least 2 seconds
const VERIFICATION_TIME = 2000; // 2 seconds
const CHECK_INTERVAL = 100; // Check every 100ms
// Create a promise that will check for filtered values periodically
const checkForCorrectValues = new Promise((resolve, reject) => {
const interval = setInterval(async () => {
const offCount = await tableFilterOn.locator('td[title="OFF"]').count();
const onCount = await tableFilterOff.locator('td[title="ON"]').count();
if (offCount > 0 || onCount > 0) {
clearInterval(interval);
reject(
new Error(
`Found ${offCount} OFF and ${onCount} ON values when expecting 0 OFF and 0 ON`
)
);
}
}, CHECK_INTERVAL);
// After VERIFICATION_TIME, if no filtered values were found, resolve successfully
setTimeout(() => {
clearInterval(interval);
resolve();
}, VERIFICATION_TIME);
});
await expect(checkForCorrectValues).resolves.toBeUndefined();
}); });
}); });
async function selectFilterOption(page, filterOption) {
await page.getByRole('tab', { name: 'Filters' }).click();
await page
.getByLabel('Inspector Views')
.locator('li')
.filter({ hasText: 'State Generator' })
.locator('span')
.click();
await page.getByRole('switch').click();
await page.selectOption('select[name="setSelectionThreshold"]', filterOption);
}
async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LAYOUT_NAME) { async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LAYOUT_NAME) {
expect(await page.getByLabel(layoutObject, { exact: true }).count()).toBe(0); await expect(page.getByLabel(layoutObject, { exact: true })).toHaveCount(0);
await addLayoutObject(page, DISPLAY_LAYOUT_NAME, layoutObject); await addLayoutObject(page, DISPLAY_LAYOUT_NAME, layoutObject);
expect( expect(
await page await page
@ -440,7 +652,7 @@ async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LA
.count() .count()
).toBe(1); ).toBe(1);
await removeLayoutObject(page, layoutObject); await removeLayoutObject(page, layoutObject);
expect(await page.getByLabel(layoutObject, { exact: true }).count()).toBe(0); await expect(page.getByLabel(layoutObject, { exact: true })).toHaveCount(0);
} }
/** /**
@ -456,7 +668,7 @@ async function removeLayoutObject(page, layoutObject) {
// eslint-disable-next-line playwright/no-force-option // eslint-disable-next-line playwright/no-force-option
.click({ force: true }); .click({ force: true });
await page.getByTitle('Delete the selected object').click(); await page.getByTitle('Delete the selected object').click();
await page.getByRole('button', { name: 'OK', exact: true }).click(); await page.getByRole('button', { name: 'Ok', exact: true }).click();
} }
/** /**
@ -475,10 +687,10 @@ async function addLayoutObject(page, layoutName, layoutObject) {
.click(); .click();
if (layoutObject === 'Text') { if (layoutObject === 'Text') {
await page.getByRole('textbox', { name: 'Text' }).fill('Hello, Universe!'); await page.getByRole('textbox', { name: 'Text' }).fill('Hello, Universe!');
await page.getByText('OK').click(); await page.getByText('Ok').click();
} else if (layoutObject === 'Image') { } else if (layoutObject === 'Image') {
await page.getByLabel('Image URL').fill(TINY_IMAGE_BASE64); await page.getByLabel('Image URL').fill(TINY_IMAGE_BASE64);
await page.getByText('OK').click(); await page.getByText('Ok').click();
} }
} }

View File

@ -20,235 +20,260 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import * as utils from '../../../../helper/faultUtils.js'; import {
acknowledgeFault,
acknowledgeMultipleFaults,
changeViewTo,
getFault,
getFaultByName,
getFaultName,
getFaultNamespace,
getFaultTriggerTime,
navigateToFaultManagementWithoutExample,
navigateToFaultManagementWithStaticExample,
selectFaultItem,
shelveFault,
shelveMultipleFaults,
sortFaultsBy
} from '../../../../helper/faultUtils.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
test.describe('The Fault Management Plugin using example faults', () => { test.describe('The Fault Management Plugin using example faults', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await utils.navigateToFaultManagementWithExample(page); await navigateToFaultManagementWithStaticExample(page);
}); });
test('Shows a criticality icon for every fault @unstable', async ({ page }) => { test('Shows a criticality icon for every fault', async ({ page }) => {
const faultCount = await page.locator('c-fault-mgmt__list').count(); const faultCount = await page.locator('c-fault-mgmt__list').count();
const criticalityIconCount = await page.locator('c-fault-mgmt__list-severity').count(); const criticalityIconCount = await page.locator('c-fault-mgmt__list-severity').count();
expect.soft(faultCount).toEqual(criticalityIconCount); expect(faultCount).toEqual(criticalityIconCount);
}); });
test('When selecting a fault, it has an "is-selected" class and it\'s information shows in the inspector @unstable', async ({ test('When selecting a fault, it has an "is-selected" class and its information shows in the inspector', async ({
page page
}) => { }) => {
await utils.selectFaultItem(page, 1); await selectFaultItem(page, 1);
await page.getByRole('tab', { name: 'Config' }).click(); await page.getByRole('tab', { name: 'Config' }).click();
const selectedFaultName = await page
.locator('.c-fault-mgmt__list.is-selected .c-fault-mgmt__list-faultname')
.textContent();
const inspectorFaultNameCount = await page
.locator(`.c-inspector__properties >> :text("${selectedFaultName}")`)
.count();
await expect const inspectorFaultName = page
.soft(page.locator('.c-faults-list-view-item-body > .c-fault-mgmt__list').first()) .getByLabel('Source inspector properties')
.toHaveClass(/is-selected/); .getByLabel('inspector property value');
expect.soft(inspectorFaultNameCount).toEqual(1);
await expect(page.getByLabel('Fault triggered at').first()).toHaveClass(/is-selected/);
await expect(inspectorFaultName).toHaveCount(1);
}); });
test('When selecting multiple faults, no specific fault information is shown in the inspector @unstable', async ({ test('When selecting multiple faults, no specific fault information is shown in the inspector', async ({
page page
}) => { }) => {
await utils.selectFaultItem(page, 1); await selectFaultItem(page, 1);
await utils.selectFaultItem(page, 2); await selectFaultItem(page, 2);
const selectedRows = page.locator( const selectedRows = page.getByRole('checkbox', { checked: true });
'.c-fault-mgmt__list.is-selected .c-fault-mgmt__list-faultname' await expect(selectedRows).toHaveCount(2);
);
expect.soft(await selectedRows.count()).toEqual(2);
await page.getByRole('tab', { name: 'Config' }).click(); await page.getByRole('tab', { name: 'Config' }).click();
const firstSelectedFaultName = await selectedRows.nth(0).textContent(); const firstSelectedFaultName = await selectedRows.nth(0).textContent();
const secondSelectedFaultName = await selectedRows.nth(1).textContent(); const secondSelectedFaultName = await selectedRows.nth(1).textContent();
const firstNameInInspectorCount = await page await expect(
.locator(`.c-inspector__properties >> :text("${firstSelectedFaultName}")`) page.locator(`.c-inspector__properties >> :text("${firstSelectedFaultName}")`)
.count(); ).toHaveCount(0);
const secondNameInInspectorCount = await page await expect(
.locator(`.c-inspector__properties >> :text("${secondSelectedFaultName}")`) page.locator(`.c-inspector__properties >> :text("${secondSelectedFaultName}")`)
.count(); ).toHaveCount(0);
expect.soft(firstNameInInspectorCount).toEqual(0);
expect.soft(secondNameInInspectorCount).toEqual(0);
}); });
test('Allows you to shelve a fault @unstable', async ({ page }) => { test('Allows you to shelve a fault', async ({ page }) => {
const shelvedFaultName = await utils.getFaultName(page, 2); const shelvedFaultName = await getFaultName(page, 2);
const beforeShelvedFault = utils.getFaultByName(page, shelvedFaultName); const beforeShelvedFault = getFaultByName(page, shelvedFaultName);
expect.soft(await beforeShelvedFault.count()).toBe(1); await expect(beforeShelvedFault).toHaveCount(1);
await utils.shelveFault(page, 2); await shelveFault(page, 2);
// check it is removed from standard view // check it is removed from standard view
const afterShelvedFault = utils.getFaultByName(page, shelvedFaultName); const afterShelvedFault = getFaultByName(page, shelvedFaultName);
expect.soft(await afterShelvedFault.count()).toBe(0); await expect(afterShelvedFault).toHaveCount(0);
await utils.changeViewTo(page, 'shelved'); await changeViewTo(page, 'shelved');
const shelvedViewFault = utils.getFaultByName(page, shelvedFaultName); const shelvedViewFault = getFaultByName(page, shelvedFaultName);
expect.soft(await shelvedViewFault.count()).toBe(1); await expect(shelvedViewFault).toHaveCount(1);
}); });
test('Allows you to acknowledge a fault @unstable', async ({ page }) => { test('Allows you to acknowledge a fault', async ({ page }) => {
const acknowledgedFaultName = await utils.getFaultName(page, 3); const acknowledgedFaultName = await getFaultName(page, 3);
await utils.acknowledgeFault(page, 3); await acknowledgeFault(page, 3);
const fault = utils.getFault(page, 3); const fault = getFault(page, 3);
await expect.soft(fault).toHaveClass(/is-acknowledged/); await expect(fault).toHaveClass(/is-acknowledged/);
await utils.changeViewTo(page, 'acknowledged'); await changeViewTo(page, 'acknowledged');
const acknowledgedViewFaultName = await utils.getFaultName(page, 1); const acknowledgedViewFaultName = await getFaultName(page, 1);
expect.soft(acknowledgedFaultName).toEqual(acknowledgedViewFaultName); expect(acknowledgedFaultName).toEqual(acknowledgedViewFaultName);
}); });
test('Allows you to shelve multiple faults @unstable', async ({ page }) => { test('Allows you to shelve multiple faults', async ({ page }) => {
const shelvedFaultNameOne = await utils.getFaultName(page, 1); const shelvedFaultNameOne = await getFaultName(page, 1);
const shelvedFaultNameFour = await utils.getFaultName(page, 4); const shelvedFaultNameFour = await getFaultName(page, 4);
const beforeShelvedFaultOne = utils.getFaultByName(page, shelvedFaultNameOne); const beforeShelvedFaultOne = getFaultByName(page, shelvedFaultNameOne);
const beforeShelvedFaultFour = utils.getFaultByName(page, shelvedFaultNameFour); const beforeShelvedFaultFour = getFaultByName(page, shelvedFaultNameFour);
expect.soft(await beforeShelvedFaultOne.count()).toBe(1); await expect(beforeShelvedFaultOne).toHaveCount(1);
expect.soft(await beforeShelvedFaultFour.count()).toBe(1); await expect(beforeShelvedFaultFour).toHaveCount(1);
await utils.shelveMultipleFaults(page, 1, 4); await shelveMultipleFaults(page, 1, 4);
// check it is removed from standard view // check it is removed from standard view
const afterShelvedFaultOne = utils.getFaultByName(page, shelvedFaultNameOne); const afterShelvedFaultOne = getFaultByName(page, shelvedFaultNameOne);
const afterShelvedFaultFour = utils.getFaultByName(page, shelvedFaultNameFour); const afterShelvedFaultFour = getFaultByName(page, shelvedFaultNameFour);
expect.soft(await afterShelvedFaultOne.count()).toBe(0); await expect(afterShelvedFaultOne).toHaveCount(0);
expect.soft(await afterShelvedFaultFour.count()).toBe(0); await expect(afterShelvedFaultFour).toHaveCount(0);
await utils.changeViewTo(page, 'shelved'); await changeViewTo(page, 'shelved');
const shelvedViewFaultOne = utils.getFaultByName(page, shelvedFaultNameOne); const shelvedViewFaultOne = getFaultByName(page, shelvedFaultNameOne);
const shelvedViewFaultFour = utils.getFaultByName(page, shelvedFaultNameFour); const shelvedViewFaultFour = getFaultByName(page, shelvedFaultNameFour);
expect.soft(await shelvedViewFaultOne.count()).toBe(1); await expect(shelvedViewFaultOne).toHaveCount(1);
expect.soft(await shelvedViewFaultFour.count()).toBe(1); await expect(shelvedViewFaultFour).toHaveCount(1);
}); });
test('Allows you to acknowledge multiple faults @unstable', async ({ page }) => { test('Allows you to acknowledge multiple faults', async ({ page }) => {
const acknowledgedFaultNameTwo = await utils.getFaultName(page, 2); const acknowledgedFaultNameTwo = await getFaultName(page, 2);
const acknowledgedFaultNameFive = await utils.getFaultName(page, 5); const acknowledgedFaultNameFive = await getFaultName(page, 5);
await utils.acknowledgeMultipleFaults(page, 2, 5); await acknowledgeMultipleFaults(page, 2, 5);
const faultTwo = utils.getFault(page, 2); const faultTwo = getFault(page, 2);
const faultFive = utils.getFault(page, 5); const faultFive = getFault(page, 5);
// check they have been acknowledged // check they have been acknowledged
await expect.soft(faultTwo).toHaveClass(/is-acknowledged/); await expect(faultTwo).toHaveClass(/is-acknowledged/);
await expect.soft(faultFive).toHaveClass(/is-acknowledged/); await expect(faultFive).toHaveClass(/is-acknowledged/);
await utils.changeViewTo(page, 'acknowledged'); await changeViewTo(page, 'acknowledged');
const acknowledgedViewFaultTwo = utils.getFaultByName(page, acknowledgedFaultNameTwo); const acknowledgedViewFaultTwo = getFaultByName(page, acknowledgedFaultNameTwo);
const acknowledgedViewFaultFive = utils.getFaultByName(page, acknowledgedFaultNameFive); const acknowledgedViewFaultFive = getFaultByName(page, acknowledgedFaultNameFive);
expect.soft(await acknowledgedViewFaultTwo.count()).toBe(1); await expect(acknowledgedViewFaultTwo).toHaveCount(1);
expect.soft(await acknowledgedViewFaultFive.count()).toBe(1); await expect(acknowledgedViewFaultFive).toHaveCount(1);
}); });
test('Allows you to search faults @unstable', async ({ page }) => { test('Allows you to search faults', async ({ page }) => {
const faultThreeNamespace = await utils.getFaultNamespace(page, 3); const faultThreeNamespace = await getFaultNamespace(page, 3);
const faultTwoName = await utils.getFaultName(page, 2); const faultTwoName = await getFaultName(page, 2);
const faultFiveTriggerTime = await utils.getFaultTriggerTime(page, 5); const faultFiveTriggerTime = await getFaultTriggerTime(page, 5);
// should be all faults (5) // should be all faults (5)
let faultResultCount = await utils.getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(5);
expect.soft(faultResultCount).toEqual(5);
// search namespace // search namespace
await utils.enterSearchTerm(page, faultThreeNamespace); await page
.getByLabel('Fault Management Object View')
.getByLabel('Search Input')
.fill(faultThreeNamespace);
faultResultCount = await utils.getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(1);
expect.soft(faultResultCount).toEqual(1); expect(await getFaultNamespace(page, 1)).toEqual(faultThreeNamespace);
expect.soft(await utils.getFaultNamespace(page, 1)).toEqual(faultThreeNamespace);
// all faults // all faults
await utils.clearSearch(page); await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill('');
faultResultCount = await utils.getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(5);
expect.soft(faultResultCount).toEqual(5);
// search name // search name
await utils.enterSearchTerm(page, faultTwoName); await page
.getByLabel('Fault Management Object View')
.getByLabel('Search Input')
.fill(faultTwoName);
faultResultCount = await utils.getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(1);
expect.soft(faultResultCount).toEqual(1); expect(await getFaultName(page, 1)).toEqual(faultTwoName);
expect.soft(await utils.getFaultName(page, 1)).toEqual(faultTwoName);
// all faults // all faults
await utils.clearSearch(page); await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill('');
faultResultCount = await utils.getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(5);
expect.soft(faultResultCount).toEqual(5);
// search triggerTime // search triggerTime
await utils.enterSearchTerm(page, faultFiveTriggerTime); await page
.getByLabel('Fault Management Object View')
.getByLabel('Search Input')
.fill(faultFiveTriggerTime);
faultResultCount = await utils.getFaultResultCount(page); await expect(page.getByLabel('Fault triggered at')).toHaveCount(1);
expect.soft(faultResultCount).toEqual(1); expect(await getFaultTriggerTime(page, 1)).toEqual(faultFiveTriggerTime);
expect.soft(await utils.getFaultTriggerTime(page, 1)).toEqual(faultFiveTriggerTime);
}); });
test('Allows you to sort faults @unstable', async ({ page }) => { test('Allows you to sort faults', async ({ page }) => {
const highestSeverity = await utils.getHighestSeverity(page); /**
const lowestSeverity = await utils.getLowestSeverity(page); * Compares two severity levels and returns a number indicating their relative order.
*
* @param {'CRITICAL' | 'WARNING' | 'WATCH'} severity1 - The first severity level to compare.
* @param {'CRITICAL' | 'WARNING' | 'WATCH'} severity2 - The second severity level to compare.
* @returns {number} - A negative number if severity1 is less severe than severity2,
* a positive number if severity1 is more severe than severity2,
* or 0 if they are equally severe.
*/
// eslint-disable-next-line func-style
const compareSeverity = (severity1, severity2) => {
const severityOrder = ['WATCH', 'WARNING', 'CRITICAL'];
return severityOrder.indexOf(severity1) - severityOrder.indexOf(severity2);
};
const faultOneName = 'Example Fault 1'; const faultOneName = 'Example Fault 1';
const faultFiveName = 'Example Fault 5'; const faultFiveName = 'Example Fault 5';
let firstFaultName = await utils.getFaultName(page, 1); let firstFaultName = await getFaultName(page, 1);
expect.soft(firstFaultName).toEqual(faultOneName); expect(firstFaultName).toEqual(faultOneName);
await utils.sortFaultsBy(page, 'oldest-first'); await sortFaultsBy(page, 'oldest-first');
firstFaultName = await utils.getFaultName(page, 1); firstFaultName = await getFaultName(page, 1);
expect.soft(firstFaultName).toEqual(faultFiveName); expect(firstFaultName).toEqual(faultFiveName);
await utils.sortFaultsBy(page, 'severity'); await sortFaultsBy(page, 'severity');
const sortedHighestSeverity = await utils.getFaultSeverity(page, 1); const firstFaultSeverityLabel = await page
const sortedLowestSeverity = await utils.getFaultSeverity(page, 5); .getByLabel('Severity:')
expect.soft(sortedHighestSeverity).toEqual(highestSeverity); .first()
expect.soft(sortedLowestSeverity).toEqual(lowestSeverity); .getAttribute('aria-label');
const firstFaultSeverity = firstFaultSeverityLabel.split(' ').slice(1).join(' ');
const lastFaultSeverityLabel = await page
.getByLabel('Severity:')
.last()
.getAttribute('aria-label');
const lastFaultSeverity = lastFaultSeverityLabel.split(' ').slice(1).join(' ');
expect(compareSeverity(firstFaultSeverity, lastFaultSeverity)).toBeGreaterThan(0);
}); });
}); });
test.describe('The Fault Management Plugin without using example faults', () => { test.describe('The Fault Management Plugin without using example faults', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await utils.navigateToFaultManagementWithoutExample(page); await navigateToFaultManagementWithoutExample(page);
}); });
test('Shows no faults when no faults are provided @unstable', async ({ page }) => { test('Shows no faults when no faults are provided', async ({ page }) => {
const faultCount = await page.locator('c-fault-mgmt__list').count(); await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
expect.soft(faultCount).toEqual(0); await changeViewTo(page, 'acknowledged');
await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
await utils.changeViewTo(page, 'acknowledged'); await changeViewTo(page, 'shelved');
const acknowledgedCount = await page.locator('c-fault-mgmt__list').count(); await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
expect.soft(acknowledgedCount).toEqual(0);
await utils.changeViewTo(page, 'shelved');
const shelvedCount = await page.locator('c-fault-mgmt__list').count();
expect.soft(shelvedCount).toEqual(0);
}); });
test('Will return no faults when searching @unstable', async ({ page }) => { test('Will return no faults when searching', async ({ page }) => {
await utils.enterSearchTerm(page, 'fault'); await page.getByLabel('Fault Management Object View').getByLabel('Search Input').fill('fault');
const faultCount = await page.locator('c-fault-mgmt__list').count(); await expect(page.getByLabel('Fault triggered at')).toHaveCount(0);
expect.soft(faultCount).toEqual(0);
}); });
}); });

View File

@ -24,7 +24,7 @@ import { fileURLToPath } from 'url';
import { import {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
setIndependentTimeConductorBounds setFixedIndependentTimeConductorBounds
} from '../../../../appActions.js'; } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
@ -78,8 +78,8 @@ test.describe('Flexible Layout', () => {
// Expand the 'My Items' folder in the left tree // Expand the 'My Items' folder in the left tree
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click(); await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click();
// Add the Sine Wave Generator and Clock to the Flexible Layout // Add the Sine Wave Generator and Clock to the Flexible Layout
await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first()); await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl-container.is-empty').first());
await clockTreeItem.dragTo(page.locator('.c-fl__container.is-empty')); await clockTreeItem.dragTo(page.locator('.c-fl-container.is-empty'));
// Check that panes can be dragged while Flexible Layout is in Edit mode // Check that panes can be dragged while Flexible Layout is in Edit mode
let dragWrapper = page let dragWrapper = page
.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper') .locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper')
@ -105,8 +105,8 @@ test.describe('Flexible Layout', () => {
// Expand the 'My Items' folder in the left tree // Expand the 'My Items' folder in the left tree
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click(); await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click();
// Add the Sine Wave Generator and Clock to the Flexible Layout // Add the Sine Wave Generator and Clock to the Flexible Layout
await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first()); await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl-container.is-empty').first());
await clockTreeItem.dragTo(page.locator('.c-fl__container.is-empty')); await clockTreeItem.dragTo(page.locator('.c-fl-container.is-empty'));
// Click on the first frame to select it // Click on the first frame to select it
await page.locator('.c-fl-container__frame').first().click(); await page.locator('.c-fl-container__frame').first().click();
@ -122,7 +122,7 @@ test.describe('Flexible Layout', () => {
expect(await page.locator('.c-fl--rows').count()).toEqual(0); expect(await page.locator('.c-fl--rows').count()).toEqual(0);
// Change the layout to rows orientation // Change the layout to rows orientation
await page.getByTitle('Columns layout').click(); await page.getByTitle('Switch to rows layout').click();
// Assert the layout is in rows orientation // Assert the layout is in rows orientation
expect(await page.locator('.c-fl--rows').count()).toBeGreaterThan(0); expect(await page.locator('.c-fl--rows').count()).toBeGreaterThan(0);
@ -171,7 +171,7 @@ test.describe('Flexible Layout', () => {
// Expand the 'My Items' folder in the left tree // Expand the 'My Items' folder in the left tree
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click(); await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click();
// Add the Sine Wave Generator to the Flexible Layout and save changes // Add the Sine Wave Generator to the Flexible Layout and save changes
await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first()); await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl-container.is-empty').first());
await page.locator('button[title="Save"]').click(); await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
@ -202,7 +202,7 @@ test.describe('Flexible Layout', () => {
// Expand the 'My Items' folder in the left tree // Expand the 'My Items' folder in the left tree
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
// Add the Sine Wave Generator to the Flexible Layout and save changes // Add the Sine Wave Generator to the Flexible Layout and save changes
await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first()); await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl-container.is-empty').first());
await page.locator('button[title="Save"]').click(); await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
@ -242,17 +242,16 @@ test.describe('Flexible Layout', () => {
name: new RegExp(exampleImageryObject.name) name: new RegExp(exampleImageryObject.name)
}); });
// Add the Sine Wave Generator to the Flexible Layout and save changes // Add the Sine Wave Generator to the Flexible Layout and save changes
await exampleImageryTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first()); await exampleImageryTreeItem.dragTo(page.locator('.c-fl-container.is-empty').first());
await page.locator('button[title="Save"]').click(); await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// flip on independent time conductor // flip on independent time conductor
await setIndependentTimeConductorBounds( await setFixedIndependentTimeConductorBounds(page, {
page, start: '2021-12-30 01:01:00.000Z',
'2021-12-30 01:01:00.000Z', end: '2021-12-30 01:11:00.000Z'
'2021-12-30 01:11:00.000Z' });
);
// check image date // check image date
await expect(page.getByText('2021-12-30 01:11:00.000Z').first()).toBeVisible(); await expect(page.getByText('2021-12-30 01:11:00.000Z').first()).toBeVisible();
@ -290,29 +289,29 @@ test.describe('Flexible Layout Toolbar Actions @localStorage', () => {
await page.getByTitle('Add Container').click(); await page.getByTitle('Add Container').click();
expect(await containerHandles.count()).toEqual(3); expect(await containerHandles.count()).toEqual(3);
await page.getByTitle('Remove Container').click(); await page.getByTitle('Remove Container').click();
await expect(page.getByRole('dialog', { name: 'Overlay' })).toHaveText( await expect(page.getByRole('dialog', { name: 'Overlay' })).toContainText(
'This action will permanently delete this container from this Flexible Layout. Do you want to continue?' 'This action will permanently delete this container from this Flexible Layout. Do you want to continue?'
); );
await page.getByRole('button', { name: 'OK', exact: true }).click(); await page.getByRole('button', { name: 'Ok', exact: true }).click();
expect(await containerHandles.count()).toEqual(2); expect(await containerHandles.count()).toEqual(2);
}); });
test('Remove Frame', async ({ page }) => { test('Remove Frame', async ({ page }) => {
expect(await page.getByRole('group', { name: 'Frame' }).count()).toEqual(2); expect(await page.getByRole('group', { name: 'Frame' }).count()).toEqual(2);
await page.getByRole('group', { name: 'Child Layout 1' }).click(); await page.getByRole('group', { name: 'Child Layout 1' }).click();
await page.getByTitle('Remove Frame').click(); await page.getByTitle('Remove Frame').click();
await expect(page.getByRole('dialog', { name: 'Overlay' })).toHaveText( await expect(page.getByRole('dialog', { name: 'Overlay' })).toContainText(
'This action will remove this frame from this Flexible Layout. Do you want to continue?' 'This action will remove this frame from this Flexible Layout. Do you want to continue?'
); );
await page.getByRole('button', { name: 'OK', exact: true }).click(); await page.getByRole('button', { name: 'Ok', exact: true }).click();
expect(await page.getByRole('group', { name: 'Frame' }).count()).toEqual(1); expect(await page.getByRole('group', { name: 'Frame' }).count()).toEqual(1);
}); });
test('Columns/Rows Layout Toggle', async ({ page }) => { test('Columns/Rows Layout Toggle', async ({ page }) => {
await page.getByRole('columnheader', { name: 'Container Handle 1' }).click(); await page.getByRole('columnheader', { name: 'Container Handle 1' }).click();
const flexRows = page.getByLabel('Flexible Layout Row'); const flexRows = page.getByLabel('Flexible Layout Row');
expect(await flexRows.count()).toEqual(0); expect(await flexRows.count()).toEqual(0);
await page.getByTitle('Columns layout').click(); await page.getByTitle('Switch to rows layout').click();
expect(await flexRows.count()).toEqual(1); expect(await flexRows.count()).toEqual(1);
await page.getByTitle('Rows layout').click(); await page.getByTitle('Switch to columns layout').click();
expect(await flexRows.count()).toEqual(0); expect(await flexRows.count()).toEqual(0);
}); });
}); });

View File

@ -28,7 +28,9 @@ import { v4 as uuid } from 'uuid';
import { import {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
createExampleTelemetryObject createExampleTelemetryObject,
setRealTimeMode,
setStartOffset
} from '../../../../appActions.js'; } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
@ -38,7 +40,7 @@ test.describe('Gauge', () => {
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
}); });
test('Can add and remove telemetry sources @unstable', async ({ page }) => { test('Can add and remove telemetry sources', async ({ page }) => {
// Create the gauge with defaults // Create the gauge with defaults
const gauge = await createDomainObjectWithDefaults(page, { type: 'Gauge' }); const gauge = await createDomainObjectWithDefaults(page, { type: 'Gauge' });
@ -53,6 +55,7 @@ test.describe('Gauge', () => {
// the SWG appears in the elements pool // the SWG appears in the elements pool
await page.goto(gauge.url); await page.goto(gauge.url);
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
await page.getByRole('tab', { name: 'Elements' }).click();
await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeVisible(); await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeVisible();
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
@ -65,38 +68,35 @@ test.describe('Gauge', () => {
}); });
// Verify that the 'Replace telemetry source' modal appears and accept it // Verify that the 'Replace telemetry source' modal appears and accept it
await expect await expect(
.soft( page.getByText(
page.locator( 'This action will replace the current telemetry source. Do you want to continue?'
'text=This action will replace the current telemetry source. Do you want to continue?'
) )
) ).toBeVisible();
.toBeVisible(); await page.getByRole('button', { name: 'Ok', exact: true }).click();
await page.click('text=Ok');
// Navigate to the gauge and verify that the new SWG // Navigate to the gauge and verify that the new SWG
// appears in the elements pool and the old one is gone // appears in the elements pool and the old one is gone
await page.goto(gauge.url); await page.goto(gauge.url);
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg1.name}`)).toBeHidden(); await page.getByRole('tab', { name: 'Elements' }).click();
await expect.soft(page.locator(`#inspector-elements-tree >> text=${swg2.name}`)).toBeVisible(); await expect(page.getByLabel(`Preview ${swg1.name}`)).toBeHidden();
await expect(page.getByLabel(`Preview ${swg2.name}`)).toBeVisible();
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
// Right click on the new SWG in the elements pool and delete it // Right click on the new SWG in the elements pool and delete it
await page.locator(`#inspector-elements-tree >> text=${swg2.name}`).click({ await page.getByLabel(`Preview ${swg2.name}`).click({
button: 'right' button: 'right'
}); });
await page.locator('li[title="Remove this object from its containing object."]').click(); await page.getByLabel('Remove').click();
// Verify that the 'Remove object' confirmation modal appears and accept it // Verify that the 'Remove object' confirmation modal appears and accept it
await expect await expect(
.soft( page.getByText(
page.locator( 'Warning! This action will remove this object. Are you sure you want to continue?'
'text=Warning! This action will remove this object. Are you sure you want to continue?'
) )
) ).toBeVisible();
.toBeVisible(); await page.getByRole('button', { name: 'Ok', exact: true }).click();
await page.click('text=Ok');
// Verify that the elements pool shows no elements // Verify that the elements pool shows no elements
await expect(page.locator('text="No contained elements"')).toBeVisible(); await expect(page.locator('text="No contained elements"')).toBeVisible();
@ -110,11 +110,11 @@ test.describe('Gauge', () => {
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
// Click the object specified by 'type' // Click the object specified by 'type'
await page.click(`li[role='menuitem']:text("Gauge")`); await page.getByRole('menuitem', { name: 'Gauge' }).click();
// FIXME: We need better selectors for these custom form controls // FIXME: We need better selectors for these custom form controls
const displayCurrentValueSwitch = page.locator('.c-toggle-switch__slider >> nth=0'); const displayCurrentValueSwitch = page.locator('.c-toggle-switch__slider >> nth=0');
await displayCurrentValueSwitch.setChecked(false); await displayCurrentValueSwitch.uncheck();
await page.click('button[aria-label="Save"]'); await page.getByLabel('Save').click();
// TODO: Verify changes in the UI // TODO: Verify changes in the UI
}); });
@ -126,24 +126,21 @@ test.describe('Gauge', () => {
// Create the gauge with defaults // Create the gauge with defaults
await createDomainObjectWithDefaults(page, { type: 'Gauge' }); await createDomainObjectWithDefaults(page, { type: 'Gauge' });
await page.click('button[title="More actions"]'); await page.getByLabel('More actions').click();
await page.click('li[role="menuitem"]:has-text("Edit Properties")'); await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
// FIXME: We need better selectors for these custom form controls // FIXME: We need better selectors for these custom form controls
const displayCurrentValueSwitch = page.locator('.c-toggle-switch__slider >> nth=0'); const displayCurrentValueSwitch = page.locator('.c-toggle-switch__slider >> nth=0');
await displayCurrentValueSwitch.setChecked(false); await displayCurrentValueSwitch.uncheck();
await page.click('button[aria-label="Save"]'); await page.getByLabel('Save').click();
// TODO: Verify changes in the UI // TODO: Verify changes in the UI
}); });
test.fixme('Gauge does not display NaN when data not available', async ({ page }) => { test('Gauge does not display NaN when data not available', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7421'
});
// Create a Gauge // Create a Gauge
const gauge = await createDomainObjectWithDefaults(page, { const gauge = await createDomainObjectWithDefaults(page, {
type: 'Gauge' type: 'Gauge',
name: 'Gauge with no data'
}); });
// Create a Sine Wave Generator in the Gauge with a loading delay // Create a Sine Wave Generator in the Gauge with a loading delay
@ -154,7 +151,7 @@ test.describe('Gauge', () => {
await page.getByRole('menuitem', { name: /Edit Properties.../ }).click(); await page.getByRole('menuitem', { name: /Edit Properties.../ }).click();
//Edit Example Telemetry Object to include 5s loading Delay //Edit Example Telemetry Object to include 5s loading Delay
await page.locator('[aria-label="Loading Delay \\(ms\\)"]').fill('5000'); await page.getByLabel('Loading Delay (ms)', { exact: true }).fill('5000');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
@ -162,9 +159,64 @@ test.describe('Gauge', () => {
await page.waitForURL(`**/${gauge.uuid}/*`); await page.waitForURL(`**/${gauge.uuid}/*`);
// Nav to the Gauge // Nav to the Gauge
await page.goto(gauge.url); await page.goto(gauge.url, { waitUntil: 'domcontentloaded' });
const gaugeNoDataText = await page.locator('.js-dial-current-value tspan').textContent(); // Check that the value is not displayed
expect(gaugeNoDataText).toBe('--'); //TODO https://github.com/nasa/openmct/issues/7790 update this locator
await expect(page.getByTitle('Value is currently out of')).toHaveAttribute(
'aria-valuenow',
'--'
);
});
test('Gauge does not break when an object is missing', async ({ page }) => {
// Set up error listeners
const pageErrors = [];
// Listen for uncaught exceptions
page.on('pageerror', (err) => {
pageErrors.push(err.message);
});
await setRealTimeMode(page);
// Create a Gauge
const gauge = await createDomainObjectWithDefaults(page, {
type: 'Gauge',
name: 'Gauge with missing object'
});
// Create a Sine Wave Generator in the Gauge with a loading delay
const missingSWG = await createExampleTelemetryObject(page, gauge.uuid);
// Remove the object from local storage
await page.evaluate(
([missingObject]) => {
const mct = localStorage.getItem('mct');
const mctObjects = JSON.parse(mct);
delete mctObjects[missingObject.uuid];
localStorage.setItem('mct', JSON.stringify(mctObjects));
},
[missingSWG]
);
// Verify start bounds
await expect(page.getByLabel('Start offset: 00:30:00')).toBeVisible();
// Nav to the Gauge
await page.goto(gauge.url, { waitUntil: 'domcontentloaded' });
// adjust time bounds and ensure they are updated
await setStartOffset(page, {
startHours: '00',
startMins: '45',
startSecs: '00'
});
// Verify start bounds changed
await expect(page.getByLabel('Start offset: 00:45:00')).toBeVisible();
// // Verify no errors were thrown
expect(pageErrors).toHaveLength(0);
}); });
test('Gauge enforces composition policy', async ({ page }) => { test('Gauge enforces composition policy', async ({ page }) => {
@ -175,13 +227,13 @@ test.describe('Gauge', () => {
}); });
// Try to create a Folder into the Gauge. Should be disallowed. // Try to create a Folder into the Gauge. Should be disallowed.
await page.getByRole('button', { name: /Create/ }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('menuitem', { name: /Folder/ }).click(); await page.getByRole('menuitem', { name: /Folder/ }).click();
await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
await page.getByLabel('Cancel').click(); await page.getByLabel('Cancel').click();
// Try to create a Display Layout into the Gauge. Should be disallowed. // Try to create a Display Layout into the Gauge. Should be disallowed.
await page.getByRole('button', { name: /Create/ }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('menuitem', { name: /Display Layout/ }).click(); await page.getByRole('menuitem', { name: /Display Layout/ }).click();
await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
}); });

View File

@ -24,11 +24,21 @@
This test suite is dedicated to tests which verify the basic operations surrounding imagery, This test suite is dedicated to tests which verify the basic operations surrounding imagery,
but only assume that example imagery is present. but only assume that example imagery is present.
*/ */
/* globals process */
import { createDomainObjectWithDefaults, setRealTimeMode } from '../../../../appActions.js'; import {
import { waitForAnimations } from '../../../../baseFixtures.js'; createDomainObjectWithDefaults,
navigateToObjectWithRealTime,
setRealTimeMode
} from '../../../../appActions.js';
import {
createImageryViewWithShortDelay,
FIVE_MINUTES,
IMAGE_LOAD_DELAY,
MOUSE_WHEEL_DELTA_Y,
THIRTY_SECONDS
} from '../../../../helper/imageryUtils.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
const backgroundImageSelector = '.c-imagery__main-image__background-image';
const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt']; const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt'];
const tagHotkey = ['Shift', 'Alt']; const tagHotkey = ['Shift', 'Alt'];
const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan'; const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan';
@ -45,8 +55,10 @@ test.describe('Example Imagery Object', () => {
// Verify that the created object is focused // Verify that the created object is focused
await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name); await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name);
await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); await page.getByLabel('Focused Image Element').hover({ trial: true });
await page.locator(backgroundImageSelector).waitFor();
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
}); });
test('Can use Mouse Wheel to zoom in and out of latest image', async ({ page }) => { test('Can use Mouse Wheel to zoom in and out of latest image', async ({ page }) => {
@ -63,9 +75,10 @@ test.describe('Example Imagery Object', () => {
test('Can right click on image and open it in a new tab @2p', async ({ page, context }) => { test('Can right click on image and open it in a new tab @2p', async ({ page, context }) => {
// try to right click on image // try to right click on image
const backgroundImage = await page.locator(backgroundImageSelector); const backgroundImage = page.getByLabel('Focused Image Element');
await backgroundImage.click({ await backgroundImage.click({
button: 'right', button: 'right',
// Need force option here due to annotation overlay which blocks playwright's click
// eslint-disable-next-line playwright/no-force-option // eslint-disable-next-line playwright/no-force-option
force: true force: true
}); });
@ -80,12 +93,9 @@ test.describe('Example Imagery Object', () => {
const newPage = await pagePromise; const newPage = await pagePromise;
await newPage.waitForLoadState(); await newPage.waitForLoadState();
// expect new tab url to have jpg in it // expect new tab url to have jpg in it
await expect(newPage.url()).toContain('.jpg'); expect(newPage.url()).toContain('.jpg');
}); });
// this requires CORS to be enabled in some fashion
test.fixme('Can right click on image and save it as a file', async ({ page }) => {});
test('Can adjust image brightness/contrast by dragging the sliders', async ({ test('Can adjust image brightness/contrast by dragging the sliders', async ({
page, page,
browserName browserName
@ -105,27 +115,36 @@ test.describe('Example Imagery Object', () => {
type: 'issue', type: 'issue',
description: 'https://github.com/nasa/openmct/issues/6821' description: 'https://github.com/nasa/openmct/issues/6821'
}); });
// Test independent fixed time with global fixed time // Test independent fixed time with global fixed time
// flip on independent time conductor // flip on independent time conductor
await page.getByRole('switch', { name: 'Enable Independent Time Conductor' }).click(); await page.getByLabel('Enable Independent Time Conductor').click();
// Adding in delay to address flakiness of ITC test-- button event handlers not registering in time
await expect(page.locator('#independentTCToggle')).toBeChecked(); await expect(page.locator('#independentTCToggle')).toBeChecked();
await expect(page.locator('.c-compact-tc').first()).toBeVisible(); await expect(page.locator('.c-compact-tc').first()).toBeVisible();
await expect(
page.getByRole('button', { name: 'Independent Time Conductor Settings' })
).toBeEnabled();
await page.getByRole('button', { name: 'Independent Time Conductor Settings' }).click(); await page.getByRole('button', { name: 'Independent Time Conductor Settings' }).click();
await expect(page.getByLabel('Time Conductor Options')).toBeVisible();
await page.getByLabel('Time Conductor Options').hover({ trial: true });
await page.getByRole('textbox', { name: 'Start date' }).hover({ trial: true });
await page.getByRole('textbox', { name: 'Start date' }).fill('2021-12-30'); await page.getByRole('textbox', { name: 'Start date' }).fill('2021-12-30');
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await page.getByRole('textbox', { name: 'Start time' }).hover({ trial: true });
await page.getByRole('textbox', { name: 'Start time' }).fill('01:01:00'); await page.getByRole('textbox', { name: 'Start time' }).fill('01:01:00');
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await page.getByRole('textbox', { name: 'End date' }).hover({ trial: true });
await page.getByRole('textbox', { name: 'End date' }).fill('2021-12-30'); await page.getByRole('textbox', { name: 'End date' }).fill('2021-12-30');
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await page.getByRole('textbox', { name: 'End time' }).hover({ trial: true });
await page.getByRole('textbox', { name: 'End time' }).fill('01:11:00'); await page.getByRole('textbox', { name: 'End time' }).fill('01:11:00');
await page.keyboard.press('Tab'); await page.getByRole('textbox', { name: 'End time' }).fill('01:11:00');
await page.keyboard.press('Enter'); await page.getByLabel('Submit time bounds').click();
// check image date // wait for image thumbnails to stabilize
await page.getByLabel('Image Thumbnails', { exact: true }).hover({ trial: true });
await expect(page.getByText('2021-12-30 01:01:00.000Z').first()).toBeVisible(); await expect(page.getByText('2021-12-30 01:01:00.000Z').first()).toBeVisible();
// flip it off // flip it off
@ -166,14 +185,11 @@ test.describe('Example Imagery Object', () => {
}); });
test('Can use alt+drag to move around image once zoomed in', async ({ page }) => { test('Can use alt+drag to move around image once zoomed in', async ({ page }) => {
const deltaYStep = 100; //equivalent to 1x zoom
await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); await page.locator('.c-imagery__main-image__bg').hover({ trial: true });
// zoom in // zoom in
await page.mouse.wheel(0, deltaYStep * 2); await page.mouse.wheel(0, MOUSE_WHEEL_DELTA_Y * 2);
await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); const zoomedBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox();
const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2; const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2;
const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2; const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2;
// move to the right // move to the right
@ -195,7 +211,7 @@ test.describe('Example Imagery Object', () => {
await page.mouse.move(imageCenterX - 200, imageCenterY, 10); await page.mouse.move(imageCenterX - 200, imageCenterY, 10);
await page.mouse.up(); await page.mouse.up();
await Promise.all(panHotkey.map((x) => page.keyboard.up(x))); await Promise.all(panHotkey.map((x) => page.keyboard.up(x)));
const afterRightPanBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const afterRightPanBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(zoomedBoundingBox.x).toBeGreaterThan(afterRightPanBoundingBox.x); expect(zoomedBoundingBox.x).toBeGreaterThan(afterRightPanBoundingBox.x);
// pan left // pan left
@ -204,7 +220,7 @@ test.describe('Example Imagery Object', () => {
await page.mouse.move(imageCenterX, imageCenterY, 10); await page.mouse.move(imageCenterX, imageCenterY, 10);
await page.mouse.up(); await page.mouse.up();
await Promise.all(panHotkey.map((x) => page.keyboard.up(x))); await Promise.all(panHotkey.map((x) => page.keyboard.up(x)));
const afterLeftPanBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const afterLeftPanBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(afterRightPanBoundingBox.x).toBeLessThan(afterLeftPanBoundingBox.x); expect(afterRightPanBoundingBox.x).toBeLessThan(afterLeftPanBoundingBox.x);
// pan up // pan up
@ -214,7 +230,7 @@ test.describe('Example Imagery Object', () => {
await page.mouse.move(imageCenterX, imageCenterY + 200, 10); await page.mouse.move(imageCenterX, imageCenterY + 200, 10);
await page.mouse.up(); await page.mouse.up();
await Promise.all(panHotkey.map((x) => page.keyboard.up(x))); await Promise.all(panHotkey.map((x) => page.keyboard.up(x)));
const afterUpPanBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const afterUpPanBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(afterUpPanBoundingBox.y).toBeGreaterThan(afterLeftPanBoundingBox.y); expect(afterUpPanBoundingBox.y).toBeGreaterThan(afterLeftPanBoundingBox.y);
// pan down // pan down
@ -223,7 +239,7 @@ test.describe('Example Imagery Object', () => {
await page.mouse.move(imageCenterX, imageCenterY - 200, 10); await page.mouse.move(imageCenterX, imageCenterY - 200, 10);
await page.mouse.up(); await page.mouse.up();
await Promise.all(panHotkey.map((x) => page.keyboard.up(x))); await Promise.all(panHotkey.map((x) => page.keyboard.up(x)));
const afterDownPanBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const afterDownPanBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(afterDownPanBoundingBox.y).toBeLessThan(afterUpPanBoundingBox.y); expect(afterDownPanBoundingBox.y).toBeLessThan(afterUpPanBoundingBox.y);
}); });
@ -282,26 +298,43 @@ test.describe('Example Imagery Object', () => {
await expect(page.getByText('Drilling')).toBeVisible(); await expect(page.getByText('Drilling')).toBeVisible();
}); });
test('Can use + - buttons to zoom on the image @unstable', async ({ page }) => { test('Can use + - buttons to zoom on the image', async ({ page }) => {
await buttonZoomOnImageAndAssert(page); await buttonZoomOnImageAndAssert(page);
}); });
test('Can use the reset button to reset the image @unstable', async ({ page }, testInfo) => { test('Can use the reset button to reset the image', async ({ page }) => {
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(1) translate(0px, 0px)'
);
// Get initial image dimensions // Get initial image dimensions
const initialBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const initialBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
// Zoom in twice via button // Zoom in twice via button
await zoomIntoImageryByButton(page); await zoomIntoImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(2) translate(0px, 0px)'
);
await zoomIntoImageryByButton(page); await zoomIntoImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(3) translate(0px, 0px)'
);
// Get and assert zoomed in image dimensions // Get and assert zoomed in image dimensions
const zoomedInBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const zoomedInBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect.soft(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height);
expect.soft(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width); expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width);
// Reset pan and zoom and assert against initial image dimensions // Reset pan and zoom and assert against initial image dimensions
await resetImageryPanAndZoom(page); await resetImageryPanAndZoom(page);
const finalBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(1) translate(0px, 0px)'
);
const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(finalBoundingBox).toEqual(initialBoundingBox); expect(finalBoundingBox).toEqual(initialBoundingBox);
}); });
@ -319,25 +352,25 @@ test.describe('Example Imagery Object', () => {
}); });
test('Uses low fetch priority', async ({ page }) => { test('Uses low fetch priority', async ({ page }) => {
const priority = await page.locator('.js-imageryView-image').getAttribute('fetchpriority'); const priority = page.locator('.js-imageryView-image');
expect(priority).toBe('low'); await expect(priority).toHaveAttribute('fetchpriority', 'low');
}); });
}); });
test.describe('Example Imagery in Display Layout', () => { test.describe('Example Imagery in Display Layout', () => {
let displayLayout; let displayLayout;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Go to baseURL // Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' }); displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' });
await page.goto(displayLayout.url);
await createImageryView(page); // Create Example Imagery inside Display Layout
await createImageryViewWithShortDelay(page, {
await expect(page.locator('.l-browse-bar__object-name')).toContainText( name: 'Unnamed Example Imagery',
'Unnamed Example Imagery' parent: displayLayout.uuid
); });
await page.goto(displayLayout.url); await page.goto(displayLayout.url);
}); });
@ -363,7 +396,7 @@ test.describe('Example Imagery in Display Layout', () => {
await page.locator('li[title="View Large"]').click(); await page.locator('li[title="View Large"]').click();
await expect(pausePlayButton).toHaveClass(/is-paused/); await expect(pausePlayButton).toHaveClass(/is-paused/);
await page.locator('[aria-label="Close"]').click(); await page.getByRole('button', { name: 'Close' }).click();
await expect.soft(pausePlayButton).not.toHaveClass(/is-paused/); await expect.soft(pausePlayButton).not.toHaveClass(/is-paused/);
}); });
@ -386,16 +419,11 @@ test.describe('Example Imagery in Display Layout', () => {
await page.locator('li[title="View Large"]').click(); await page.locator('li[title="View Large"]').click();
await expect(pausePlayButton).toHaveClass(/is-paused/); await expect(pausePlayButton).toHaveClass(/is-paused/);
await page.locator('[aria-label="Close"]').click(); await page.getByRole('button', { name: 'Close' }).click();
await expect.soft(pausePlayButton).toHaveClass(/is-paused/); await expect.soft(pausePlayButton).toHaveClass(/is-paused/);
}); });
test('Imagery View operations @unstable', async ({ page }) => { test('Imagery View operations', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
// Edit mode // Edit mode
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
@ -410,7 +438,9 @@ test.describe('Example Imagery in Display Layout', () => {
await page.locator('div[title="Resize object width"] > input').click(); await page.locator('div[title="Resize object width"] > input').click();
await page.locator('div[title="Resize object width"] > input').fill('50'); await page.locator('div[title="Resize object width"] > input').fill('50');
await performImageryViewOperationsAndAssert(page); await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
await performImageryViewOperationsAndAssert(page, displayLayout);
}); });
test('Resizing the layout changes thumbnail visibility and size', async ({ page }) => { test('Resizing the layout changes thumbnail visibility and size', async ({ page }) => {
@ -454,7 +484,10 @@ test.describe('Example Imagery in Display Layout', () => {
type: 'issue', type: 'issue',
description: 'https://github.com/nasa/openmct/issues/6709' description: 'https://github.com/nasa/openmct/issues/6709'
}); });
await createImageryView(page); await createImageryViewWithShortDelay(page, {
name: 'Unnamed Example Imagery',
parent: displayLayout.uuid
});
await page.goto(displayLayout.url); await page.goto(displayLayout.url);
const imageElements = page.locator('.c-imagery__main-image-wrapper'); const imageElements = page.locator('.c-imagery__main-image-wrapper');
@ -491,71 +524,43 @@ test.describe('Example Imagery in Flexible layout', () => {
flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' }); flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' });
// Create Example Imagery inside the Flexible Layout // Create Example Imagery inside the Flexible Layout
await createDomainObjectWithDefaults(page, { await createImageryViewWithShortDelay(page, {
type: 'Example Imagery', name: 'Unnamed Example Imagery',
parent: flexibleLayout.uuid parent: flexibleLayout.uuid
}); });
// Navigate back to Flexible Layout // Navigate back to Flexible Layout
await page.goto(flexibleLayout.url); await page.goto(flexibleLayout.url);
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
}); });
test('Can double-click on the image to view large image', async ({ page }) => { test('Can double-click on the image to view large image', async ({ page }) => {
// Double-click on the image to open large view // Double-click on the image to open large view
const imageElement = await page.getByRole('button', { name: 'Image Wrapper' }); const imageElement = page.getByRole('button', { name: 'Image Wrapper' });
await imageElement.dblclick(); await imageElement.dblclick();
// Check if the large view is visible // Check if the large view is visible
await page.getByRole('button', { name: 'Background Image', state: 'visible' }); page.getByRole('button', { name: 'Focused Image Element', state: 'visible' });
// Close the large view // Close the large view
await page.getByLabel('Close').click(); await page.getByRole('button', { name: 'Close' }).click();
}); });
test.beforeEach(async ({ page }) => { test('Imagery View operations', async ({ page, browserName }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' });
await page.goto(flexibleLayout.url);
/* Create Sine Wave Generator with minimum Image Load Delay */
// Click the Create button
await page.getByRole('button', { name: 'Create' }).click();
// Click text=Example Imagery
await page.click('li[role="menuitem"]:has-text("Example Imagery")');
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').fill('');
await page.locator('input[type="number"]').fill('5000');
// Click text=OK
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
page.click('button:has-text("OK")'),
//Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message')
]);
await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery'
);
await page.goto(flexibleLayout.url);
});
test('Imagery View operations @unstable', async ({ page, browserName }) => {
test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox'); test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox');
test.info().annotations.push({ test.info().annotations.push({
type: 'issue', type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5326' description: 'https://github.com/nasa/openmct/issues/5326'
}); });
await performImageryViewOperationsAndAssert(page); await performImageryViewOperationsAndAssert(page, flexibleLayout);
}); });
}); });
test.describe('Example Imagery in Tabs View', () => { test.describe('Example Imagery in Tabs View', () => {
let tabsView; let tabsView;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
@ -567,28 +572,25 @@ test.describe('Example Imagery in Tabs View', () => {
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
// Click text=Example Imagery // Click text=Example Imagery
await page.click('li[role="menuitem"]:has-text("Example Imagery")'); await page.getByRole('menuitem', { name: 'Example Imagery' }).click();
// Clear and set Image load delay to minimum value // Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').fill(''); await page.locator('input[type="number"]').clear();
await page.locator('input[type="number"]').fill('5000'); await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
// Click text=OK await page.getByLabel('Save').click();
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
page.click('button:has-text("OK")'),
//Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message')
]);
await expect(page.locator('.l-browse-bar__object-name')).toContainText( await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery' 'Unnamed Example Imagery'
); );
await page.goto(tabsView.url); await page.goto(tabsView.url);
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
}); });
test('Imagery View operations @unstable', async ({ page }) => {
await performImageryViewOperationsAndAssert(page); test('Imagery View operations', async ({ page }) => {
await performImageryViewOperationsAndAssert(page, tabsView);
}); });
}); });
@ -648,24 +650,28 @@ test.describe('Example Imagery in Time Strip', () => {
* 3. Can pan the image using the pan hotkey + mouse drag * 3. Can pan the image using the pan hotkey + mouse drag
* 4. Clicking on the left arrow button pauses imagery and moves to the previous image * 4. Clicking on the left arrow button pauses imagery and moves to the previous image
* 5. Imagery is updated as new images stream in, regardless of pause status * 5. Imagery is updated as new images stream in, regardless of pause status
* 6. Old images are discarded when new images stream in * 6. Old images are discarded when their timestamps fall out of bounds
* 7. Image brightness/contrast can be adjusted by dragging the sliders * 7. Multiple images can be discarded when their timestamps fall out of bounds
* 8. Image brightness/contrast can be adjusted by dragging the sliders
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function performImageryViewOperationsAndAssert(page) { async function performImageryViewOperationsAndAssert(page, layoutObject) {
// Verify that imagery thumbnails use a thumbnail url await test.step('Verify that imagery thumbnails use a thumbnail url', async () => {
const thumbnailImages = page.locator('.c-thumb__image'); const thumbnailImages = page.getByLabel('Image thumbnail from').locator('.c-thumb__image');
const mainImage = page.locator('.c-imagery__main-image__image'); const mainImage = page.locator('.c-imagery__main-image__image');
await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp); await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp);
await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp); await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp);
});
// Click previous image button // Click previous image button
const previousImageButton = page.locator('.c-nav--prev'); const previousImageButton = page.getByLabel('Previous image');
await previousImageButton.click(); await expect(previousImageButton).toBeVisible();
await page.getByLabel('Image Wrapper').hover({ trial: true });
// Verify previous image // Need to force click as the annotation canvas lies on top of the image
const selectedImage = page.locator('.selected'); // and fails the accessibility checks
await expect(selectedImage).toBeVisible(); // eslint-disable-next-line playwright/no-force-option
await previousImageButton.click({ force: true });
// Use the zoom buttons to zoom in and out // Use the zoom buttons to zoom in and out
await buttonZoomOnImageAndAssert(page); await buttonZoomOnImageAndAssert(page);
@ -680,45 +686,41 @@ async function performImageryViewOperationsAndAssert(page) {
await mouseZoomOnImageAndAssert(page, -2); await mouseZoomOnImageAndAssert(page, -2);
// Click next image button // Click next image button
const nextImageButton = page.locator('.c-nav--next'); const nextImageButton = page.getByLabel('Next image');
await nextImageButton.click(); await expect(nextImageButton).toBeVisible();
await page.getByLabel('Image Wrapper').hover({ trial: true });
// eslint-disable-next-line playwright/no-force-option
await nextImageButton.click({ force: true });
// set realtime mode // set realtime mode
await setRealTimeMode(page); await navigateToObjectWithRealTime(
page,
layoutObject.url,
`${FIVE_MINUTES}`,
`${THIRTY_SECONDS}`
);
// Verify previous image
await expect(previousImageButton).toBeVisible();
await page.getByLabel('Image Wrapper').hover({ trial: true });
// eslint-disable-next-line playwright/no-force-option
await previousImageButton.click({ force: true });
await page.locator('.active').click();
const selectedImage = page.locator('.selected');
await expect(selectedImage).toBeVisible();
// Zoom in on next image // Zoom in on next image
await mouseZoomOnImageAndAssert(page, 2); await mouseZoomOnImageAndAssert(page, 2);
// Clicking on the left arrow should pause the imagery and go to previous image // Clicking on the left arrow should pause the imagery and go to previous image
await previousImageButton.click(); await previousImageButton.click();
await expect(page.locator('.c-button.pause-play')).toHaveClass(/is-paused/); await expect(page.getByLabel('Pause automatic scrolling of image thumbnails')).toBeVisible();
await expect(selectedImage).toBeVisible(); await expect(selectedImage).toBeVisible();
// The imagery view should be updated when new images come in
const imageCount = await page.locator('.c-imagery__thumb').count();
await expect
.poll(
async () => {
const newImageCount = await page.locator('.c-imagery__thumb').count();
return newImageCount;
},
{
message: 'verify that old images are discarded',
timeout: 7 * 1000
}
)
.toBe(imageCount);
// Verify selected image is still displayed // Verify selected image is still displayed
await expect(selectedImage).toBeVisible(); await expect(selectedImage).toBeVisible();
// Unpause imagery // Unpause imagery
await page.locator('.pause-play').click(); await page.locator('.pause-play').click();
//Get background-image url from background-image css prop
await assertBackgroundImageUrlFromBackgroundCss(page);
// Open the image filter menu // Open the image filter menu
await page.locator('[role=toolbar] button[title="Brightness and contrast"]').click(); await page.locator('[role=toolbar] button[title="Brightness and contrast"]').click();
@ -773,7 +775,7 @@ async function dragContrastSliderAndAssertFilterValues(page) {
* Gets the filter:brightness value of the current background-image and * Gets the filter:brightness value of the current background-image and
* asserts against an expected value * asserts against an expected value
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {String} expected The expected brightness value * @param {string} expected The expected brightness value
*/ */
async function assertBackgroundImageBrightness(page, expected) { async function assertBackgroundImageBrightness(page, expected) {
const backgroundImage = page.locator('.c-imagery__main-image__background-image'); const backgroundImage = page.locator('.c-imagery__main-image__background-image');
@ -785,51 +787,12 @@ async function assertBackgroundImageBrightness(page, expected) {
expect(actual).toBe(expected); expect(actual).toBe(expected);
} }
/**
* @param {import('@playwright/test').Page} page
*/
async function assertBackgroundImageUrlFromBackgroundCss(page) {
const backgroundImage = page.locator('.c-imagery__main-image__background-image');
let backgroundImageUrl = await backgroundImage.evaluate((el) => {
return window
.getComputedStyle(el)
.getPropertyValue('background-image')
.match(/url\(([^)]+)\)/)[1];
});
let backgroundImageUrl1 = backgroundImageUrl.slice(1, -1); //forgive me, padre
console.log('backgroundImageUrl1 ' + backgroundImageUrl1);
let backgroundImageUrl2;
await expect
.poll(
async () => {
// Verify next image has updated
let backgroundImageUrlNext = await backgroundImage.evaluate((el) => {
return window
.getComputedStyle(el)
.getPropertyValue('background-image')
.match(/url\(([^)]+)\)/)[1];
});
backgroundImageUrl2 = backgroundImageUrlNext.slice(1, -1); //forgive me, padre
return backgroundImageUrl2;
},
{
message: 'verify next image has updated',
timeout: 7 * 1000
}
)
.not.toBe(backgroundImageUrl1);
console.log('backgroundImageUrl2 ' + backgroundImageUrl2);
}
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function panZoomAndAssertImageProperties(page) { async function panZoomAndAssertImageProperties(page) {
const imageryHintsText = await page.locator('.c-imagery__hints').innerText(); await expect(page.locator('.c-imagery__hints')).toContainText(expectedAltText);
expect(expectedAltText).toEqual(imageryHintsText); const zoomedBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox();
const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2; const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2;
const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2; const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2;
@ -839,7 +802,7 @@ async function panZoomAndAssertImageProperties(page) {
await page.mouse.move(imageCenterX - 200, imageCenterY, 10); await page.mouse.move(imageCenterX - 200, imageCenterY, 10);
await page.mouse.up(); await page.mouse.up();
await Promise.all(panHotkey.map((x) => page.keyboard.up(x))); await Promise.all(panHotkey.map((x) => page.keyboard.up(x)));
const afterRightPanBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const afterRightPanBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(zoomedBoundingBox.x).toBeGreaterThan(afterRightPanBoundingBox.x); expect(zoomedBoundingBox.x).toBeGreaterThan(afterRightPanBoundingBox.x);
// Pan left // Pan left
@ -848,7 +811,7 @@ async function panZoomAndAssertImageProperties(page) {
await page.mouse.move(imageCenterX, imageCenterY, 10); await page.mouse.move(imageCenterX, imageCenterY, 10);
await page.mouse.up(); await page.mouse.up();
await Promise.all(panHotkey.map((x) => page.keyboard.up(x))); await Promise.all(panHotkey.map((x) => page.keyboard.up(x)));
const afterLeftPanBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const afterLeftPanBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(afterRightPanBoundingBox.x).toBeLessThan(afterLeftPanBoundingBox.x); expect(afterRightPanBoundingBox.x).toBeLessThan(afterLeftPanBoundingBox.x);
// Pan up // Pan up
@ -858,7 +821,7 @@ async function panZoomAndAssertImageProperties(page) {
await page.mouse.move(imageCenterX, imageCenterY + 200, 10); await page.mouse.move(imageCenterX, imageCenterY + 200, 10);
await page.mouse.up(); await page.mouse.up();
await Promise.all(panHotkey.map((x) => page.keyboard.up(x))); await Promise.all(panHotkey.map((x) => page.keyboard.up(x)));
const afterUpPanBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const afterUpPanBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(afterUpPanBoundingBox.y).toBeGreaterThanOrEqual(afterLeftPanBoundingBox.y); expect(afterUpPanBoundingBox.y).toBeGreaterThanOrEqual(afterLeftPanBoundingBox.y);
// Pan down // Pan down
@ -867,7 +830,7 @@ async function panZoomAndAssertImageProperties(page) {
await page.mouse.move(imageCenterX, imageCenterY - 200, 10); await page.mouse.move(imageCenterX, imageCenterY - 200, 10);
await page.mouse.up(); await page.mouse.up();
await Promise.all(panHotkey.map((x) => page.keyboard.up(x))); await Promise.all(panHotkey.map((x) => page.keyboard.up(x)));
const afterDownPanBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const afterDownPanBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(afterDownPanBoundingBox.y).toBeLessThanOrEqual(afterUpPanBoundingBox.y); expect(afterDownPanBoundingBox.y).toBeLessThanOrEqual(afterUpPanBoundingBox.y);
} }
@ -879,19 +842,20 @@ async function panZoomAndAssertImageProperties(page) {
*/ */
async function mouseZoomOnImageAndAssert(page, factor = 2) { async function mouseZoomOnImageAndAssert(page, factor = 2) {
// Zoom in // Zoom in
const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox(); await page.getByLabel('Focused Image Element').hover({ trial: true });
const deltaYStep = 100; // equivalent to 1x zoom const originalImageDimensions = await page.getByLabel('Focused Image Element').boundingBox();
await page.mouse.wheel(0, deltaYStep * factor); await page.mouse.wheel(0, MOUSE_WHEEL_DELTA_Y * factor);
const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); await waitForZoomAndPanTransitions(page);
const zoomedBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2; const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2;
const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2; const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2;
// center the mouse pointer // center the mouse pointer
await page.mouse.move(imageCenterX, imageCenterY); await page.mouse.move(imageCenterX, imageCenterY);
// Wait for zoom animation to finish // Wait for zoom animation to finish and get the new image dimensions
await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); const imageMouseZoomed = await page.getByLabel('Focused Image Element').boundingBox();
const imageMouseZoomed = await page.locator(backgroundImageSelector).boundingBox();
if (factor > 0) { if (factor > 0) {
expect(imageMouseZoomed.height).toBeGreaterThan(originalImageDimensions.height); expect(imageMouseZoomed.height).toBeGreaterThan(originalImageDimensions.height);
@ -908,37 +872,73 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) {
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function buttonZoomOnImageAndAssert(page) { async function buttonZoomOnImageAndAssert(page) {
await test.step('Can zoom using buttons', async () => {
// Lock the zoom and pan so it doesn't reset if a new image comes in
await page.getByLabel('Focused Image Element').hover({ trial: true });
const lockButton = page.getByRole('button', {
name: 'Lock current zoom and pan across all images'
});
await lockButton.isVisible();
// if (!(await lockButton.isVisible())) {
// await page.getByLabel('Focused Image Element').hover({ trial: true });
// }
await lockButton.click();
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(1) translate(0px, 0px)'
);
// Get initial image dimensions // Get initial image dimensions
const initialBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const initialBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
// Zoom in twice via button // Zoom in twice via button
await zoomIntoImageryByButton(page); await zoomIntoImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(2) translate(0px, 0px)'
);
await zoomIntoImageryByButton(page); await zoomIntoImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(3) translate(0px, 0px)'
);
// Get and assert zoomed in image dimensions // Get and assert zoomed in image dimensions
const zoomedInBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const zoomedInBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height);
expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width); expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width);
// Zoom out once via button // Zoom out once via button
await zoomOutOfImageryByButton(page); await zoomOutOfImageryByButton(page);
await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(2) translate(0px, 0px)'
);
// Get and assert zoomed out image dimensions // Get and assert zoomed out image dimensions
const zoomedOutBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const zoomedOutBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height); expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height);
expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width); expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width);
// Zoom out again via button, assert against the initial image dimensions // Zoom out again via button, assert against the initial image dimensions
await zoomOutOfImageryByButton(page); await zoomOutOfImageryByButton(page);
const finalBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); await expect(page.getByLabel('Focused Image Element')).toHaveJSProperty(
'style.transform',
'scale(1) translate(0px, 0px)'
);
const finalBoundingBox = await page.getByLabel('Focused Image Element').boundingBox();
expect(finalBoundingBox).toEqual(initialBoundingBox); expect(finalBoundingBox).toEqual(initialBoundingBox);
});
} }
/** /**
* Gets the filter:contrast value of the current background-image and * Gets the filter:contrast value of the current background-image and
* asserts against an expected value * asserts against an expected value
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
* @param {String} expected The expected contrast value * @param {string} expected The expected contrast value
*/ */
async function assertBackgroundImageContrast(page, expected) { async function assertBackgroundImageContrast(page, expected) {
const backgroundImage = page.locator('.c-imagery__main-image__background-image'); const backgroundImage = page.locator('.c-imagery__main-image__background-image');
@ -957,16 +957,11 @@ async function assertBackgroundImageContrast(page, expected) {
*/ */
async function zoomIntoImageryByButton(page) { async function zoomIntoImageryByButton(page) {
// FIXME: There should only be one set of imagery buttons, but there are two? // FIXME: There should only be one set of imagery buttons, but there are two?
const zoomInBtn = page const zoomInBtn = page.getByRole('button', { name: 'Zoom in' });
.locator("[role='toolbar'][aria-label='Image controls'] .t-btn-zoom-in") const backgroundImage = page.getByLabel('Focused Image Element');
.nth(0);
const backgroundImage = page.locator(backgroundImageSelector);
if (!(await zoomInBtn.isVisible())) {
await backgroundImage.hover({ trial: true }); await backgroundImage.hover({ trial: true });
}
await zoomInBtn.click(); await zoomInBtn.click();
await waitForAnimations(backgroundImage); await waitForZoomAndPanTransitions(page);
} }
/** /**
@ -975,17 +970,11 @@ async function zoomIntoImageryByButton(page) {
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function zoomOutOfImageryByButton(page) { async function zoomOutOfImageryByButton(page) {
// FIXME: There should only be one set of imagery buttons, but there are two? const zoomOutBtn = page.getByRole('button', { name: 'Zoom out' });
const zoomOutBtn = page const backgroundImage = page.getByLabel('Focused Image Element');
.locator("[role='toolbar'][aria-label='Image controls'] .t-btn-zoom-out")
.nth(0);
const backgroundImage = page.locator(backgroundImageSelector);
if (!(await zoomOutBtn.isVisible())) {
await backgroundImage.hover({ trial: true }); await backgroundImage.hover({ trial: true });
}
await zoomOutBtn.click(); await zoomOutBtn.click();
await waitForAnimations(backgroundImage); await waitForZoomAndPanTransitions(page);
} }
/** /**
@ -994,38 +983,24 @@ async function zoomOutOfImageryByButton(page) {
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function resetImageryPanAndZoom(page) { async function resetImageryPanAndZoom(page) {
// FIXME: There should only be one set of imagery buttons, but there are two? const panZoomResetBtn = page.getByRole('button', { name: 'Remove zoom and pan' });
const panZoomResetBtn = page await expect(panZoomResetBtn).toBeVisible();
.locator("[role='toolbar'][aria-label='Image controls'] .t-btn-zoom-reset") await panZoomResetBtn.hover({ trial: true });
.nth(0);
const backgroundImage = page.locator(backgroundImageSelector);
if (!(await panZoomResetBtn.isVisible())) {
await backgroundImage.hover({ trial: true });
}
await panZoomResetBtn.click(); await panZoomResetBtn.click();
await waitForAnimations(backgroundImage);
await waitForZoomAndPanTransitions(page);
await expect(page.getByText('Alt drag to pan')).toBeHidden();
await expect(page.locator('.c-thumb__viewable-area')).toBeHidden();
} }
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */
async function createImageryView(page) { async function waitForZoomAndPanTransitions(page) {
// Click the Create button // Wait for image to stabilize
await page.getByRole('button', { name: 'Create' }).click(); await page.getByLabel('Focused Image Element').hover({ trial: true });
// Wait for zoom to end
// Click text=Example Imagery await expect(page.getByLabel('Focused Image Element')).not.toHaveClass(/is-zooming|is-panning/);
await page.click('li[role="menuitem"]:has-text("Example Imagery")'); // Wait for image to stabilize
await page.getByLabel('Focused Image Element').hover({ trial: true });
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').fill('');
await page.locator('input[type="number"]').fill('5000');
// Click text=OK
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
page.click('button:has-text("OK")'),
//Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message')
]);
} }

View File

@ -0,0 +1,489 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*
This test suite is dedicated to testing how imagery functions over time.
It only assumes that example imagery is present.
It uses https://playwright.dev/docs/clock to have control over time
*/
import {
createDomainObjectWithDefaults,
navigateToObjectWithRealTime,
setRealTimeMode,
setStartOffset
} from '../../../../appActions.js';
import { MISSION_TIME } from '../../../../constants.js';
import {
createImageryViewWithShortDelay,
FIVE_MINUTES,
IMAGE_LOAD_DELAY,
THIRTY_SECONDS
} from '../../../../helper/imageryUtils.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Example Imagery Object with Controlled Clock @clock', () => {
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
//Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create a default 'Example Imagery' object
// Click the Create button
await page.getByRole('button', { name: 'Create' }).click();
// Click text=Example Imagery
await page.getByRole('menuitem', { name: 'Example Imagery' }).click();
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').clear();
await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
await page.getByLabel('Save').click();
// Verify that the created object is focused
await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery'
);
await page.getByLabel('Focused Image Element').hover({ trial: true });
// set realtime mode
await setRealTimeMode(page);
await setStartOffset(page, { startMins: '05' });
});
test('Imagery Time Bounding', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('Image thumbnail from').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('title');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('title');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
test('Get background-image url from background-image css prop', async ({ page }) => {
await assertBackgroundImageUrlFromBackgroundCss(page);
});
});
test.describe('Example Imagery in Display Layout with Controlled Clock @clock', () => {
let displayLayout;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
// Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' });
// Create Example Imagery inside Display Layout
await createImageryViewWithShortDelay(page, {
name: 'Unnamed Example Imagery',
parent: displayLayout.uuid
});
// set realtime mode
await navigateToObjectWithRealTime(
page,
displayLayout.url,
`${FIVE_MINUTES}`,
`${THIRTY_SECONDS}`
);
});
test('Imagery Time Bounding', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// Edit mode
await page.getByLabel('Edit Object').click();
// Click on example imagery to expose toolbar
await page.locator('.c-so-view__header').click();
// Adjust object height
await page.locator('div[title="Resize object height"] > input').click();
await page.locator('div[title="Resize object height"] > input').fill('50');
// Adjust object width
await page.locator('div[title="Resize object width"] > input').click();
await page.locator('div[title="Resize object width"] > input').fill('50');
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('Image thumbnail from').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('title');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('title');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
test('Get background-image url from background-image css prop @clock', async ({ page }) => {
await assertBackgroundImageUrlFromBackgroundCss(page);
});
});
test.describe('Example Imagery in Flexible layout with Controlled Clock @clock', () => {
let flexibleLayout;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
flexibleLayout = await createDomainObjectWithDefaults(page, { type: 'Flexible Layout' });
// Create Example Imagery inside the Flexible Layout
await createImageryViewWithShortDelay(page, {
name: 'Unnamed Example Imagery',
parent: flexibleLayout.uuid
});
// set realtime mode
await navigateToObjectWithRealTime(
page,
flexibleLayout.url,
`${FIVE_MINUTES}`,
`${THIRTY_SECONDS}`
);
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
});
test('Imagery Time Bounding @clock', async ({ page, browserName }) => {
test.fixme(browserName === 'firefox', 'This test needs to be updated to work with firefox');
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5326'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('Image thumbnail from').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('title');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('title');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
test('Get background-image url from background-image css prop @clock', async ({ page }) => {
await assertBackgroundImageUrlFromBackgroundCss(page);
});
});
test.describe('Example Imagery in Tabs View with Controlled Clock @clock', () => {
let timeStripObject;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
timeStripObject = await createDomainObjectWithDefaults(page, { type: 'Tabs View' });
await page.goto(timeStripObject.url);
/* Create Example Imagery with minimum Image Load Delay */
// Click the Create button
await page.getByRole('button', { name: 'Create' }).click();
// Click text=Example Imagery
await page.getByRole('menuitem', { name: 'Example Imagery' }).click();
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').clear();
await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
await page.getByLabel('Save').click();
await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery'
);
// set realtime mode
await navigateToObjectWithRealTime(
page,
timeStripObject.url,
`${FIVE_MINUTES}`,
`${THIRTY_SECONDS}`
);
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
});
test('Imagery Time Bounding @clock', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('Image thumbnail from').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('title');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('title');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page
.getByLabel('Image thumbnail from')
.nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('title');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
test('Get background-image url from background-image css prop @clock', async ({ page }) => {
await assertBackgroundImageUrlFromBackgroundCss(page);
});
});
test.describe('Example Imagery in Time Strip with Controlled Clock @clock', () => {
let timeStripObject;
test.beforeEach(async ({ page }) => {
// We mock the clock so that we don't need to wait for time driven events
// to verify functionality.
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
timeStripObject = await createDomainObjectWithDefaults(page, { type: 'Time Strip' });
await page.goto(timeStripObject.url);
/* Create Example Imagery with minimum Image Load Delay */
// Click the Create button
await page.getByRole('button', { name: 'Create' }).click();
// Click text=Example Imagery
await page.getByRole('menuitem', { name: 'Example Imagery' }).click();
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').clear();
await page.locator('input[type="number"]').fill(`${IMAGE_LOAD_DELAY}`);
await page.getByLabel('Save').click();
await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery'
);
// set realtime mode
await navigateToObjectWithRealTime(
page,
timeStripObject.url,
`${FIVE_MINUTES}`,
`${THIRTY_SECONDS}`
);
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('wrapper-').last()).toBeInViewport();
});
test('Imagery Time Bounding @clock', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/5265'
});
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7825'
});
// verify that old images are discarded
const lastImageInBounds = page.getByLabel('wrapper-').first();
const lastImageTimestamp = await lastImageInBounds.getAttribute('aria-label');
expect(lastImageTimestamp).not.toBeNull();
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(page.getByLabel(lastImageTimestamp)).toBeHidden();
// go way forward in time to ensure multiple images are discarded
const IMAGES_TO_DISCARD_COUNT = 5;
const lastImageToDiscard = page.getByLabel('wrapper-').nth(IMAGES_TO_DISCARD_COUNT - 1);
const lastImageToDiscardTimestamp = await lastImageToDiscard.getAttribute('aria-label');
expect(lastImageToDiscardTimestamp).not.toBeNull();
const imageAfterLastImageToDiscard = page.getByLabel('wrapper-').nth(IMAGES_TO_DISCARD_COUNT);
const imageAfterLastImageToDiscardTimestamp =
await imageAfterLastImageToDiscard.getAttribute('aria-label');
expect(imageAfterLastImageToDiscardTimestamp).not.toBeNull();
await page.clock.fastForward(IMAGE_LOAD_DELAY * IMAGES_TO_DISCARD_COUNT);
await page.clock.resume();
await expect(page.getByLabel(lastImageToDiscardTimestamp)).toBeHidden();
await expect(page.getByLabel(imageAfterLastImageToDiscardTimestamp)).toBeVisible();
});
});
/**
* @param {import('@playwright/test').Page} page
*/
async function assertBackgroundImageUrlFromBackgroundCss(page) {
const backgroundImage = page.getByLabel('Focused Image Element');
const backgroundImageUrl = await backgroundImage.evaluate((el) => {
return window
.getComputedStyle(el)
.getPropertyValue('background-image')
.match(/url\(([^)]+)\)/)[1];
});
// go forward in time to ensure old images are discarded
await page.clock.fastForward(IMAGE_LOAD_DELAY);
await page.clock.resume();
await expect(backgroundImage).not.toHaveJSProperty('background-image', backgroundImageUrl);
}

View File

@ -0,0 +1,93 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*
* This test suite verifies modifying the image location of the example imagery object.
*/
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Example Imagery Object Custom Images', () => {
let exampleImagery;
test.beforeEach(async ({ page }) => {
//Go to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create a default 'Example Imagery' object
exampleImagery = await createDomainObjectWithDefaults(page, {
name: 'Example Imagery',
type: 'Example Imagery'
});
// Verify that the created object is focused
await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name);
await page.getByLabel('Focused Image Element').hover({ trial: true });
// Wait for image thumbnail auto-scroll to complete
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
});
// this requires CORS to be enabled in some fashion
test.fixme('Can right click on image and save it as a file', async ({ page }) => {});
test('Can provide a custom image location for the example imagery object', async ({ page }) => {
// Modify Example Imagery to create a really stable image which will never let us down
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page
.locator('#imageLocation-textarea')
.fill(
'https://raw.githubusercontent.com/nasa/openmct/554f77c42fec81cf0f63e62b278012cb08d82af9/e2e/test-data/rick.jpg,https://raw.githubusercontent.com/nasa/openmct/554f77c42fec81cf0f63e62b278012cb08d82af9/e2e/test-data/rick.jpg'
);
await page.getByRole('button', { name: 'Save' }).click();
await page.reload({ waitUntil: 'domcontentloaded' });
// Wait for the thumbnails to finish their scroll animation
// (Wait until the rightmost thumbnail is in view)
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
});
test.fixme('Can provide a custom image with spaces in name', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7903'
});
await page.goto(exampleImagery.url, { waitUntil: 'domcontentloaded' });
// Modify Example Imagery to create a really stable image which will never let us down
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
await page
.locator('#imageLocation-textarea')
.fill(
'https://raw.githubusercontent.com/nasa/openmct/d8c64f183400afb70137221fc1a035e091bea912/e2e/test-data/rick%20space%20roll.jpg'
);
await page.getByRole('button', { name: 'Save' }).click();
await page.reload({ waitUntil: 'domcontentloaded' });
// Wait for the thumbnails to finish their scroll animation
// (Wait until the rightmost thumbnail is in view)
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
});
});

View File

@ -26,10 +26,7 @@ This test suite is dedicated to tests which verify the basic operations surround
import fs from 'fs/promises'; import fs from 'fs/promises';
import { import { createDomainObjectWithDefaults } from '../../../../appActions.js';
createDomainObjectWithDefaults,
openObjectTreeContextMenu
} from '../../../../appActions.js';
import { expect, test } from '../../../../baseFixtures.js'; import { expect, test } from '../../../../baseFixtures.js';
import { navigateToFaultManagementWithExample } from '../../../../helper/faultUtils.js'; import { navigateToFaultManagementWithExample } from '../../../../helper/faultUtils.js';
@ -51,7 +48,10 @@ test.describe('ExportAsJSON', () => {
await page.goto(folder.url); await page.goto(folder.url);
// Open context menu and initiate download // Open context menu and initiate download
await openObjectTreeContextMenu(page, folder.url); await page.getByLabel('Show selected item in tree').click();
await page.getByRole('treeitem', { name: 'Expand e2e folder folder' }).click({
button: 'right'
});
const [download] = await Promise.all([ const [download] = await Promise.all([
page.waitForEvent('download'), // Waits for the download event page.waitForEvent('download'), // Waits for the download event
page.getByLabel('Export as JSON').click() // Triggers the download page.getByLabel('Export as JSON').click() // Triggers the download
@ -105,7 +105,12 @@ test.describe('ExportAsJSON', () => {
await page.goto(timer.url); await page.goto(timer.url);
//do this against parent folder.url, NOT timer.url child //do this against parent folder.url, NOT timer.url child
await openObjectTreeContextMenu(page, folder.url); // Open context menu and initiate download
await page.getByLabel('Show selected item in tree').click();
await page.getByRole('treeitem', { name: 'Collapse e2e folder folder' }).click({
button: 'right'
});
// Open context menu and initiate download // Open context menu and initiate download
const [download] = await Promise.all([ const [download] = await Promise.all([
page.waitForEvent('download'), // Waits for the download event page.waitForEvent('download'), // Waits for the download event
@ -141,18 +146,18 @@ test.describe('ExportAsJSON Disabled Actions', () => {
}); });
test('Verify that the ExportAsJSON dropdown does not appear for the item X', async ({ page }) => { test('Verify that the ExportAsJSON dropdown does not appear for the item X', async ({ page }) => {
await page.getByLabel('More actions').click(); await page.getByLabel('More actions').click();
await expect(await page.getByLabel('Export as JSON')).toHaveCount(0); await expect(page.getByLabel('Export as JSON')).toHaveCount(0);
await page.getByRole('treeitem', { name: 'Fault Management' }).click({ await page.getByRole('treeitem', { name: 'Fault Management' }).click({
button: 'right' button: 'right'
}); });
await expect(await page.getByLabel('Export as JSON')).toHaveCount(0); await expect(page.getByLabel('Export as JSON')).toHaveCount(0);
}); });
}); });
test.describe('ExportAsJSON ProgressBar @couchdb', () => { test.describe('ExportAsJSON ProgressBar @couchdb', () => {
let folder; let folder;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'networkidle' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
// Perform actions to create the domain object // Perform actions to create the domain object
folder = await createDomainObjectWithDefaults(page, { folder = await createDomainObjectWithDefaults(page, {
type: 'Folder' type: 'Folder'

View File

@ -27,7 +27,6 @@ import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Testing numeric data with inspector data visualization (i.e., data pivoting)', () => { test.describe('Testing numeric data with inspector data visualization (i.e., data pivoting)', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// eslint-disable-next-line no-undef
await page.addInitScript({ await page.addInitScript({
path: fileURLToPath( path: fileURLToPath(
new URL('../../../../helper/addInitDataVisualization.js', import.meta.url) new URL('../../../../helper/addInitDataVisualization.js', import.meta.url)
@ -37,6 +36,8 @@ test.describe('Testing numeric data with inspector data visualization (i.e., dat
}); });
test('Can click on telemetry and see data in inspector @2p', async ({ page, context }) => { test('Can click on telemetry and see data in inspector @2p', async ({ page, context }) => {
const initStartBounds = page.getByLabel('Start bounds');
const initEndBounds = await page.getByLabel('End bounds').textContent();
const exampleDataVisualizationSource = await createDomainObjectWithDefaults(page, { const exampleDataVisualizationSource = await createDomainObjectWithDefaults(page, {
type: 'Example Data Visualization Source' type: 'Example Data Visualization Source'
}); });
@ -78,5 +79,11 @@ test.describe('Testing numeric data with inspector data visualization (i.e., dat
await newPage.waitForLoadState(); await newPage.waitForLoadState();
// expect new tab title to contain 'Second Sine Wave Generator' // expect new tab title to contain 'Second Sine Wave Generator'
await expect(newPage).toHaveTitle('Second Sine Wave Generator'); await expect(newPage).toHaveTitle('Second Sine Wave Generator');
// Verify that "Open in New Tab" preserves the time bounds
await expect(initStartBounds).toHaveText(
await newPage.getByLabel('Start bounds').textContent()
);
expect(initEndBounds).toEqual(await newPage.getByLabel('End bounds').textContent());
}); });
}); });

View File

@ -22,7 +22,7 @@
import { import {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
openObjectTreeContextMenu, navigateToObjectWithRealTime,
setFixedTimeMode, setFixedTimeMode,
setRealTimeMode, setRealTimeMode,
setStartOffset setStartOffset
@ -56,61 +56,61 @@ test.describe('Testing LAD table configuration', () => {
await page.getByRole('tab', { name: 'LAD Table Configuration' }).click(); await page.getByRole('tab', { name: 'LAD Table Configuration' }).click();
// make sure headers are visible initially // make sure headers are visible initially
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Units' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
// hide timestamp column // hide timestamp column
await page.getByLabel('Timestamp', { exact: true }).uncheck(); await page.getByLabel('Timestamp', { exact: true }).uncheck();
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Units' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
// hide units & type column // hide units & type column
await page.getByLabel('Units').uncheck(); await page.getByLabel('Units').uncheck();
await page.getByLabel('Type', { exact: true }).uncheck(); await page.getByLabel('Type', { exact: true }).uncheck();
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Units' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeHidden();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
// hide WATCH column // hide WATCH column
await page.getByLabel('WATCH').uncheck(); await page.getByLabel('WATCH').uncheck();
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Units' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeHidden();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
// save and reload and verify they columns are still hidden // save and reload and verify they columns are still hidden
await page.locator('button[title="Save"]').click(); await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
await page.reload(); await page.reload();
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Units' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeHidden();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
// Edit LAD table // Edit LAD table
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
@ -118,27 +118,27 @@ test.describe('Testing LAD table configuration', () => {
// show timestamp column // show timestamp column
await page.getByLabel('Timestamp', { exact: true }).check(); await page.getByLabel('Timestamp', { exact: true }).check();
await expect(page.getByRole('cell', { name: 'Units' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
// save and reload and make sure timestamp is still visible // save and reload and make sure timestamp is still visible
await page.locator('button[title="Save"]').click(); await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
await page.reload(); await page.reload();
await expect(page.getByRole('cell', { name: 'Units' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeHidden();
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
// Edit LAD table // Edit LAD table
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
@ -148,27 +148,27 @@ test.describe('Testing LAD table configuration', () => {
await page.getByLabel('Units').check(); await page.getByLabel('Units').check();
await page.getByLabel('Type', { exact: true }).check(); await page.getByLabel('Type', { exact: true }).check();
await page.getByLabel('WATCH').check(); await page.getByLabel('WATCH').check();
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Units' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
// save and reload and make sure all columns are still visible // save and reload and make sure all columns are still visible
await page.locator('button[title="Save"]').click(); await page.locator('button[title="Save"]').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
await page.reload(); await page.reload();
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Units' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
}); });
test('When adding something without Units, do not show Units column', async ({ page }) => { test('When adding something without Units, do not show Units column', async ({ page }) => {
@ -185,14 +185,14 @@ test.describe('Testing LAD table configuration', () => {
await page.getByRole('tab', { name: 'LAD Table Configuration' }).click(); await page.getByRole('tab', { name: 'LAD Table Configuration' }).click();
// make sure Sine Wave headers are visible initially too // make sure Sine Wave headers are visible initially too
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Units' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeVisible();
// save and reload and verify they columns are still hidden // save and reload and verify they columns are still hidden
await page.getByLabel('Save').click(); await page.getByLabel('Save').click();
@ -201,25 +201,25 @@ test.describe('Testing LAD table configuration', () => {
// Remove Sine Wave Generator // Remove Sine Wave Generator
openObjectTreeContextMenu(page, sineWaveObject.url); openObjectTreeContextMenu(page, sineWaveObject.url);
await page.getByRole('menuitem', { name: /Remove/ }).click(); await page.getByRole('menuitem', { name: /Remove/ }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click(); await page.getByRole('button', { name: 'Ok', exact: true }).click();
// Ensure Units & Limit columns are gone // Ensure Units & Limit columns are gone
// as Event Generator don't have them // as Event Generator don't have them
await page.goto(ladTable.url); await page.goto(ladTable.url);
await expect(page.getByRole('cell', { name: 'Timestamp', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Timestamp', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Type', exact: true })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Type', exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'Units' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Units' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'WATCH' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Limit WATCH' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'WARNING' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Limit WARNING' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'DISTRESS' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Limit DISTRESS' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'CRITICAL' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Limit CRITICAL' })).toBeHidden();
await expect(page.getByRole('cell', { name: 'SEVERE' })).toBeHidden(); await expect(page.getByRole('columnheader', { name: 'Limit SEVERE' })).toBeHidden();
}); });
test("LAD Tables don't allow selection of rows but does show context click menus", async ({ test("LAD Tables don't allow selection of rows but does show context click menus", async ({
page page
}) => { }) => {
const cell = await page.locator('.js-first-data'); const cell = page.locator('.js-first-data');
const userSelectable = await cell.evaluate((el) => { const userSelectable = await cell.evaluate((el) => {
return window.getComputedStyle(el).getPropertyValue('user-select'); return window.getComputedStyle(el).getPropertyValue('user-select');
}); });
@ -237,19 +237,21 @@ test.describe('Testing LAD table configuration', () => {
}); });
}); });
test.describe('Testing LAD table @unstable', () => { test.describe('Testing LAD table', () => {
let sineWaveObject; let sineWaveObject;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
await setRealTimeMode(page);
// Create Sine Wave Generator // Create Sine Wave Generator
sineWaveObject = await createDomainObjectWithDefaults(page, { sineWaveObject = await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator', type: 'Sine Wave Generator',
name: 'Test Sine Wave Generator' name: 'Test Sine Wave Generator'
}); });
// Switch to real time mode by navigating directly to the URL
await navigateToObjectWithRealTime(page, sineWaveObject.url);
}); });
test('telemetry value exactly matches latest telemetry value received in real time', async ({ test('telemetry value exactly matches latest telemetry value received in realtime mode', async ({
page page
}) => { }) => {
// Create LAD table // Create LAD table
@ -261,23 +263,23 @@ test.describe('Testing LAD table @unstable', () => {
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
// Expand the 'My Items' folder in the left tree // Expand the 'My Items' folder in the left tree
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); await page.getByLabel('Expand My Items').click();
// Add the Sine Wave Generator to the LAD table and save changes // Add the Sine Wave Generator to the LAD table and save changes
await page.dragAndDrop('text=Test Sine Wave Generator', '.c-lad-table-wrapper'); await page.getByLabel('Preview Test Sine Wave').dragTo(page.locator('#lad-table-drop-area'));
await page.locator('button[title="Save"]').click(); await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Subscribe to the Sine Wave Generator data // Subscribe to the Sine Wave Generator data
// On getting data, check if the value found in the LAD table is the most recent value // On getting data, check if the value found in the LAD table is the most recent value
// from the Sine Wave Generator // from the Sine Wave Generator
const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); const getTelemValuePromise = subscribeToTelemetry(page, sineWaveObject.uuid);
const subscribeTelemValue = await getTelemValuePromise; const subscribeTelemValue = await getTelemValuePromise;
const ladTableValuePromise = await page.waitForSelector(`text="${subscribeTelemValue}"`); await expect(page.getByLabel('lad value')).toHaveText(subscribeTelemValue);
const ladTableValue = await ladTableValuePromise.textContent(); const ladTableValue = await page.getByText(subscribeTelemValue).textContent();
expect(ladTableValue).toBe(subscribeTelemValue); expect(ladTableValue).toEqual(subscribeTelemValue);
}); });
test('telemetry value exactly matches latest telemetry value received in fixed time', async ({ test('telemetry value exactly matches latest telemetry value received in fixed time mode', async ({
page page
}) => { }) => {
// Create LAD table // Create LAD table
@ -289,25 +291,23 @@ test.describe('Testing LAD table @unstable', () => {
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
// Expand the 'My Items' folder in the left tree // Expand the 'My Items' folder in the left tree
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); await page.getByLabel('Expand My Items').click();
// Add the Sine Wave Generator to the LAD table and save changes // Add the Sine Wave Generator to the LAD table and save changes
await page.dragAndDrop('text=Test Sine Wave Generator', '.c-lad-table-wrapper'); await page.getByLabel('Preview Test Sine Wave').dragTo(page.locator('#lad-table-drop-area'));
await page.locator('button[title="Save"]').click(); await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(); await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Subscribe to the Sine Wave Generator data // Subscribe to the Sine Wave Generator data
const getTelemValuePromise = await subscribeToTelemetry(page, sineWaveObject.uuid); const getTelemValuePromise = subscribeToTelemetry(page, sineWaveObject.uuid);
// Set an offset of 1 minute and then change the time mode to fixed to set a 1 minute historical window // Set an offset of 1 minute and then change the time mode to fixed to set a 1 minute historical window
await setStartOffset(page, { mins: '1' }); await setRealTimeMode(page);
await setStartOffset(page, { startMins: '01' });
await setFixedTimeMode(page); await setFixedTimeMode(page);
// On getting data, check if the value found in the LAD table is the most recent value // On getting data, check if the value found in the LAD table is the most recent value
// from the Sine Wave Generator // from the Sine Wave Generator
const subscribeTelemValue = await getTelemValuePromise; const subscribeTelemValue = await getTelemValuePromise;
const ladTableValuePromise = await page.waitForSelector(`text="${subscribeTelemValue}"`); await expect(page.getByLabel('lad value')).toHaveText(subscribeTelemValue);
const ladTableValue = await ladTableValuePromise.textContent();
expect(ladTableValue).toBe(subscribeTelemValue);
}); });
}); });
@ -338,3 +338,18 @@ async function subscribeToTelemetry(page, objectIdentifier) {
return getTelemValuePromise; return getTelemValuePromise;
} }
/**
* Open the given `domainObject`'s context menu from the object tree.
* Expands the path to the object and scrolls to it if necessary.
*
* @param {import('@playwright/test').Page} page
* @param {string} url the url to the object
*/
async function openObjectTreeContextMenu(page, url) {
await page.goto(url);
await page.getByLabel('Show selected item in tree').click();
await page.locator('.is-navigated-object').click({
button: 'right'
});
}

View File

@ -0,0 +1,86 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('LAD Table', () => {
let ladTable;
let swg;
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
ladTable = await createDomainObjectWithDefaults(page, {
type: 'LAD Table'
});
swg = await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator',
parent: ladTable.uuid
});
await page.goto(ladTable.url);
});
test('Ensure we have numbers in cells', async ({ page }) => {
// Wait for the initial value to show after mount
await expect(page.getByLabel('lad value').first()).not.toContainText('---');
const valueFromFirstSineWave = await page.getByLabel('lad value').first().innerText();
const firstSineWaveNumber = parseFloat(valueFromFirstSineWave);
// ensure we have a float value in the cell and it's finite
expect(Number.isFinite(firstSineWaveNumber)).toBeTruthy();
const valueFromSecondSineWave = await page.getByLabel('lad value').last().innerText();
const secondSineWaveNumber = parseFloat(valueFromSecondSineWave);
// ensure we have a float value in the cell and it's finite
expect(Number.isFinite(secondSineWaveNumber)).toBeTruthy();
});
test(
'Can remove telemetry from composition',
{
annotation: {
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7633'
}
},
async ({ page }) => {
// Assert that the table is initially populated
await expect(page.getByLabel('lad row')).toHaveCount(1);
// Expand the tree so the SWG is visible
await page.getByLabel('Expand My Items').click();
await page.getByLabel('Expand LAD Table').click();
// Right-click the SWG treeitem context menu and click 'Remove' and confirm
await page.getByRole('treeitem', { name: swg.name }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Ok', exact: true }).click();
// Assert that the SWG is no longer in the tree and the table is empty
await expect(page.getByRole('treeitem', { name: swg.name })).toBeHidden();
await expect(page.getByLabel('lad row')).toHaveCount(0);
}
);
});

View File

@ -26,7 +26,11 @@ This test suite is dedicated to tests which verify the basic operations surround
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { createDomainObjectWithDefaults } from '../../../../appActions.js'; import {
createDomainObjectWithDefaults,
renameCurrentObjectFromBrowseBar
} from '../../../../appActions.js';
import { copy, paste, selectAll } from '../../../../helper/hotkeys/hotkeys.js';
import * as nbUtils from '../../../../helper/notebookUtils.js'; import * as nbUtils from '../../../../helper/notebookUtils.js';
import { expect, streamToString, test } from '../../../../pluginFixtures.js'; import { expect, streamToString, test } from '../../../../pluginFixtures.js';
@ -277,7 +281,6 @@ test.describe('Notebook entry tests', () => {
// Create Notebook with URL Whitelist // Create Notebook with URL Whitelist
let notebookObject; let notebookObject;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// eslint-disable-next-line no-undef
await page.addInitScript({ await page.addInitScript({
path: fileURLToPath(new URL('../../../../helper/addInitNotebookWithUrls.js', import.meta.url)) path: fileURLToPath(new URL('../../../../helper/addInitNotebookWithUrls.js', import.meta.url))
}); });
@ -296,7 +299,7 @@ test.describe('Notebook entry tests', () => {
await expect(page.getByLabel('Notebook Entry Input')).toBeVisible(); await expect(page.getByLabel('Notebook Entry Input')).toBeVisible();
await expect(page.getByLabel('Notebook Entry', { exact: true })).toHaveClass(/is-selected/); await expect(page.getByLabel('Notebook Entry', { exact: true })).toHaveClass(/is-selected/);
}); });
test('When an object is dropped into a notebook, a new entry is created and it should be focused @unstable', async ({ test('When an object is dropped into a notebook, a new entry is created and it should be focused', async ({
page page
}) => { }) => {
// Create Overlay Plot // Create Overlay Plot
@ -308,7 +311,7 @@ test.describe('Notebook entry tests', () => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
// Reveal the notebook in the tree // Reveal the notebook in the tree
await page.getByTitle('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
await page await page
.getByRole('treeitem', { name: overlayPlot.name }) .getByRole('treeitem', { name: overlayPlot.name })
@ -320,7 +323,7 @@ test.describe('Notebook entry tests', () => {
await expect(embed).toHaveClass(/icon-plot-overlay/); await expect(embed).toHaveClass(/icon-plot-overlay/);
expect(embedName).toBe(overlayPlot.name); expect(embedName).toBe(overlayPlot.name);
}); });
test('When an object is dropped into a notebooks existing entry, it should be focused @unstable', async ({ test('When an object is dropped into a notebooks existing entry, it should be focused', async ({
page page
}) => { }) => {
// Create Overlay Plot // Create Overlay Plot
@ -332,7 +335,7 @@ test.describe('Notebook entry tests', () => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
// Reveal the notebook in the tree // Reveal the notebook in the tree
await page.getByTitle('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
await nbUtils.enterTextEntry(page, 'Entry to drop into'); await nbUtils.enterTextEntry(page, 'Entry to drop into');
await page await page
@ -354,19 +357,19 @@ test.describe('Notebook entry tests', () => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
await nbUtils.enterTextEntry(page, 'First Entry'); await nbUtils.enterTextEntry(page, 'First Entry');
await page.hover('text="First Entry"'); await page.getByLabel('Notebook Entry', { exact: true }).hover();
await page.click('button[title="Delete this entry"]'); await page.getByLabel('Delete this entry').click();
await page.getByRole('button', { name: 'Ok' }).filter({ hasText: 'Ok' }).click(); await page.getByRole('button', { name: 'Ok', exact: true }).click();
await expect(page.locator('text="First Entry"')).toBeHidden(); await expect(page.getByText('First Entry')).toBeHidden();
await nbUtils.enterTextEntry(page, 'Another First Entry'); await nbUtils.enterTextEntry(page, 'Another First Entry');
await nbUtils.enterTextEntry(page, 'Second Entry'); await nbUtils.enterTextEntry(page, 'Second Entry');
await nbUtils.enterTextEntry(page, 'Third Entry'); await nbUtils.enterTextEntry(page, 'Third Entry');
await page.hover('[aria-label="Notebook Entry"] >> nth=2'); await page.getByLabel('Notebook Entry', { exact: true }).nth(2).hover();
await page.click('button[title="Delete this entry"] >> nth=2'); await page.getByLabel('Delete this entry').nth(2).click();
await page.getByRole('button', { name: 'Ok' }).filter({ hasText: 'Ok' }).click(); await page.getByRole('button', { name: 'Ok', exact: true }).click();
await expect(page.locator('text="Third Entry"')).toBeHidden(); await expect(page.getByText('Third Entry')).toBeHidden();
await expect(page.locator('text="Another First Entry"')).toBeVisible(); await expect(page.getByText('Another First Entry')).toBeVisible();
await expect(page.locator('text="Second Entry"')).toBeVisible(); await expect(page.getByText('Second Entry')).toBeVisible();
}); });
test('when a valid link is entered into a notebook entry, it becomes clickable when viewing', async ({ test('when a valid link is entered into a notebook entry, it becomes clickable when viewing', async ({
page page
@ -377,13 +380,13 @@ test.describe('Notebook entry tests', () => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
// Reveal the notebook in the tree // Reveal the notebook in the tree
await page.getByTitle('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`); await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`);
const validLink = page.locator(`a[href="${TEST_LINK}"]`); const validLink = page.locator(`a[href="${TEST_LINK}"]`);
expect(await validLink.count()).toBe(1); await expect(validLink).toHaveCount(1);
// Start waiting for popup before clicking. Note no await. // Start waiting for popup before clicking. Note no await.
const popupPromise = page.waitForEvent('popup'); const popupPromise = page.waitForEvent('popup');
@ -404,13 +407,13 @@ test.describe('Notebook entry tests', () => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
// Reveal the notebook in the tree // Reveal the notebook in the tree
await page.getByTitle('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`); await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`);
const invalidLink = page.locator(`a[href="${TEST_LINK}"]`); const invalidLink = page.locator(`a[href="${TEST_LINK}"]`);
expect(await invalidLink.count()).toBe(0); await expect(invalidLink).toHaveCount(0);
}); });
test('when a link is entered, but it is not in the whitelisted urls, it does not become clickable when viewing', async ({ test('when a link is entered, but it is not in the whitelisted urls, it does not become clickable when viewing', async ({
page page
@ -421,13 +424,13 @@ test.describe('Notebook entry tests', () => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
// Reveal the notebook in the tree // Reveal the notebook in the tree
await page.getByTitle('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`); await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`);
const invalidLink = page.locator(`a[href="${TEST_LINK}"]`); const invalidLink = page.locator(`a[href="${TEST_LINK}"]`);
expect(await invalidLink.count()).toBe(0); await expect(invalidLink).toHaveCount(0);
}); });
test('when a valid link with a subdomain and a valid domain in the whitelisted urls is entered into a notebook entry, it becomes clickable when viewing', async ({ test('when a valid link with a subdomain and a valid domain in the whitelisted urls is entered into a notebook entry, it becomes clickable when viewing', async ({
page page
@ -438,13 +441,13 @@ test.describe('Notebook entry tests', () => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
// Reveal the notebook in the tree // Reveal the notebook in the tree
await page.getByTitle('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
await nbUtils.enterTextEntry(page, `This should be a link: ${INVALID_TEST_LINK} is it?`); await nbUtils.enterTextEntry(page, `This should be a link: ${INVALID_TEST_LINK} is it?`);
const validLink = page.locator(`a[href="${INVALID_TEST_LINK}"]`); const validLink = page.locator(`a[href="${INVALID_TEST_LINK}"]`);
expect(await validLink.count()).toBe(1); await expect(validLink).toHaveCount(1);
}); });
test('when a valid secure link is entered into a notebook entry, it becomes clickable when viewing', async ({ test('when a valid secure link is entered into a notebook entry, it becomes clickable when viewing', async ({
page page
@ -455,13 +458,13 @@ test.describe('Notebook entry tests', () => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
// Reveal the notebook in the tree // Reveal the notebook in the tree
await page.getByTitle('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`); await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`);
const validLink = page.locator(`a[href="${TEST_LINK}"]`); const validLink = page.locator(`a[href="${TEST_LINK}"]`);
expect(await validLink.count()).toBe(1); await expect(validLink).toHaveCount(1);
// Start waiting for popup before clicking. Note no await. // Start waiting for popup before clicking. Note no await.
const popupPromise = page.waitForEvent('popup'); const popupPromise = page.waitForEvent('popup');
@ -483,7 +486,7 @@ test.describe('Notebook entry tests', () => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
// Reveal the notebook in the tree // Reveal the notebook in the tree
await page.getByTitle('Show selected item in tree').click(); await page.getByLabel('Show selected item in tree').click();
await nbUtils.enterTextEntry( await nbUtils.enterTextEntry(
page, page,
@ -494,7 +497,7 @@ test.describe('Notebook entry tests', () => {
const unsanitizedLink = page.locator(`a[href="${TEST_LINK_BAD}"]`); const unsanitizedLink = page.locator(`a[href="${TEST_LINK_BAD}"]`);
expect.soft(await sanitizedLink.count()).toBe(1); expect.soft(await sanitizedLink.count()).toBe(1);
expect(await unsanitizedLink.count()).toBe(0); await expect(unsanitizedLink).toHaveCount(0);
}); });
test('Can add markdown to a notebook entry', async ({ page }) => { test('Can add markdown to a notebook entry', async ({ page }) => {
await page.goto(notebookObject.url); await page.goto(notebookObject.url);
@ -547,4 +550,110 @@ test.describe('Notebook entry tests', () => {
); );
await expect(secondLineOfBlockquoteText).toBeVisible(); await expect(secondLineOfBlockquoteText).toBeVisible();
}); });
/**
* Paste into notebook entry tests
*/
test('Can paste text into a notebook entry', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7686'
}); });
const TEST_TEXT = 'This is a test';
const iterations = 20;
const EXPECTED_TEXT = TEST_TEXT.repeat(iterations);
await page.goto(notebookObject.url);
await nbUtils.addNotebookEntry(page);
await nbUtils.enterTextInLastEntry(page, TEST_TEXT);
await selectAll(page);
await copy(page);
for (let i = 0; i < iterations; i++) {
await paste(page);
}
await nbUtils.commitEntry(page);
await expect(page.locator(`text="${EXPECTED_TEXT}"`)).toBeVisible();
});
test('Prevents pasting text into selected notebook entry if not editing', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7686'
});
const TEST_TEXT = 'This is a test';
await page.goto(notebookObject.url);
await nbUtils.addNotebookEntry(page);
await nbUtils.enterTextInLastEntry(page, TEST_TEXT);
await selectAll(page);
await copy(page);
await paste(page);
await nbUtils.commitEntry(page);
// This should not paste text into the entry
await paste(page);
await expect(await page.locator(`text="${TEST_TEXT.repeat(1)}"`).count()).toEqual(1);
await expect(await page.locator(`text="${TEST_TEXT.repeat(2)}"`).count()).toEqual(0);
});
test('When changing the name of a notebook in the browse bar, new notebook changes are not lost', async ({
page
}) => {
const TEST_TEXT = 'Do not lose me!';
const FIRST_NEW_NAME = 'New Name';
const SECOND_NEW_NAME = 'Second New Name';
await page.goto(notebookObject.url);
await page.getByLabel('Expand My Items folder').click();
await renameCurrentObjectFromBrowseBar(page, FIRST_NEW_NAME);
// verify the name change in tree and browse bar
await verifyNameChange(page, FIRST_NEW_NAME);
// enter one entry
await enterAndCommitTextEntry(page, TEST_TEXT);
// verify the entry is present
await expect(await page.locator(`text="${TEST_TEXT}"`).count()).toEqual(1);
// change the name
await renameCurrentObjectFromBrowseBar(page, SECOND_NEW_NAME);
// verify the name change in tree and browse bar
await verifyNameChange(page, SECOND_NEW_NAME);
// verify the entry is still present
await expect(await page.locator(`text="${TEST_TEXT}"`).count()).toEqual(1);
});
});
/**
* Enter text into the last notebook entry and commit it.
*
* @param {import('@playwright/test').Page} page
* @param {string} text
*/
async function enterAndCommitTextEntry(page, text) {
await nbUtils.addNotebookEntry(page);
await nbUtils.enterTextInLastEntry(page, text);
await nbUtils.commitEntry(page);
}
/**
* Verify the name change in the tree and browse bar.
*
* @param {import('@playwright/test').Page} page
* @param {string} newName
*/
async function verifyNameChange(page, newName) {
await expect(
page.getByRole('treeitem').locator('.is-navigated-object .c-tree__item__name')
).toHaveText(newName);
await expect(page.getByLabel('Browse bar object name')).toHaveText(newName);
}

View File

@ -68,7 +68,7 @@ test.describe('Snapshot image tests', () => {
// expect large image to be displayed // expect large image to be displayed
await expect(page.getByRole('dialog').getByText('favicon-96x96.png')).toBeVisible(); await expect(page.getByRole('dialog').getByText('favicon-96x96.png')).toBeVisible();
await page.getByLabel('Close').click(); await page.getByRole('button', { name: 'Close' }).click();
// drop another image onto the entry // drop another image onto the entry
await page.dispatchEvent('.c-snapshots', 'drop', { dataTransfer: dropTransfer }); await page.dispatchEvent('.c-snapshots', 'drop', { dataTransfer: dropTransfer });
@ -76,7 +76,7 @@ test.describe('Snapshot image tests', () => {
const secondThumbnail = page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).nth(1); const secondThumbnail = page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).nth(1);
await secondThumbnail.waitFor({ state: 'attached' }); await secondThumbnail.waitFor({ state: 'attached' });
// expect two embedded images now // expect two embedded images now
expect(await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).count()).toBe(2); await expect(page.getByRole('img', { name: 'favicon-96x96.png thumbnail' })).toHaveCount(2);
await page.locator('.c-snapshot.c-ne__embed').first().getByTitle('More actions').click(); await page.locator('.c-snapshot.c-ne__embed').first().getByTitle('More actions').click();
@ -86,7 +86,7 @@ test.describe('Snapshot image tests', () => {
await secondThumbnail.waitFor({ state: 'detached' }); await secondThumbnail.waitFor({ state: 'detached' });
// expect one embedded image now as we deleted the other // expect one embedded image now as we deleted the other
expect(await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).count()).toBe(1); await expect(page.getByRole('img', { name: 'favicon-96x96.png thumbnail' })).toHaveCount(1);
}); });
}); });

View File

@ -71,42 +71,89 @@ test.describe('Snapshot Container tests', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
//Navigate to baseURL //Navigate to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
await page.getByLabel('Open the Notebook Snapshot Menu').click();
// Create Notebook
// const notebook = await createDomainObjectWithDefaults(page, {
// type: 'Notebook',
// name: "Test Notebook"
// });
// // Create Overlay Plot
// const snapShotObject = await createDomainObjectWithDefaults(page, {
// type: 'Overlay Plot',
// name: "Dropped Overlay Plot"
// });
await page.getByLabel('Take a Notebook Snapshot').click();
await page.getByRole('menuitem', { name: 'Save to Notebook Snapshots' }).click(); await page.getByRole('menuitem', { name: 'Save to Notebook Snapshots' }).click();
await page.getByLabel('Show Snapshots').click(); await page.getByLabel('Show Snapshots').click();
}); });
test('A snapshot can be Quick Viewed from Container with 3 dot action menu', async ({ page }) => { test('A snapshot can be Quick Viewed from Container with 3 dot action menu', async ({ page }) => {
await page.locator('.c-snapshot.c-ne__embed').first().getByTitle('More actions').click(); await page.getByLabel('My Items Notebook Embed').getByLabel('More actions').click();
await page.getByRole('menuitem', { name: 'Quick View' }).click(); await page.getByRole('menuitem', { name: 'Quick View' }).click();
await expect(page.locator('.c-overlay__outer')).toBeVisible(); await expect(page.getByLabel('Modal Overlay')).toBeVisible();
await expect(page.getByLabel('Preview Container')).toBeVisible();
}); });
test.fixme( test('A snapshot can be Viewed, Annotated, display deleted, and saved from Container with 3 dot action menu', async ({
'A snapshot can be Viewed, Annotated, display deleted, and saved from Container with 3 dot action menu', page
async ({ page }) => { }) => {
await page.locator('.c-snapshot.c-ne__embed').first().getByTitle('More actions').click(); test.info().annotations.push({
await page.getByRole('menuitem', { name: ' View Snapshot' }).click(); type: 'issue',
await expect(page.locator('.c-overlay__outer')).toBeVisible(); description: 'https://github.com/nasa/openmct/issues/7552'
await page.getByTitle('Annotate').click(); });
//Open Snapshot Object View
await page.getByLabel('My Items Notebook Embed').getByLabel('More actions').click();
await page.getByRole('menuitem', { name: 'View Snapshot' }).click();
await expect(page.getByRole('dialog', { name: 'Modal Overlay' })).toBeVisible();
await expect(page.locator('#snapshotDescriptor')).toHaveText(
/SNAPSHOT \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/
);
// Open Annotation Editor with Painterro
await page.getByLabel('Annotate this snapshot').click();
await expect(page.locator('#snap-annotation-canvas')).toBeVisible(); await expect(page.locator('#snap-annotation-canvas')).toBeVisible();
await page.getByRole('button', { name: '' }).click(); // Clear the canvas
// await expect(page.locator('#snap-annotation-canvas')).not.toBeVisible(); await page.getByRole('button', { name: 'Put text [T]' }).click();
// Click in the Painterro canvas to add a text annotation
await page.locator('.ptro-crp-el').click();
await page.locator('.ptro-text-tool-input').fill('...is there life on mars?');
// When working with Painterro, we need to check that the Apply button is hidden after clicking
await page.getByTitle('Apply').click();
await expect(page.getByTitle('Apply')).toBeHidden();
// Save and exit annotation window
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Done' }).click(); await page.getByRole('button', { name: 'Done' }).click();
//await expect(await page.locator)
} // Open up annotation again
); await page.getByRole('img', { name: 'My Items thumbnail' }).click();
await expect(page.getByLabel('Modal Overlay').getByRole('img')).toBeVisible();
});
test('A snapshot can be Annotated and saved as a JPG and PNG', async ({ page }) => {
//Open Snapshot Object View
await page.getByLabel('My Items Notebook Embed').getByLabel('More actions').click();
await page.getByRole('menuitem', { name: 'View Snapshot' }).click();
await expect(page.getByRole('dialog', { name: 'Modal Overlay' })).toBeVisible();
// Open Annotation Editor with Painterro
await page.getByLabel('Annotate this snapshot').click();
await expect(page.locator('#snap-annotation-canvas')).toBeVisible();
// Clear the canvas
await page.getByRole('button', { name: 'Put text [T]' }).click();
// Click in the Painterro canvas to add a text annotation
await page.locator('.ptro-crp-el').click();
await page.locator('.ptro-text-tool-input').fill('...is there life on mars?');
// When working with Painterro, we need to check that the Apply button is hidden after clicking
await page.getByTitle('Apply').click();
await expect(page.getByTitle('Apply')).toBeHidden();
// Save and exit annotation window
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Done' }).click();
// Open up annotation again
await page.getByRole('img', { name: 'My Items thumbnail' }).click();
await expect(page.getByLabel('Modal Overlay').getByRole('img')).toBeVisible();
// Save as JPG
await Promise.all([
page.waitForEvent('download'), // Waits for the download event
page.getByLabel('Export as JPG').click() // Triggers the download
]);
// Save as PNG
await expect(page.getByLabel('Modal Overlay').getByRole('img')).toBeVisible();
await Promise.all([
page.waitForEvent('download'), // Waits for the download event
page.getByLabel('Export as PNG').click() // Triggers the download
]);
});
test.fixme('5 Snapshots can be added to a container', async ({ page }) => {}); test.fixme('5 Snapshots can be added to a container', async ({ page }) => {});
test.fixme( test.fixme(
'5 Snapshots can be added to a container and Deleted with Delete All action', '5 Snapshots can be added to a container and Deleted with Delete All action',
@ -116,10 +163,6 @@ test.describe('Snapshot Container tests', () => {
'A snapshot can be Deleted from Container with 3 dot action menu', 'A snapshot can be Deleted from Container with 3 dot action menu',
async ({ page }) => {} async ({ page }) => {}
); );
test.fixme(
'A snapshot can be Navigated To from Container with 3 dot action menu',
async ({ page }) => {}
);
test.fixme( test.fixme(
'A snapshot can be Navigated To Item in Time from Container with 3 dot action menu', 'A snapshot can be Navigated To Item in Time from Container with 3 dot action menu',
async ({ page }) => {} async ({ page }) => {}
@ -151,11 +194,4 @@ test.describe('Snapshot Container tests', () => {
//Snapshot removed from container? //Snapshot removed from container?
} }
); );
test.fixme(
'Verify Embedded options for PNG, JPG, and Annotate work correctly',
async ({ page }) => {
//Add snapshot to container
//Verify PNG, JPG, and Annotate buttons work correctly
}
);
}); });

View File

@ -24,16 +24,22 @@
This test suite is dedicated to tests which verify the basic operations surrounding Notebooks with CouchDB. This test suite is dedicated to tests which verify the basic operations surrounding Notebooks with CouchDB.
*/ */
/**
* Disable no-networkidle eslint rule until we can engineer more deterministic network-event
* driven tests.
*/
/* eslint-disable playwright/no-networkidle */
import { createDomainObjectWithDefaults } from '../../../../appActions.js'; import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import * as nbUtils from '../../../../helper/notebookUtils.js'; import * as nbUtils from '../../../../helper/notebookUtils.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
test.describe('Notebook Tests with CouchDB @couchdb', () => { test.describe('Notebook Tests with CouchDB @couchdb @network', () => {
let testNotebook; let testNotebook;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Navigate to baseURL // Navigate to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'networkidle' });
// Create Notebook // Create Notebook
testNotebook = await createDomainObjectWithDefaults(page, { type: 'Notebook' }); testNotebook = await createDomainObjectWithDefaults(page, { type: 'Notebook' });
@ -55,7 +61,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
// Waits for the next request with the specified url // Waits for the next request with the specified url
page.waitForRequest(`**/openmct/${testNotebook.uuid}`), page.waitForRequest(`**/openmct/${testNotebook.uuid}`),
// Triggers the request // Triggers the request
page.click('[aria-label="Add Page"]') page.getByLabel('Add Page').click()
]); ]);
// Ensures that there are no other network requests // Ensures that there are no other network requests
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
@ -63,7 +69,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
// Assert that only two requests are made // Assert that only two requests are made
// Network Requests are: // Network Requests are:
// 1) The actual POST to create the page // 1) The actual POST to create the page
expect(notebookElementsRequests.length).toBe(1); expect(notebookElementsRequests).toHaveLength(1);
// Assert on request object // Assert on request object
expect(notebookUrlRequest.postDataJSON().metadata.name).toBe(testNotebook.name); expect(notebookUrlRequest.postDataJSON().metadata.name).toBe(testNotebook.name);
@ -120,8 +126,8 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
expect(filterNonFetchRequests(notebookElementsRequests).length).toBeLessThanOrEqual(12); expect(filterNonFetchRequests(notebookElementsRequests).length).toBeLessThanOrEqual(12);
// Add two more pages // Add two more pages
await page.click('[aria-label="Add Page"]'); await page.getByLabel('Add Page').click();
await page.click('[aria-label="Add Page"]'); await page.getByLabel('Add Page').click();
// Add three entries // Add three entries
await nbUtils.enterTextEntry(page, 'First Entry'); await nbUtils.enterTextEntry(page, 'First Entry');

View File

@ -20,7 +20,6 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import { openObjectTreeContextMenu } from '../../../../appActions.js';
import { import {
dragAndDropEmbed, dragAndDropEmbed,
enterTextEntry, enterTextEntry,
@ -51,27 +50,22 @@ test.describe('Restricted Notebook', () => {
const restrictedNotebookTreeObject = page.locator(`a:has-text("${notebook.name}")`); const restrictedNotebookTreeObject = page.locator(`a:has-text("${notebook.name}")`);
// notebook tree object exists // notebook tree object exists
expect.soft(await restrictedNotebookTreeObject.count()).toEqual(1); await expect(restrictedNotebookTreeObject).toHaveCount(1);
// Click Remove Text // Click Remove Text
await page.locator('li[role="menuitem"]:has-text("Remove")').click(); await page.locator('li[role="menuitem"]:has-text("Remove")').click();
// Click 'OK' on confirmation window and wait for save banner to appear // Click 'Ok' on confirmation window
await Promise.all([ await page.locator('button:has-text("OK")').click();
page.waitForNavigation(),
page.locator('button:has-text("OK")').click(),
page.waitForSelector('.c-message-banner__message')
]);
// has been deleted // has been deleted
expect(await restrictedNotebookTreeObject.count()).toEqual(0); await expect(restrictedNotebookTreeObject).toHaveCount(0);
}); });
test('Can be locked if at least one page has one entry @addInit', async ({ page }) => { test('Can be locked if at least one page has one entry @addInit', async ({ page }) => {
await enterTextEntry(page, TEST_TEXT); await enterTextEntry(page, TEST_TEXT);
const commitButton = page.locator('button:has-text("Commit Entries")'); await expect(page.getByLabel('Commit Entries')).toHaveCount(1);
expect(await commitButton.count()).toEqual(1);
}); });
}); });
@ -86,20 +80,18 @@ test.describe('Restricted Notebook with at least one entry and with the page loc
await page.locator('button.c-notebook__toggle-nav-button').click(); await page.locator('button.c-notebook__toggle-nav-button').click();
}); });
test('Locked page should now be in a locked state @addInit @unstable', async ({ test('Locked page should now be in a locked state @addInit', async ({ page }, testInfo) => {
page
}, testInfo) => {
// eslint-disable-next-line playwright/no-skipped-test // eslint-disable-next-line playwright/no-skipped-test
test.skip(testInfo.project === 'chrome-beta', 'Test is unreliable on chrome-beta'); test.skip(testInfo.project === 'chrome-beta', 'Test is unreliable on chrome-beta');
// main lock message on page // main lock message on page
const lockMessage = page.locator( const lockMessage = page.locator(
'text=This page has been committed and cannot be modified or removed' 'text=This page has been committed and cannot be modified or removed'
); );
expect.soft(await lockMessage.count()).toEqual(1); await expect(lockMessage).toHaveCount(1);
// lock icon on page in sidebar // lock icon on page in sidebar
const pageLockIcon = page.locator('ul.c-notebook__pages li div.icon-lock'); const pageLockIcon = page.locator('ul.c-notebook__pages li div.icon-lock');
expect.soft(await pageLockIcon.count()).toEqual(1); await expect(pageLockIcon).toHaveCount(1);
// no way to remove a restricted notebook with a locked page // no way to remove a restricted notebook with a locked page
await openObjectTreeContextMenu(page, notebook.url); await openObjectTreeContextMenu(page, notebook.url);
@ -119,17 +111,14 @@ test.describe('Restricted Notebook with at least one entry and with the page loc
await page.getByText('Unnamed Page').nth(1).fill(TEST_TEXT_NAME); await page.getByText('Unnamed Page').nth(1).fill(TEST_TEXT_NAME);
// expect to be able to rename unlocked pages // expect to be able to rename unlocked pages
const newPageElement = page.getByText(TEST_TEXT_NAME); await page.getByText(TEST_TEXT_NAME).press('Enter'); // exit contenteditable state
const newPageCount = await newPageElement.count(); await expect(page.locator('div').filter({ hasText: /^Test Page$/ })).toHaveCount(1);
await newPageElement.press('Enter'); // exit contenteditable state
expect.soft(newPageCount).toEqual(1);
// enter test text // enter test text
await enterTextEntry(page, TEST_TEXT); await enterTextEntry(page, TEST_TEXT);
// expect new page to be lockable // expect new page to be lockable
const commitButton = page.getByRole('button', { name: ' Commit Entries' }); await expect(page.getByLabel('Commit Entries')).toHaveCount(1);
expect.soft(await commitButton.count()).toEqual(1);
// Click the context menu button for the new page // Click the context menu button for the new page
await page.getByTitle('Open context menu').click(); await page.getByTitle('Open context menu').click();
@ -140,7 +129,7 @@ test.describe('Restricted Notebook with at least one entry and with the page loc
// deleted page, should no longer exist // deleted page, should no longer exist
const deletedPageElement = page.getByText(TEST_TEXT_NAME); const deletedPageElement = page.getByText(TEST_TEXT_NAME);
expect(await deletedPageElement.count()).toEqual(0); await expect(deletedPageElement).toHaveCount(0);
}); });
}); });
@ -195,3 +184,18 @@ test.describe('can export restricted notebook as text', () => {
test.fixme('can export all notebook tags', async ({ page }) => {}); test.fixme('can export all notebook tags', async ({ page }) => {});
test.fixme('can export all notebook snapshots', async ({ page }) => {}); test.fixme('can export all notebook snapshots', async ({ page }) => {});
}); });
/**
* Open the given `domainObject`'s context menu from the object tree.
* Expands the path to the object and scrolls to it if necessary.
*
* @param {import('@playwright/test').Page} page
* @param {string} url the url to the object
*/
async function openObjectTreeContextMenu(page, url) {
await page.goto(url);
await page.getByLabel('Show selected item in tree').click();
await page.locator('.is-navigated-object').click({
button: 'right'
});
}

View File

@ -0,0 +1,72 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { expect, test } from '../../../../pluginFixtures.js';
test.describe('The performance indicator', () => {
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
const openmct = window.openmct;
openmct.install(openmct.plugins.PerformanceIndicator());
});
});
test('can be installed', ({ page }) => {
const performanceIndicator = page.getByTitle('Performance Indicator');
expect(performanceIndicator).toBeDefined();
});
test('Shows a numerical FPS value', async ({ page }) => {
// Frames Per Second. We need to wait at least 1 second to get a value.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expect(page.getByTitle('Performance Indicator')).toHaveText(/\d\d? fps/);
});
test('Supports showing optional extended performance information in an overlay for debugging', async ({
page
}) => {
const performanceMeasurementLabel = 'Some measurement';
const performanceMeasurementValue = 'Some value';
await page.evaluate(
({ performanceMeasurementLabel: label, performanceMeasurementValue: value }) => {
const openmct = window.openmct;
openmct.performance.measurements.set(label, value);
},
{ performanceMeasurementLabel, performanceMeasurementValue }
);
const performanceIndicator = page.getByTitle('Performance Indicator');
await performanceIndicator.click();
//Performance overlay is a crude debugging tool, it's evaluated once per second.
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
const performanceOverlay = page.getByTitle('Performance Overlay');
await expect(performanceOverlay).toBeVisible();
await expect(performanceOverlay).toHaveText(new RegExp(`${performanceMeasurementLabel}.*`));
await expect(performanceOverlay).toHaveText(new RegExp(`.*${performanceMeasurementValue}`));
//Confirm that it disappears if we click on it again.
await performanceIndicator.click();
await expect(performanceOverlay).toBeHidden();
});
});

View File

@ -24,7 +24,10 @@
Testsuite for plot autoscale. Testsuite for plot autoscale.
*/ */
import { createDomainObjectWithDefaults } from '../../../../appActions.js'; import {
createDomainObjectWithDefaults,
navigateToObjectWithFixedTimeBounds
} from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js'; import { expect, test } from '../../../../pluginFixtures.js';
test.use({ test.use({
viewport: { viewport: {
@ -51,9 +54,7 @@ test.describe('Autoscale', () => {
}); });
// Switch to fixed time, start: 2022-03-28 22:00:00.000 UTC, end: 2022-03-28 22:00:30.000 UTC // Switch to fixed time, start: 2022-03-28 22:00:00.000 UTC, end: 2022-03-28 22:00:30.000 UTC
await page.goto( await navigateToObjectWithFixedTimeBounds(page, overlayPlot.url, 1648591200000, 1648591230000);
`${overlayPlot.url}?tc.mode=fixed&tc.startBound=1648591200000&tc.endBound=1648591230000&tc.timeSystem=utc&view=plot-overlay`
);
await testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']); await testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']);
@ -61,20 +62,23 @@ test.describe('Autoscale', () => {
await page.getByLabel('Edit Object').click(); await page.getByLabel('Edit Object').click();
await page.getByRole('tab', { name: 'Config' }).click(); await page.getByRole('tab', { name: 'Config' }).click();
await turnOffAutoscale(page);
await setUserDefinedMinAndMax(page, '-2', '2'); // turn off autoscale
await page.getByRole('checkbox', { name: 'Auto scale' }).uncheck();
await page.getByLabel('Y Axis 1 Minimum value').fill('-2');
await page.getByLabel('Y Axis 1 Maximum value').fill('2');
// save // save
await page.click('button[title="Save"]'); await page.getByLabel('Save').click();
await Promise.all([ await Promise.all([
page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(), page.getByRole('listitem', { name: 'Save and Finish Editing' }).click(),
//Wait for Save Banner to appear //Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message') page.locator('.c-message-banner__message').hover({ trial: true })
]); ]);
//Wait until Save Banner is gone //Wait until Save Banner is gone
await page.locator('.c-message-banner__close-button').click(); await page.locator('.c-message-banner__close-button').click();
await page.waitForSelector('.c-message-banner__message', { state: 'detached' }); await page.locator('.c-message-banner__message').waitFor({ state: 'detached' });
// Make sure that after turning off autoscale, the user entered range values are reflected in the ticks. // Make sure that after turning off autoscale, the user entered range values are reflected in the ticks.
await testYTicks(page, [ await testYTicks(page, [
@ -127,26 +131,6 @@ test.describe('Autoscale', () => {
}); });
}); });
/**
* @param {import('@playwright/test').Page} page
*/
async function turnOffAutoscale(page) {
// uncheck autoscale
await page.getByRole('checkbox', { name: 'Auto scale' }).uncheck();
}
/**
* @param {import('@playwright/test').Page} page
* @param {string} min
* @param {string} max
*/
async function setUserDefinedMinAndMax(page, min, max) {
// set minimum value
await page.getByRole('spinbutton').first().fill(min);
// set maximum value
await page.getByRole('spinbutton').nth(1).fill(max);
}
/** /**
* @param {import('@playwright/test').Page} page * @param {import('@playwright/test').Page} page
*/ */

Some files were not shown because too many files have changed in this diff Show More