diff --git a/.travis.yml b/.travis.yml index 0261a5ebb..85627bccf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -32,13 +32,53 @@ matrix: python: 3.8 env: PYTHONVER=3.8 +addons: + chrome: stable + firefox: latest + +node_js: + - 14 + +before_install: + - CHROME_DRIVER_BASE_URL=https://chromedriver.storage.googleapis.com + - GECKO__DRIVER_BASE_URL=https://github.com/mozilla/geckodriver/releases/download + - GECKO__DRIVER_VERSION=v0.27.0 + - | + if [[ $TRAVIS_OS_NAME == "osx" ]]; then + CHROME_ARTIFACT_NAME=chromedriver_mac64.zip + GECKO__ARTIFACT_NAME=geckodriver-${GECKO__DRIVER_VERSION}-macos.tar.gz + export BROWSER_PATH="/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome" + CHROME_MAJOR_VERSION="LATEST_RELEASE" + else + CHROME_ARTIFACT_NAME=chromedriver_linux64.zip + GECKO__ARTIFACT_NAME=geckodriver-${GECKO__DRIVER_VERSION}-linux64.tar.gz + export BROWSER_PATH=$(which google-chrome-stable) + CHROME_MAJOR_VERSION="LATEST_RELEASE_$(google-chrome-stable --version | cut -d ' ' -f 3 | cut -d '.' -f 1)" + fi + - CHROME_DRIVER_VERSION_CMD="curl -sS https://chromedriver.storage.googleapis.com/${CHROME_MAJOR_VERSION}" + - CHROME_DRIVER_VERSION=$(${CHROME_DRIVER_VERSION_CMD}) + - wget -q -N ${CHROME_DRIVER_BASE_URL}/${CHROME_DRIVER_VERSION}/${CHROME_ARTIFACT_NAME} -P ~/ + - wget -q ${GECKO__DRIVER_BASE_URL}/${GECKO__DRIVER_VERSION}/${GECKO__ARTIFACT_NAME} -O ~/${GECKO__ARTIFACT_NAME} + - mkdir -p $HOME/drivers + - unzip ~/${CHROME_ARTIFACT_NAME} -d $HOME/drivers + - tar -xzvf ~/${GECKO__ARTIFACT_NAME} --directory $HOME/drivers + - sudo chmod 0755 $HOME/drivers/chromedriver + - sudo chmod 0755 $HOME/drivers/geckodriver + - export PATH="$HOME/drivers:$PATH" + # TODO : answer the xvfb question (service not available on macOS) + - echo "${BROWSER_PATH} --headless --disable-gpu --remote-debugging-port=9222 http://localhost &" + - "${BROWSER_PATH} --headless --disable-gpu --remote-debugging-port=9222 http://localhost &" + - sleep 3 + install: - - if [[ $TRAVIS_OS_NAME == "osx" ]]; then - wget https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh -O miniconda.sh; + - | + if [[ $TRAVIS_OS_NAME == "osx" ]]; then + wget -q https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh -O miniconda.sh else - wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh; - fi; + wget -q https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh + fi echo "DONE" + - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - conda config --add channels conda-forge @@ -52,50 +92,64 @@ install: - conda install -q -c exaanalytics exa>=0.5.24 - conda install -q -c conda-forge six>=1.0 numexpr>=2.0 ipywidgets>=7.0 bokeh scipy>=1.4 - conda install -q -c conda-forge coveralls coverage pytest pytest-cov - - conda install -q -c anaconda h5py - - if [[ ${TRAVIS_OS_NAME} == "linux" ]] && [[ ${TRAVIS_PULL_REQUEST} == false ]] && [[ ${TRAVIS_PULL_REQUEST_BRANCH} == "" ]] && [[ ${TRAVIS_BRANCH} == "master" ]]; then - conda install -q -c conda-forge sphinx sphinx_rtd_theme ply pandoc pypandoc nbsphinx ipython nodejs; - conda install -q conda-build conda-verify anaconda-client twine; - pip install travis-sphinx; + - conda install -q -c anaconda h5py selenium + - | + if ( [[ ${TRAVIS_OS_NAME} == "linux" ]] && + [[ ${TRAVIS_BRANCH} == "master" ]] && + [[ ${TRAVIS_PULL_REQUEST} == false ]] && + [[ ${TRAVIS_PULL_REQUEST_BRANCH} == "" ]] ); then + conda install -q -c conda-forge sphinx sphinx_rtd_theme ply pandoc pypandoc nbsphinx ipython nodejs + conda install -q conda-build conda-verify anaconda-client twine + pip install travis-sphinx fi - python setup.py develop script: - export PYTHONDONTWRITEBYTECODE=1 + - python test_widget.py - pytest --doctest-modules -v --cov coveralls --cov-report term --cov=exatomic --cov-report xml - - if [[ ${TRAVIS_OS_NAME} == "linux" ]] && [[ ${TRAVIS_PULL_REQUEST} == false ]] && [[ ${TRAVIS_PULL_REQUEST_BRANCH} == "" ]] && [[ ${TRAVIS_BRANCH} == "master" ]] && [[ ${PYTHONVER} == "3.8" ]]; then - rm -rf docs/source/*.txt; - SPHINX_APIDOC_OPTIONS=members,undoc-members,show-inheritance sphinx-apidoc -eM -s txt -o docs/source/ exatomic *test*; - travis-sphinx build; + - | + if ( [[ ${TRAVIS_OS_NAME} == "linux" ]] && + [[ ${TRAVIS_BRANCH} == "master" ]] && + [[ ${PYTHONVER} == "3.8" ]] && + [[ ${TRAVIS_PULL_REQUEST} == false ]] && + [[ ${TRAVIS_PULL_REQUEST_BRANCH} == "" ]] ); then + rm -rf docs/source/*.txt + SPHINX_APIDOC_OPTIONS=members,undoc-members,show-inheritance sphinx-apidoc -eM -s txt -o docs/source/ exatomic *test* + travis-sphinx build fi after_success: - coveralls - bash <(curl -Ls https://coverage.codacy.com/get.sh) report --language Python -r coverage.xml - bash <(curl -Ls https://coverage.codacy.com/get.sh) final - - if [[ ${TRAVIS_OS_NAME} == "linux" ]] && [[ ${TRAVIS_PULL_REQUEST} == false ]] && [[ ${TRAVIS_PULL_REQUEST_BRANCH} == "" ]] && [[ ${TRAVIS_BRANCH} == "master" ]] && [[ ${PYTHONVER} == "3.8" ]]; then - travis-sphinx deploy; - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ${HOME}/.npmrc; - cd js; npm publish; cd ..; - fi - - if [[ ${TRAVIS_OS_NAME} == "linux" ]] && [[ ${TRAVIS_PULL_REQUEST} == false ]] && [[ ${TRAVIS_PULL_REQUEST_BRANCH} == "" ]] && [[ ${TRAVIS_BRANCH} == "master" ]]; then - export pyver="py${PYTHONVER/./}"; - export ver=`cat exatomic/static/version.txt`; - git remote set-url origin https://${GH_TOKEN}@github.com/exa-analytics/exatomic.git; - git tag ${ver}; - git push --tags; - sed -i "s/version = .* /version = \"${ver}\"/" meta.yaml; - cat meta.yaml; - printf "[distutils]\nindex-servers =\n pypi\n testpypi\n\n[pypi]\nrepository = https://upload.pypi.org/legacy/\nusername = __token__\npassword = ${pypi}\n\n[testpypi]\nrepository = https://test.pypi.org/legacy/\nusername = __token__\npassword = ${testpypi}" > ${HOME}/.pypirc; - python setup.py sdist; - python -m twine upload --repository pypi dist/*; - conda build --no-include-recipe .; - conda convert -f -p osx-64 ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2 -o dist1/; - conda convert -f -p linux-32 ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2 -o dist1/; - conda convert -f -p win-32 ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2 -o dist1/; - conda convert -f -p win-64 ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2 -o dist1/; - ls -lisah dist1; - anaconda login --username ${anaconda_username} --password ${anaconda_password}; - anaconda upload --no-progress ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2; - for i in $(ls -d dist1/*/); do echo "${i}exatomic-${ver}-${pyver}_0.tar.bz2"; anaconda upload --no-progress ${i}exatomic-${ver}-${pyver}_0.tar.bz2; done; + - | + if ( [[ ${TRAVIS_OS_NAME} == "linux" ]] && + [[ ${TRAVIS_BRANCH} == "master" ]] && + [[ ${TRAVIS_PULL_REQUEST} == false ]] && + [[ ${TRAVIS_PULL_REQUEST_BRANCH} == "" ]] ); then + if [[ ${PYTHONVER} == "3.8" ]]; then + travis-sphinx deploy + echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ${HOME}/.npmrc + cd js; npm login; npm publish; cd .. + fi + export pyver="py${PYTHONVER/./}" + export ver=`cat exatomic/static/version.txt` + git remote set-url origin https://${GH_TOKEN}@github.com/exa-analytics/exatomic.git + git tag ${ver} + git push --tags + sed -i "s/version = .* /version = \"${ver}\"/" meta.yaml + cat meta.yaml + printf "[distutils]\nindex-servers =\n pypi\n testpypi\n\n[pypi]\nrepository = https://upload.pypi.org/legacy/\nusername = __token__\npassword = ${pypi}\n\n[testpypi]\nrepository = https://test.pypi.org/legacy/\nusername = __token__\npassword = ${testpypi}" > ${HOME}/.pypirc + python setup.py sdist + python -m twine upload --repository pypi dist/* + conda build --no-include-recipe . + conda convert -f -p osx-64 ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2 -o dist1/ + conda convert -f -p linux-32 ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2 -o dist1/ + conda convert -f -p win-32 ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2 -o dist1/ + conda convert -f -p win-64 ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2 -o dist1/ + ls -lisah dist1 + anaconda login --username ${anaconda_username} --password ${anaconda_password} + anaconda upload --no-progress ${HOME}/miniconda/envs/test/conda-bld/linux-64/exatomic-${ver}-${pyver}_0.tar.bz2 + for i in $(ls -d dist1/*/); do echo "${i}exatomic-${ver}-${pyver}_0.tar.bz2"; anaconda upload --no-progress ${i}exatomic-${ver}-${pyver}_0.tar.bz2; done fi diff --git a/appveyor.yml b/appveyor.yml index 38be303cf..f7f1ec5d5 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -51,7 +51,7 @@ install: - cmd: conda update -q -y --all - cmd: conda install -q -c exaanalytics exa>=0.5.24 - cmd: conda install -q -c conda-forge six>=1.0 numexpr>=2.0 ipywidgets>=7.0 bokeh scipy>=1.4 - - cmd: conda install -q -c conda-forge pytest pytest-cov + - cmd: conda install -q -c conda-forge pytest pytest-cov selenium - cmd: conda install -q -c anaconda h5py - cmd: python setup.py install diff --git a/exatomic/widgets/__init__.py b/exatomic/widgets/__init__.py index 4579d49a6..ec56c4f41 100644 --- a/exatomic/widgets/__init__.py +++ b/exatomic/widgets/__init__.py @@ -3,3 +3,64 @@ # Distributed under the terms of the Apache License 2.0 from .widget import TensorContainer, DemoContainer, DemoUniverse, UniverseWidget + +def exhibition_widget(): + """An exhibition widget from static resources + that contains enough data to demonstrate numerous pieces + of functionality in the UniverseWidget. + + Control each scene individually by selecting active + scenes based on the index of their layout reading + left to right, top to bottom. The Camera tab allows + for linking cameras across scenes. The Fill tab + controls the atomic display model and Axis will display + a unit vector (defaults to the origin). + + Returns: + UniverseWidget featuring the application. + scene 0: trajectory animation (Animate tab) + scene 1: orbital isosurfaces (Fields, Contours tabs) + scene 2: NMR shielding tensor (Tensor tab) + + Note: + All scenes are active by default and not all required + data are exposed in each scene. Therefore, "unselect" + active scenes in the Active Scenes tab (at least until + there is better exception handling on the JS side). + + Note: + This widget provides test cases for marching cubes + (and marching squares) over 3D (2D) scalar fields, animated + trajectories, and plotting parametric surfaces, but + not all at the same time. The aim is to provide a + complex enough test case that covers a good portion of + the JS code so that updates can be checked (albeit in + a time-consuming manner). It may also serve to uncover + python-related bugs related to a UniverseWidget housing + multiple independent UniverseScenes. + + Note: + The use of an entire tensor table is not well supported + by the application yet. The aim is to improve functionality + to be similar to the functionality for isosurfaces. + + """ + import exatomic + from exatomic import gaussian + + trj_file = 'H2O.traj.xyz' + orb_file = 'g09-ch3nh2-631g.out' + nmr_file = 'g16-nitromalonamide-6-31++g-nmr.out' + + trj = exatomic.XYZ( + exatomic.base.resource(trj_file)).to_universe() + + orb = gaussian.Output( + exatomic.base.resource(orb_file)).to_universe() + orb.add_molecular_orbitals() + + nmr = gaussian.Output( + exatomic.base.resource(nmr_file)).to_universe() + nmr.tensor = nmr.nmr_shielding + + return exatomic.UniverseWidget(trj, orb, nmr) diff --git a/exatomic/widgets/tests/test_widgets.py b/exatomic/widgets/tests/test_widgets.py index 1710c91e7..c27649885 100644 --- a/exatomic/widgets/tests/test_widgets.py +++ b/exatomic/widgets/tests/test_widgets.py @@ -16,6 +16,13 @@ H 0.0 0.0 0.35 ''' +class TestExhibitionWidget(TestCase): + + def test_exhibition_widget(self): + import exatomic + w = exatomic.widgets.exhibition_widget() + self.assertEqual(len(w.scenes), 3) + class TestDemoContainer(TestCase): def setUp(self): self.box = DemoContainer() diff --git a/exatomic/widgets/widget.py b/exatomic/widgets/widget.py index d63052b87..84e512843 100644 --- a/exatomic/widgets/widget.py +++ b/exatomic/widgets/widget.py @@ -574,6 +574,9 @@ def _axis(b): return mainopts + def __repr__(self): + return self.__class__.__name__ + f'({len(self.scenes)},closed)' + def __init__(self, *unis, **kwargs): scenekwargs = kwargs.pop('scenekwargs', {}) #scenekwargs.update({'uni': True, 'test': False}) diff --git a/exatomic/widgets/widget_base.py b/exatomic/widgets/widget_base.py index 6b449dbe7..2d8f0b0aa 100644 --- a/exatomic/widgets/widget_base.py +++ b/exatomic/widgets/widget_base.py @@ -25,6 +25,26 @@ _vboxlo, _bboxlo, _ListDict, Folder, GUIBox, gui_field_widgets) +@register +class Scene(DOMWidget): + _model_module_version = Unicode(__js_version__).tag(sync=True) + _view_module_version = Unicode(__js_version__).tag(sync=True) + _view_module = Unicode('exatomic').tag(sync=True) + _model_module = Unicode('exatomic').tag(sync=True) + _model_name = Unicode('SceneModel').tag(sync=True) + _view_name = Unicode('SceneView').tag(sync=True) + _inited = Bool(False).tag(sync=True) + flag = Bool(True).tag(sync=True) + + + def close(self): + self.send({'type': 'close'}) + super().close() + + def _handle_custom_msg(self, msg, callback): + if msg['type'] == 'init': + self._inited = True + @register class ExatomicScene(DOMWidget): diff --git a/install-chromedriver.sh b/install-chromedriver.sh new file mode 100755 index 000000000..d88fb5f69 --- /dev/null +++ b/install-chromedriver.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +echo """ +Debian-based installer script to set up chromedriver for selenium. +First installs some linux dependencies for this script. +Then installs google-chrome-stable from google repository. +Uses that version of chrome to install chromedriver into PATH. +This should work for linux systems to run headless selenium +tests against chrome. + +On WSL, however, installing chrome this way does not work in +headless mode. You can still run the selenium tests by pointing +to the chrome executable path from Windows. + +Note: + WSL environment should look something like: + + export DISPLAY=:0 + export BROWSER=/mnt/c/Program\ Files\ \(x86\)/Google/Chrome/Application/chrome.exe +""" + +ON_WSL=0 # 1 if on WSL +CHROME_WINDOWS_MAJOR_VER=84 # check your chrome version + +# ====== + +CHROME_PUBKEY_URL=https://dl.google.com/linux/linux_signing_key.pub +CHROME_STABLE_URL=https://dl.google.com/linux/direct +CHROME_STABLE_DEB=google-chrome-stable_current_amd64.deb +CHROME_DRIVER_URL=https://chromedriver.storage.googleapis.com +CHROME_DRIVER_DEST=/usr/local/bin/chromedriver +CHROME_DRIVER_ARTIFACT=chromedriver_linux64.zip + +# Clean workspace +rm -f ./${CHROME_DRIVER_ARTIFACT} +sudo rm -f ${CHROME_DRIVER_DEST} +rm -f ./${CHROME_STABLE_DEB} + +# Install dependencies +sudo apt-get install -y openjdk-8-jre-headless xvfb libxi6 libgconf-2-4 + +if [[ "${ON_WSL}" == 0 ]]; then + # Download chrome if normal linux + wget -q -O - ${CHROME_PUBKEY_URL} | sudo apt-key add - + wget ${CHROME_STABLE_URL}/${CHROME_STABLE_DEB} + sudo dpkg -i ${CHROME_STABLE_DEB} + sudo apt -f install + sudo dpkg -i ${CHROME_STABLE_DEB} + CHROME_SUFFIX=$(google-chrome-stable --version | cut -d ' ' -f 3 | cut -d '.' -f 1) +else + CHROME_SUFFIX="${CHROME_WINDOWS_MAJOR_VER}" +fi +CHROME_DRIVER_VERSION=$(curl -sS ${CHROME_DRIVER_URL}/LATEST_RELEASE_${CHROME_SUFFIX}) + +# Install chromedriver +wget -N ${CHROME_DRIVER_URL}/${CHROME_DRIVER_VERSION}/${CHROME_DRIVER_ARTIFACT} -P ./ +unzip ./${CHROME_DRIVER_ARTIFACT} -d ./ +rm ./${CHROME_DRIVER_ARTIFACT} +sudo mv -f ./chromedriver ${CHROME_DRIVER_DEST} +sudo chown root:root ${CHROME_DRIVER_DEST} +sudo chmod 0755 ${CHROME_DRIVER_DEST} diff --git a/install-geckodriver.sh b/install-geckodriver.sh new file mode 100755 index 000000000..677c0e792 --- /dev/null +++ b/install-geckodriver.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + + +echo """ +Debian-based installer script for the GeckoDriver Firefox selenium driver tool. +""" + +GECKO_DRIVER_VERSION=v0.27.0 +GECKO_DRIVER_BASE_URL=https://github.com/mozilla/geckodriver/releases/download +GECKO_DRIVER_ARTIFACT=geckodriver-${GECKO_DRIVER_VERSION}-linux64.tar.gz +GECKO_DRIVER=geckodriver +GECKO_DRIVER_SRC=${GECKO_DRIVER}.tar.gz +GECKO_DRIVER_DEST=/usr/local/bin +GECKO_DRIVER_URL=${GECKO_DRIVER_BASE_URL}/${GECKO_DRIVER_VERSION}/${GECKO_DRIVER_ARTIFACT} + +# Clean workspace +rm -f ./${GECKO_DRIVER_ARTIFACT} +rm -f ${GECKO_DRIVER_DEST} +rm -f ./${GECKO_DRIVER_SRC} + +wget ${GECKO_DRIVER_URL} -O ${GECKO_DRIVER_SRC} +tar -xzvf ${GECKO_DRIVER_SRC} +rm -f ./${GECKO_DRIVER}.log +sudo mv -f ./${GECKO_DRIVER} ${GECKO_DRIVER_DEST} +rm ./${GECKO_DRIVER_SRC} +sudo chown root:root ${GECKO_DRIVER_DEST}/${GECKO_DRIVER} +sudo chmod 0755 ${GECKO_DRIVER_DEST}/${GECKO_DRIVER} diff --git a/js/.eslintrc.js b/js/.eslintrc.js index d371ea596..95c904dc7 100644 --- a/js/.eslintrc.js +++ b/js/.eslintrc.js @@ -1,27 +1,45 @@ module.exports = { - parser: '@typescript-eslint/parser', - env: { - browser: true, - es2020: true, - }, - extends: [ - 'airbnb-base', - ], - parserOptions: { - ecmaVersion: 11, - sourceType: 'module', - }, - rules: { - indent: ["error", 4], - semi: [2, "never"], - 'max-classes-per-file': ["error", 4] - }, - settings: { - 'import/resolver': { - node: { - paths: ['src'], - extensions: ['.js', '.jsx', '.ts', '.tsx'] - } + parser: '@typescript-eslint/parser', + env: { + browser: true, + es2020: true, + }, + extends: [ + 'eslint:recommended', + 'airbnb-base', + 'plugin:@typescript-eslint/recommended', + ], + parserOptions: { + ecmaVersion: 11, + sourceType: 'module', + }, + rules: { + indent: ['error', 4], + semi: [2, 'never'], + 'no-console': 'off', + 'max-classes-per-file': ['error', 4], + 'no-bitwise': ['error', { allow: ['>>', '<<', '&'] }], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/member-delimiter-style': [ + 'error', { + multiline: { + delimiter: 'none', + } + } + ], + }, + // TODO : typescript indent checking is buggy + // '@typescript-eslint/indent': ['error', 4], + overrides: [{ + files: ['*.ts'], + rules: { indent: 'off' }, + }], + settings: { + 'import/resolver': { + node: { + paths: ['src'], + extensions: ['.js', '.jsx', '.ts', '.tsx'] + } + } } - } -}; +} diff --git a/js/package-lock.json b/js/package-lock.json index 46d8711de..c2c44aa55 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -403,6 +403,66 @@ "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.10.7.tgz", "integrity": "sha512-v1wOSqwcyqeE65A/Pr+INp1rLBbrUZkekX0i6Io96p6rgpYAoN7r0JYoKnT1sSFSBV3VoF6YQMJCDWZM8wrKXg==" }, + "@typescript-eslint/eslint-plugin": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz", + "integrity": "sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "3.10.1", + "debug": "^4.1.1", + "functional-red-black-tree": "^1.0.1", + "regexpp": "^3.0.0", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "dependencies": { + "@typescript-eslint/experimental-utils": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz", + "integrity": "sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/typescript-estree": "3.10.1", + "eslint-scope": "^5.0.0", + "eslint-utils": "^2.0.0" + } + }, + "@typescript-eslint/types": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz", + "integrity": "sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz", + "integrity": "sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==", + "dev": true, + "requires": { + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/visitor-keys": "3.10.1", + "debug": "^4.1.1", + "glob": "^7.1.6", + "is-glob": "^4.0.1", + "lodash": "^4.17.15", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz", + "integrity": "sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + } + } + }, "@typescript-eslint/experimental-utils": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.6.1.tgz", diff --git a/js/package.json b/js/package.json index 23341e7ef..77653edc3 100644 --- a/js/package.json +++ b/js/package.json @@ -28,6 +28,7 @@ "watch": "webpack -d -w" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "^3.6.1", "@typescript-eslint/parser": "^3.6.0", "babel-eslint": "^10.1.0", "eslint": "^7.4.0", diff --git a/js/src/base.js b/js/src/base.js index 431e75c9f..e66930f5e 100644 --- a/js/src/base.js +++ b/js/src/base.js @@ -8,9 +8,6 @@ JavaScript "frontend" complement of exatomic"s Universe for use within the Jupyter notebook interface. */ -// eslint-disable-next-line -import * as util from './util' - const widgets = require('@jupyter-widgets/base') const control = require('@jupyter-widgets/controls') const three = require('./appthree') @@ -20,8 +17,6 @@ const ver = require('../package.json').version const semver = `^${ver}` // eslint-disable-next-line no-console console.log(`exatomic JS version: ${semver}`) -// eslint-disable-next-line no-console -console.log('typescript module', util) export class ExatomicBoxModel extends control.BoxModel { defaults() { diff --git a/js/src/index.js b/js/src/index.js index 8a9a8c1e5..34ab0de90 100644 --- a/js/src/index.js +++ b/js/src/index.js @@ -8,10 +8,12 @@ const utils = require('./utils') const appthree = require('./appthree') const widgets = require('./widgets') const tensor = require('./tensor') +// eslint-disable-next-line +const scene = require('./scene') const util = require('./util') const loaded = [ - base, utils, appthree, widgets, tensor, util, + base, utils, appthree, widgets, tensor, scene, util, ] Object.keys(loaded).forEach((key) => { diff --git a/js/src/scene.ts b/js/src/scene.ts new file mode 100644 index 000000000..d8c06c590 --- /dev/null +++ b/js/src/scene.ts @@ -0,0 +1,660 @@ +// Copright (c) 2015-2020, Exa Analytics Development Team +// Distributed under the terms of the Apache License 2.0 +/* """ +================= +scene.ts +================= +A 3D scene for exatomic + +*/ + +import { DOMWidgetModel, DOMWidgetView } from '@jupyter-widgets/base' + +import * as three from 'three' +import * as TrackBallControls from 'three-trackballcontrols' +import { version } from '../package.json' +// eslint-disable-next-line +import * as util from './util' + +const semver = `^${version}` + +export class SceneModel extends DOMWidgetModel { + defaults(): any { + return { + ...super.defaults(), + /* eslint-disable */ + _model_name: 'SceneModel', + _view_name: 'SceneView', + _model_module_version: semver, + _view_module_version: semver, + _model_module: 'exatomic', + _view_module: 'exatomic', + /* eslint-enable */ + width: 200, + height: 200, + } + } +} + +interface Meshes { + scene: any[] + contour: three.Mesh[] + frame: three.Mesh[] + field: three.Mesh[] + atom: three.Mesh[] + test: three.Mesh[] + two: three.Mesh[] +} + +// TODO : app interface to SceneView? + +export class SceneView extends DOMWidgetView { + scene: three.Scene + + camera: three.PerspectiveCamera + + renderer: three.WebGLRenderer | null + + controls: any // TrackBallControls + + raycaster: three.Raycaster + + mouse: three.Vector2 + + hudscene: three.Scene + + hudcamera: three.OrthographicCamera + + hudcanvas: HTMLCanvasElement + + hudcontext: CanvasRenderingContext2D | null + + hudtexture: three.Texture + + hudsprite: three.Sprite + + prevheight: number + + prevwidth: number + + hudfontsize: number + + hudcanvasdim: number + + promises: Promise + + selected: three.Mesh[] + + meshes: Meshes + + initialize(parameters: any): void { + super.initialize(parameters) + this.initListeners() + this.selected = [] + this.meshes = { + scene: [], + contour: [], + frame: [], + field: [], + atom: [], + test: [], + two: [], + } + this.prevwidth = 0 + this.prevheight = 0 + this.hudfontsize = 28 + this.hudcanvasdim = 1024 + this.promises = this.init() + } + + init(): Promise { + /* """ + init + --------------- + Promise it will work + + */ + return this.initRenderer().then(() => { + this.initControls() + this.initScene() + this.initObj() + this.initHud() + this.finalizeHudcanvas() + this.finalizeInteractive() + this.setCameraFromScene() + this.send({ type: 'init' }) + }) + } + + initScene(): void { + /* """ + initScene + --------------- + A prepped three.js scene containing an ambient light, + a directional light, and a spot light for shadow creation. + + */ + const faraway = 1000 + const smallnum = 0.3 + const offwhite = 0xdddddd + const fov = 30 + const aspect = 1 + const near = 1500 + const far = 5000 + + this.scene = new three.Scene() + const ambLight = new three.AmbientLight(offwhite, smallnum) + const dirLight = new three.DirectionalLight(offwhite, smallnum) + const sunLight = new three.SpotLight(offwhite, smallnum, 0, Math.PI / 2) + const shadowcam = new three.PerspectiveCamera(fov, aspect, near, far) + + dirLight.position.set(-faraway, -faraway, -faraway) + sunLight.position.set(faraway, faraway, faraway) + sunLight.castShadow = true + sunLight.shadow = new three.SpotLightShadow(shadowcam) + sunLight.shadow.bias = 0.0 + + this.meshes.scene.push(ambLight) + this.meshes.scene.push(dirLight) + this.meshes.scene.push(sunLight) + this.scene.add(ambLight) + this.scene.add(dirLight) + this.scene.add(sunLight) + } + + initRenderer(): Promise { + /* """ + initRenderer + --------------- + Creates a WebGLRenderer and PerspectiveCamera. + + */ + const fov = 5 // frustum vertical field of view + const near = 1 // frustum near plane + const far = 100000 // frustum far plane + const aspect = 1 // aspect ratio + + const renderer = Promise.resolve(new three.WebGLRenderer({ + antialias: true, + alpha: true, + })) + return renderer.then((ren) => { + this.renderer = ren + this.renderer.autoClear = false + this.renderer.shadowMap.enabled = true + this.renderer.shadowMap.type = three.PCFSoftShadowMap + this.el.appendChild(this.renderer.domElement) + this.camera = new three.PerspectiveCamera(fov, aspect, near, far) + }) + } + + initControls(): void { + /* """ + initControls + --------------- + Provides mouse input control over the camera in + the scene by use of a TrackBallControls object. + + */ + if (this.renderer === null) { return } + this.controls = new TrackBallControls(this.camera, this.renderer.domElement) + this.controls.rotateSpeed = 10.0 + this.controls.zoomSpeed = 5.0 + this.controls.panSpeed = 0.5 + this.controls.noZoom = false + this.controls.noPan = false + this.controls.staticMoving = true + this.controls.dynamicDampingFactor = 0.3 + this.controls.keys = [65, 83, 68] + this.controls.target = new three.Vector3(0.0, 0.0, 0.0) + this.controls.addEventListener('change', this.render.bind(this)) + } + + initHud(): void { + /* """ + initHud + --------------- + Everything needed to interact with the scene. + A Raycaster, HUD Scene and OrthographicCamera, + a mouse and an HTMLCanvas. + + */ + const w = 200 + const h = 200 + this.raycaster = new three.Raycaster() + this.hudscene = new three.Scene() + this.mouse = new three.Vector2() + this.hudcamera = new three.OrthographicCamera( + -w / 2, w / 2, h / 2, -h / 2, 1, 10, + ) + this.hudcamera.position.z = 10 + + this.hudcanvas = document.createElement('canvas') + this.hudcanvas.width = this.hudcanvasdim + this.hudcanvas.height = this.hudcanvasdim + } + + initObj(): void { + /* """ + initObj + ------------ + Create some test meshes to test hud and interactive + functionality. + + */ + const geom = new three.IcosahedronGeometry(2, 1) + const mat0 = new three.MeshBasicMaterial({ + color: 0x000000, + wireframe: true, + }) + const mesh0 = new three.Mesh(geom, mat0) + mesh0.position.set(0, 0, -5) + mesh0.name = 'Icosahedron 0 extra long label that will get cut off eventually around this location' + + const mat1 = new three.MeshBasicMaterial({ + color: 0xFF0000, + wireframe: true, + }) + const mesh1 = new three.Mesh(geom, mat1) + mesh1.position.set(0, 0, 5) + mesh1.name = 'Icosahedron 1' + this.scene.add(mesh0) + this.scene.add(mesh1) + } + + initListeners(): void { + /* """ + initListeners + --------------- + Register listeners to changes on model + + */ + this.listenTo(this.model, 'change:flag', this.updateFlag) + this.listenTo(this.model, 'msg:custom', this.handleCustomMsg) + } + + finalizeHudcanvas(): void { + /* """ + finalizeHudcanvas + ------------------ + Set up everything needed to write text to + the hudcanvas and display it in the same + renderer. + + */ + if (this.renderer === null) { return } + this.hudcontext = this.hudcanvas.getContext('2d') + if (this.hudcontext === null) { return } + this.hudcontext.textAlign = 'left' + this.hudcontext.textBaseline = 'bottom' + this.hudcontext.font = `${this.hudfontsize}px Arial` + + this.hudtexture = new three.Texture(this.hudcanvas) + this.hudtexture.anisotropy = this.renderer.capabilities.getMaxAnisotropy() + this.hudtexture.minFilter = three.NearestMipMapLinearFilter + this.hudtexture.magFilter = three.NearestFilter + this.hudtexture.needsUpdate = true + + const material = new three.SpriteMaterial({ map: this.hudtexture }) + this.hudsprite = new three.Sprite(material) + this.hudsprite.position.set(1000, 1000, 1000) + this.hudsprite.scale.set(512, 512, 1) + this.hudscene.add(this.hudcamera) + this.hudscene.add(this.hudsprite) + } + + updateFlag(): void { + /* """ + updateFlag + ----------- + Fired when widget.flag = not widget.flag in the + notebook. A debugging convenience. + + */ + this.resize() + } + + resize(): void { + /* """ + resize + ----------- + Resizes the renderer and updates all cameras + and controls to respect the new renderer size. + Caches previous resize parameters to reduce + the call stack in the animation loop. Additionally, + the height of the DOMWidgetView.el element is + sporadic after a kernel interruption, so an + explicit check is made for the disconnected state. + + */ + if (this.renderer === null) { return } + let w + let h + if (this.el.className.includes('disconnected')) { + w = this.prevwidth || this.model.get('width') + h = this.prevheight || this.model.get('height') + } else { + const pos = this.el.getBoundingClientRect() + w = Math.floor(pos.width) + h = Math.floor(pos.height - 5) + if ((w !== this.prevwidth) || (h !== this.prevheight)) { + this.renderer.setSize(w, h) + this.camera.aspect = w / h + this.camera.updateProjectionMatrix() + this.camera.updateMatrix() + this.hudcamera.left = -w / 2 + this.hudcamera.right = w / 2 + this.hudcamera.top = h / 2 + this.hudcamera.bottom = -h / 2 + this.hudcamera.updateProjectionMatrix() + this.hudcamera.updateMatrix() + this.controls.handleResize() + this.prevheight = h + this.prevwidth = w + } + } + } + + render(): any { + /* """ + render + ----------- + Return all promises + + */ + return this.promises.then(this.animate.bind(this)) + } + + paint(): void { + /* """ + paint + ----------- + Update the renderer and render both the scene + and hudscene. + + */ + if (this.renderer === null) { return } + this.resize() + this.controls.update() + this.renderer.clear() + this.renderer.render(this.scene, this.camera) + this.renderer.clearDepth() + this.renderer.render(this.hudscene, this.hudcamera) + } + + animate(): void { + /* """ + animate + ----------- + Turn on the animation loop. + + */ + if (this.renderer === null) { return } + this.renderer.setAnimationLoop(this.paint.bind(this)) + } + + handleCustomMsg(msg: any): void { + /* """ + handleCustomMsg + ----------------- + Route a message from the kernel. + + */ + if (msg.type === 'close') { this.close() } else { console.log('received msg', msg) } + } + + setCameraFromScene(): void { + /* """ + setCameraFromScene + -------------------- + Find the "center-of-objects" of the scene and point the + camera towards it from a reasonable distance away from + all the objects in the scene. Also adjusts scene lighting + to fit the scene. + + */ + const [, dirLight, sunLight] = this.meshes.scene + const bbox = new three.Box3().setFromObject(this.scene) + const { min, max } = bbox + const ox = (max.x + min.x) / 2 + const oy = (max.y + min.y) / 2 + const oz = (max.z + min.z) / 2 + const px = Math.max(2 * max.x, 60) + const py = Math.max(2 * max.y, 60) + const pz = Math.max(2 * max.z, 60) + const far = Math.max(px, Math.max(py, pz)) + this.camera.position.set(far, far, far) + this.controls.target.setX(ox) + this.controls.target.setY(oy) + this.controls.target.setZ(oz) + this.camera.lookAt(this.controls.target) + let mi = Math.min(min.x, Math.min(min.y, min.z)) + let ma = Math.max(max.x, Math.max(max.y, max.z)) + mi = Math.min(-1000, 2 * mi) + ma = Math.max(1000, 2 * ma) + dirLight.position.set(mi, mi, mi) + sunLight.position.set(ma, ma, ma) + } + + close(): void { + /* """ + close + ----------- + Garbage collect all three.js objects when widget + is closed. + + */ + if (this.renderer === null) { return } + console.log('disposing contents of Scene') + // TODO: clear meshes + // and all top level attrs + this.hudtexture.dispose() + this.renderer.forceContextLoss() + this.renderer.dispose() + this.renderer.setAnimationLoop(null) + this.renderer = null + } + + writeFromSelected(): void { + /* """ + writeFromSelected + --------------------- + Based on the number (and kind) of objects selected, + when no other banner is being displayed, display the + calculable information based on what was selected. + + For example, when two Meshes are selected with positions, + we can compute the distance between them and write out + the result when both objects are selected and no other + objects are hovered. + + */ + this.hudsprite.position.set(1000, 1000, 1000) + if (this.selected.length === 2) { + const obj0 = this.selected[0] + const obj1 = this.selected[1] + const dist = Math.sqrt( + (obj0.position.x - obj1.position.x) ** 2 + + (obj0.position.y - obj1.position.y) ** 2 + + (obj0.position.z - obj1.position.z) ** 2, + ) + this.writeHud(`Distance between selected ${dist} units`) + } + } + + writeHud(banner: string): void { + /* """ + writeHud + --------------- + Write into a CanvasRenderingContext2D the given banner. + The hudcontext API generally accepts (x, y, w, h) and + has coordinates laid out as follows: + + y=0 + x=0 +---+ x=w -+ 1024 + | | | + +---+ | + y=h | + | | + +----------+ + 1024 + + All based on the hudcanvas (1024x1024). By measuring + the text width and knowing the font size, construct a + bordered text box to frame the text banner. Then write + the text to the hudcontext. Currently only write one line + of text, as much that will fit into the full hudcanvas size + of 1024 pixels. + + The hudcontext is where we do the drawing, but the sprite + displays the result. The hudcanvas (housing the hudcontext) + was the source for the three.Texture that is the material + the sprite renders. + + The sprite is rendered in a hudscene using an orthographic + camera. Since we only write one line of text into an otherwise + square hudcanvas, the center of the sprite and the center of + the drawn text are nowhere near each other. + + Reassign the center of the sprite to be the top left corner + of the hudcontext using the following coordinate system: + + y + 1 - - - 1 + |_____| | + | | + 0 - - - 1 x + + So that the apparent "center" of the sprite will be closer + to the actual drawn text. Finally, position the sprite (in + orthographic coordinates [-w/2, w/2, -h/2, h/2]). + */ + + if (this.hudcontext === null) { + return + } + this.hudcontext.clearRect(0, 0, this.hudcanvas.width, this.hudcanvas.height) + + // frame the text + const pad = 6 + const textWidth = this.hudcontext.measureText(banner).width + + // with a black border + this.hudcontext.fillStyle = 'rgba(0,0,0,0.9)' + this.hudcontext.fillRect(0, 0, textWidth + 2 * pad, this.hudfontsize + 2 * pad) + + // and light background + this.hudcontext.fillStyle = 'rgba(245,245,245,0.9)' + this.hudcontext.fillRect( + pad / 2, pad / 2, textWidth + pad, this.hudfontsize + pad, + ) + // for easy to read black text + this.hudcontext.fillStyle = 'rgba(0,0,0,0.95)' + this.hudcontext.fillText(banner, pad, this.hudfontsize + pad) + + this.hudsprite.center.x = 0 + this.hudsprite.center.y = 1 + + const width = this.el.offsetWidth + const height = this.el.offsetHeight + this.hudsprite.position.set(-width / 2, -height / 2 + this.hudfontsize - pad / 4, 0) + this.hudsprite.material.needsUpdate = true + this.hudtexture.needsUpdate = true + } + + updateSelected(intersects: any[]): void { + /* """ + updateSelected + ---------------- + If the first element of intersects is not selected, + highlight it and add it to selected objects. + Otherwise, if the first element of intersects is + selected, unhighlight it and remove it from + selected objects + */ + if (!intersects.length) { return } + const obj = intersects[0].object + const uuids = this.selected.map((o) => o.uuid) + const { uuid } = obj + const idx = uuids.indexOf(uuid) + if (idx > -1) { + obj.material.color.setHex(obj.oldHex) + this.selected.splice(idx, 1) + } else { + obj.oldHex = obj.material.color.getHex() + const newHex = util.lightenColor(obj.oldHex) + obj.material.color.setHex(newHex) + this.selected.push(obj) + } + } + + updateHovered(intersects: any[]): void { + /* """ + updateHovered + -------------- + Update the HUD based on the current hover-over + logic. If hovering an object with label information, + display it. Otherwise fall back to selected + objects. + */ + if (!intersects.length) { + this.writeFromSelected() + return + } + const { name } = intersects[0].object + // no name no banner + // TODO: a default "repr"? + if (!name) { return } + this.writeHud(name) + } + + handleMouseEvent(event: MouseEvent, kind: string): void { + /* """ + handleMouseEvent + ------------------ + Compute the mouse position in 2D hovering over + the 3D scene. Cast a ray from that 2D position + into the 3D scene and get all intersecting objects. + + There are two distinct interactions with the three.js + scene, hover-over and selection. The hover-over displays + information about the object (if available) and the + selection allows to select multiple items and compute + values related to collections of objects in the scene. + */ + event.preventDefault() + const pos = this.el.getBoundingClientRect() + const w: number = this.el.offsetWidth + const h: number = this.el.offsetHeight + this.mouse.x = ((event.clientX - pos.x) / w) * 2 - 1 + this.mouse.y = -((event.clientY - pos.y) / h) * 2 + 1 + this.raycaster.setFromCamera(this.mouse, this.camera) + const intersects = this.raycaster.intersectObjects(this.scene.children) + if (kind === 'mousemove') { + this.updateHovered(intersects) + } else if (kind === 'mouseup') { + this.updateSelected(intersects) + } + } + + finalizeInteractive(): void { + /* """ + finalizeInteractive + -------------------- + Adds mouse interactivity event listeners + + */ + this.el.addEventListener( + 'mousemove', ((event: MouseEvent): void => { + this.handleMouseEvent(event, 'mousemove') + }), + false, + ) + this.el.addEventListener( + 'mouseup', ((event: MouseEvent): void => { + this.handleMouseEvent(event, 'mouseup') + }), + false, + ) + } +} diff --git a/js/src/tsmod.d.ts b/js/src/tsmod.d.ts new file mode 100644 index 000000000..1578ac008 --- /dev/null +++ b/js/src/tsmod.d.ts @@ -0,0 +1 @@ +declare module 'three-trackballcontrols' diff --git a/js/src/util.ts b/js/src/util.ts index 1bbc485b4..c0f455792 100644 --- a/js/src/util.ts +++ b/js/src/util.ts @@ -1,9 +1,5 @@ -export function add(x: number, y: number): number { - return x + y -} - export function repeatFloat(value: number, nitems: number): Float32Array { - const array: Float32Array = new Float32Array(nitems) + const array = new Float32Array(nitems) array.fill(value) return array } @@ -11,7 +7,7 @@ export function repeatFloat(value: number, nitems: number): Float32Array { export function linspace(min: number, max: number, n: number): Float32Array { const n1 = n - 1 const step = (max - min) / n1 - const array: Float32Array = new Float32Array(n) + const array = new Float32Array(n) for (let i = 0; i < n; i += 1) { array[i] = min + i * step } @@ -21,10 +17,9 @@ export function linspace(min: number, max: number, n: number): Float32Array { export function createFloatArrayXyz( x: Array, y: Array, z: Array, ): Float32Array { - const n: number = Math.max(x.length, y.length, z.length) - let i3: number = 0 - const array: Float32Array = new Float32Array(3 * n) - i3 = 0 + const n = Math.max(x.length, y.length, z.length) + let i3 = 0 + const array = new Float32Array(3 * n) for (let i = 0; i < n; i += 1) { array[i3] = x[i] array[i3 + 1] = y[i] @@ -33,3 +28,17 @@ export function createFloatArrayXyz( } return array } + +export function lightenColor(color: number): number { + const light = 76 + let R = (color >> 16) + let G = ((color >> 8) & 0x00FF) + let B = (color & 0x0000FF) + R = (R === 0) ? 110 : R + light + G = (G === 0) ? 110 : G + light + B = (B === 0) ? 111 : B + light + R = Math.min(Math.max(R, 0), 255) + G = Math.min(Math.max(G, 0), 255) + B = Math.min(Math.max(B, 0), 255) + return (0x1000000 + R * 0x10000 + G * 0x100 + B) +} diff --git a/js/tsconfig.json b/js/tsconfig.json index a44eb0e0d..6919563bf 100644 --- a/js/tsconfig.json +++ b/js/tsconfig.json @@ -8,7 +8,10 @@ "module": "esnext", "target": "es2017", "outDir": "./lib", - "allowJs": true + "allowJs": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noEmitOnError": true }, "include": [ "./src/**/*" diff --git a/requirements.txt b/requirements.txt index 17a991d33..ee48dc01f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +pandas<1.2 exa>=0.5.24 six>=1.0 numexpr>=2.0 diff --git a/test_widget.py b/test_widget.py new file mode 100644 index 000000000..8a7a01ba7 --- /dev/null +++ b/test_widget.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python +""" +High-level exatomic widget test script. Start up +a jupyter notebook server, use selenium to interact +with the browser, and execute widget rendering. +Optionally persist a reference PNG of the rendered +widget, e.g. to compare to a reference PNG for +strict validation. + + +Configure the script by environment variables: + + VENDOR_TYPE = chrome OR firefox (default chrome) + BROWSER_PATH = /path/to/browser (optional) + HEADLESS_RUN = true OR false (default true) + CLEANUP_POST = true OR false (default true) + + python test_widget.py + + +Or by command line (takes priority over environment, +specify as few as necessary): + + python test_widget.py \ + --Selenium.vendor_type=firefox|chrome \ + --Selenium.browser_path=/usr/bin/google-chrome-stable \ + --Selenium.headless_run=true|false \ + --Selenium.console_port='9222' \ + --Selenium.driver_timeout=10 \ + --Notebook.server_url='http://path/to/server' \ + --Notebook.server_port='8889' \ + --Notebook.server_token='mystatictoken' \ + --Notebook.cleanup_post=true|false +""" + +import os +import sys +import logging.config +from glob import glob + +from uuid import uuid4 +from time import sleep +from shutil import rmtree, which +from subprocess import Popen, PIPE +from importlib import import_module + +from traitlets.config.application import Application +from traitlets.config.configurable import Configurable +from traitlets import Int, Unicode, Bool, default, validate + +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.wait import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.action_chains import ActionChains +from selenium.common.exceptions import TimeoutException + + +logging.basicConfig() +logging.getLogger('test_widget').setLevel(logging.INFO) +DEFAULT_VENDOR_TYPE = 'chrome' +DEFAULT_HEADLESS_RUN = 'true' +DEFAULT_CLEANUP_POST = 'true' +DEFAULT_CONSOLE_PORT = '9222' +DEFAULT_BROWSER_PATH = None +DEFAULT_DRIVER_TIMEOUT = 10 + + +class DisabledConfiguration(Exception): pass + + +class Base: + + @property + def log(self): + log = logging.getLogger( + '.'.join(['test_widget', self.__class__.__name__])) + return log + + def clean_bool(self, val): + self.log.debug(f'cleaning {type(val)} {val} to bool') + if isinstance(val, bool): + return val + return {'true': True, 'false': False}[val.lower()] + + +class Selenium(Base, Configurable): + """A selenium interface for custom widgets in the jupyter notebook.""" + vendor_type = Unicode('').tag(config=True) + browser_path = Unicode('').tag(config=True) + headless_run = Bool(False).tag(config=True) + console_port = Unicode(DEFAULT_CONSOLE_PORT).tag(config=True) + driver_log = Unicode('driver.log').tag(config=True) + driver_timeout = Int(DEFAULT_DRIVER_TIMEOUT).tag(config=True) + + @default('vendor_type') + def _default_vendor_type(self): + val = os.getenv('VENDOR_TYPE', DEFAULT_VENDOR_TYPE) + self.log.debug(f'_default_vendor_type: {val}') + return val + + @default('browser_path') + def _default_browser_path(self): + path = os.getenv('BROWSER_PATH', DEFAULT_BROWSER_PATH) + path = path.replace('\\', '') + if not os.path.isfile(path): + self.log.info(f'path from environment={path} not found') + parts = path.split(os.sep) + self.log.info(parts) + base = '/' + for part in parts: + base = os.path.join(base, part) + try: + fols = os.listdir(base) + self.log.info(f'path={base}, len={len(fols)}') + for fol in fols: + self.log.info(fol) + except Exception as e: + self.log.info(f'path={base}, {str(e)}') + if path is not None: + return path + aliases = { + 'chrome': ['google-chrome-stable', 'google-chrome'], + }.get(self.vendor_type, [self.vendor_type]) + path = None + for alias in aliases: + try: + path = which(alias) + self.log.info(f'found browser path: {path}') + break + except Exception: + continue + if path is None: + raise Exception(f'browser executable not found for vendor_type={self.vendor_type}') + return path + + @validate('headless_run') + def _validate_headless_run(self, prop): + prop['value'] = self.clean_bool(prop['value']) + return prop['value'] + + @default('headless_run') + def _default_headless_run(self): + val = os.getenv('HEADLESS_RUN', DEFAULT_HEADLESS_RUN).lower() + return self.clean_bool(val) + + @staticmethod + def click_by_css(driver, wait, css): + wait.until( + EC.presence_of_element_located((By.CSS_SELECTOR, css)) + ) + [obj] = driver.find_elements_by_css_selector(css) + obj.click() + + def get_vendor_options(self): + mod = f'selenium.webdriver.{self.vendor_type}.options' + self.log.info(f'importing {mod}') + mod = import_module(mod) + options = mod.Options() + + # parametrize or apply vendor-specific config + if self.vendor_type == 'firefox': + self.log.info('no firefox-specific options') + headless_arg = '-headless' + binary_attr = 'binary' + + elif self.vendor_type == 'chrome': + headless_arg = '--headless' + binary_attr = 'binary_location' + self.log.info('adding chrome-specific options') + options.add_argument('--no-sandbox') + options.add_argument('--disable-gpu') + options.add_argument('--disable-extensions') + options.add_argument('--disable-dev-shm-usage') + options.add_argument(f'--remote-debugging-port={self.console_port}') + + else: + self.log.info('unfamiliar vendor_type {self.vendor_type}') + self.log.info('guessing chrome-like driver options') + headless_arg = '--headless' + binary_attr = 'binary_location' + + # apply parametrized config + self.log.info(f'setting options.{binary_attr} to {self.browser_path}') + setattr(options, binary_attr, self.browser_path) + self.log.info(f'adding {headless_arg} flag {self.headless_run}') + if self.headless_run: + if self.vendor_type == 'firefox': + raise DisabledConfiguration("From firefox driver: WebGL warning: : Can't use WebGL in headless mode (https://bugzil.la/1375585).") + options.add_argument(headless_arg) + return options + + def init_webdriver(self, scratch): + log_path = f'{scratch}/{self.driver_log}' + proper = self.vendor_type.title() + cls = getattr(webdriver, proper, None) + if cls is None: + raise Exception(f'no webdriver {proper} found') + return cls(options=self.get_vendor_options(), service_log_path=log_path) + + def create_and_goto_new_notebook(self, driver, wait): + # click on new notebook dropdown + self.log.info('opening new notebook') + self.click_by_css(driver, wait, '#new-dropdown-button') + # primary notebook server window + n_initial = len(driver.window_handles) + # spawns new notebook + self.click_by_css(driver, wait, '#kernel-python3 > a') + # wait for new notebook + wait.until( + lambda driver: n_initial != len(driver.window_handles) + ) + # switch to new notebook + self.log.info('navigating to new notebook') + driver.switch_to.window(driver.window_handles[1]) + + def insert_and_execute_first_cell(self, driver, wait, code_to_run): + # select the first input cell + self.log.info('selecting first cell') + cell = '#notebook-container > div > div.input > div.inner_cell > div.input_area' + self.click_by_css(driver, wait, cell) + # input some text + self.log.info(f"writing '{code_to_run}'") + ActionChains(driver).send_keys(code_to_run).perform() + [cell] = driver.find_elements_by_css_selector(cell) + # execute the cell + self.log.info('executing first cell') + (ActionChains(driver).key_down(Keys.SHIFT) + .key_down(Keys.ENTER) + .key_up(Keys.SHIFT) + .key_up(Keys.ENTER) + .perform()) + + def close_and_leave_new_notebook(self, driver, wait): + # shut down the new notebook + self.log.info('clicking on file menu button') + menu = '#filelink' + self.click_by_css(driver, wait, menu) + + self.log.info('clicking on close and halt') + n_current = len(driver.window_handles) + close = '#close_and_halt > a' + self.click_by_css(driver, wait, close) + + # TODO : convention to handle browser-specific logic + if self.vendor_type == 'chrome': + # closing triggers "Leave without saving? alert" + wait.until(EC.alert_is_present()) + driver.switch_to.alert.accept() + self.log.info('caught alert about unsaved notebook') + + # wait until second notebook window is closed + wait.until( + lambda driver: n_current != len(driver.window_handles) + ) + + # switch back to server home window + driver.switch_to.window(driver.window_handles[0]) + + def shutdown_notebook(self, driver, wait): + self.log.info('closing notebook server down from UI') + self.click_by_css(driver, wait, '#shutdown') + + def run_basic(self, server_url, scratch_dir): + + with self.init_webdriver(scratch_dir) as driver: + + wait = WebDriverWait(driver, self.driver_timeout) + driver.get(server_url) + + # spawn a notebook and render a widget + self.create_and_goto_new_notebook(driver, wait) + code = 'import exatomic; exatomic.widgets.widget_base.Scene()' + self.insert_and_execute_first_cell(driver, wait, code) + + # touch the rendered canvas + self.log.info('clicking on the widget') + scene = ('#notebook-container > ' + 'div.cell.code_cell.rendered.unselected > ' + 'div.output_wrapper > div.output > div > ' + 'div.output_subarea.jupyter-widgets-view > ' + 'div > canvas') + try: + self.click_by_css(driver, wait, scene) + # download a PNG of the widget + self.log.info('screenshotting the widget') + driver.get_screenshot_as_file(f'{scratch_dir}/widget.png') + except TimeoutException: + self.log.info(f'failed to find css "{scene}", perhaps make it less specific') + finally: + # shut it down + self.close_and_leave_new_notebook(driver, wait) + self.shutdown_notebook(driver, wait) + driver.close() + + + + +class Notebook(Base, Configurable): + server_url = Unicode('').tag(config=True) + server_port = Unicode('8889').tag(config=True) + server_token = Unicode(str(uuid4())).tag(config=True) + cleanup_post = Bool(True).tag(config=True) + + @default('server_url') + def _default_server_url(self): + return f'http://localhost:{self.server_port}/?token={self.server_token}' + + @validate('cleanup_post') + def _validate_cleanup_post(self, prop): + prop['value'] = self.clean_bool(prop['value']) + return prop['value'] + + @default('cleanup_post') + def _default_cleanup_post(self): + val = os.getenv('CLEANUP_POST', DEFAULT_CLEANUP_POST).lower() + return self.clean_bool(val) + + def start_notebook_server(self): + self.log.info(f'making scratch dir {self.server_token}') + os.makedirs(self.server_token, exist_ok=True) + self.log.info(f'starting notebook server {self.server_url}') + command = [ + 'jupyter', 'notebook', '--no-browser', + f'--NotebookApp.port={self.server_port}', + f'--NotebookApp.token={self.server_token}' + ] + self.log.info(f"full command {' '.join(command)}") + self.server_process = Popen( + command, stdout=PIPE, stderr=PIPE, cwd=self.server_token) + + def stop_notebook_server(self): + proc = getattr(self, 'server_process') + if proc is not None: + self.log.info('stopping notebook server') + proc.kill() + if self.cleanup_post and os.path.isdir(self.server_token): + rmtree(self.server_token) + + +class App(Base, Application): + + def _initialize(self): + self.notebook = Notebook() + self.selenium = Selenium() + + def initialize(self, argv=None): + self.parse_command_line(argv) + self.notebook = Notebook(config=self.config) + self.selenium = Selenium(config=self.config) + self.log.info(f'custom config: {self.config}') + + def run(self, verbose=True): + if os.uname == 'nt': + self.log.info("widget test is disabled on Windows") + return + nb = self.notebook + sl = self.selenium + nb.start_notebook_server() + try: + sleep(2) + sl.run_basic(nb.server_url, nb.server_token) + except DisabledConfiguration as e: + self.log.info("this configuration is disabled") + self.log.info(str(e)) + finally: + log = f'{nb.server_token}/{sl.driver_log}' + if verbose and os.path.isfile(log): + with open(log, 'r') as f: + for ln in f: + print(ln, end='') + nb.stop_notebook_server() + + +def test_selenium(): + """pytest flags seems to interfere with the + traitlets command-line system so use a work + around for now. + """ + app = App() + app._initialize() + app.run() + +def startup_info(): + log = logging.getLogger('test_widget') + width = 12 + spacer = f'{{:<{width}}}{{:>{width}}}'.format + header = spacer('env', 'val') + log.info(header) + log.info('-' * len(header)) + for env in [ + 'VENDOR_TYPE', + 'HEADLESS_RUN', + 'CLEANUP_POST', + 'MOZ_HEADLESS', + ]: + log.info(spacer(env, os.getenv(env, 'N/A'))) + + +if __name__ == '__main__': + startup_info() + app = App() + app.initialize(sys.argv) + app.run()