diff --git a/.env.dist b/.env.dist new file mode 100644 index 00000000..3d9414cd --- /dev/null +++ b/.env.dist @@ -0,0 +1,24 @@ +# Location of Ollama models at your HOST machine +HOST_OLLAMA_MODELS_DIR="/path/to/your/.ollama/models" +# UI port at the HOST system +DEVIKA_UI_PORT=3033 +# Ollama daemon configuration variables +OLLAMA_ORIGINS="*" +OLLAMA_KEEP_ALIVE="10m" +OLLAMA_MODELS="/root/.ollama/models" +# Ollama service networking configuration +OLLAMA_SERVICE_HOST=ollama +OLLAMA_PORT=11434 +OLLAMA_PORT_HOST=11435 +OLLAMA_DEBUG= +# Devika backend application folder +DEVIKA_APP_ROOT="/home/devika" +DEVIKA_API_PORT=1337 +# PyTorch +TOKENIZERS_PARALLELISM=true +# UI frontend +VITE_API_BASE_URL="http://127.0.0.1:1337" +# +DEBUG=true +# Specify playwright version if necessary +PLAYWRIGHT_VERSION=1.43.0 diff --git a/.gitignore b/.gitignore index fec312fd..6cb0d3a9 100644 --- a/.gitignore +++ b/.gitignore @@ -160,4 +160,6 @@ cython_debug/ .idea/ notes.md -data/ \ No newline at end of file +data/ +**/.history +*.code-workspace \ No newline at end of file diff --git a/app.dockerfile b/app.dockerfile index 693addb6..8a8fc41e 100644 --- a/app.dockerfile +++ b/app.dockerfile @@ -1,29 +1,36 @@ -FROM debian:12 +FROM node:18.20.2-bullseye # setting up build variable -ARG VITE_API_BASE_URL -ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} +ARG vite_api_base_url +ARG user +ARG uid +ARG debug +ARG dev_mode +ARG apt_cache_dir=/var/cache/apt -# setting up os env -USER root -WORKDIR /home/nonroot/client -RUN groupadd -r nonroot && useradd -r -g nonroot -d /home/nonroot/client -s /bin/bash nonroot +ENV VITE_API_BASE_URL=${vite_api_base_url} +ENV DEBUG="$debug" +ENV DEBIAN_FRONTEND=noninteractive -# install node js -RUN apt-get update && apt-get upgrade -y -RUN apt-get install -y build-essential software-properties-common curl sudo wget git -RUN curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - -RUN apt-get install nodejs +WORKDIR /root/webui -# copying devika app client only -COPY ui /home/nonroot/client/ui -COPY src /home/nonroot/client/src -COPY config.toml /home/nonroot/client/ +RUN --mount=type=cache,target=${apt_cache_dir},sharing=locked \ + if [ -n "${debug}" ]; then set -eux; fi && \ + apt-get -q update > /dev/null && \ + if [ -z "${dev_mode}" ]; then apt-get -qy upgrade > /dev/null; fi && \ + apt-get install -qy build-essential software-properties-common curl wget git > /dev/null -RUN cd ui && npm install && npm install -g npm && npm install -g bun -RUN chown -R nonroot:nonroot /home/nonroot/client +COPY ui ui +COPY src src +COPY config.toml . -USER nonroot -WORKDIR /home/nonroot/client/ui +ARG npm_cache_dir=/var/cache/npm -ENTRYPOINT [ "npx", "bun", "run", "dev", "--", "--host" ] \ No newline at end of file +WORKDIR /root/webui/ui + +RUN --mount=type=cache,target=${npm_cache_dir},sharing=locked \ + npm config set --global cache "${npm_cache_dir}" && \ + npm install -g npm@latest > /dev/null && \ + yarn install --frozen-lockfile > /dev/null + +ENTRYPOINT [ "yarn", "run", "dev", "--host" ] \ No newline at end of file diff --git a/devika.dockerfile b/devika.dockerfile index bc7a3576..187ba1c9 100644 --- a/devika.dockerfile +++ b/devika.dockerfile @@ -1,36 +1,81 @@ -FROM debian:12 +FROM python:3.11.9-bookworm as backend -# setting up os env -USER root -WORKDIR /home/nonroot/devika -RUN groupadd -r nonroot && useradd -r -g nonroot -d /home/nonroot/devika -s /bin/bash nonroot +ARG root_dir + +WORKDIR "${root_dir}" + +ARG debug +ARG dev_mode +ARG apt_cache_dir=/var/cache/apt ENV PYTHONUNBUFFERED 1 ENV PYTHONDONTWRITEBYTECODE 1 +ENV DEBUG="$debug" +ENV DEBIAN_FRONTEND=noninteractive +ENV NVIDIA_VISIBLE_DEVICES=all + +RUN --mount=type=cache,target=${apt_cache_dir},sharing=locked \ + if [ -n "${debug}" ]; then set -eux; fi && \ + apt-get update && \ + if [ -z "${dev_mode}" ]; then apt-get -qy upgrade > /dev/null; fi && \ + apt-get install -y --no-install-recommends software-properties-common \ + curl wget git + +ADD --checksum=sha256:4da8dde69eca0d9bc31420349a204851bfa2a1c87aeb87fe0c05517797edaac4 https://repo.anaconda.com/miniconda/Miniconda3-py311_24.3.0-0-Linux-x86_64.sh /tmp/ + +ARG conda_root=/var/miniconda3 +ARG venv_name=devika_env + +ENV CONDA_ROOT="${conda_root}" +ENV VENV_NAME="${venv_name}" +ENV APP_ROOT="${root_dir}" + +RUN if [ -n "${debug}" ]; then set -eux; fi && \ + echo "Installing Miniconda..." && \ + mkdir -p "${CONDA_ROOT}" && \ + bash /tmp/Miniconda3-py311_24.3.0-0-Linux-x86_64.sh -b -u -p ${CONDA_ROOT} > /dev/null + +ARG user=root +ARG uid +ARG conda_pkgs_dir="${CONDA_ROOT}/pkgs" + +ENV PATH="/home/${user}/.local/bin:${CONDA_ROOT}/bin:${PATH}" +ENV PYTHONPATH="${root_dir}/devika" + +WORKDIR ${APP_ROOT} + +COPY environment.yml . + +RUN --mount=type=cache,target=${conda_pkgs_dir},sharing=locked \ + if [ -n "${debug}" ]; then set -eux; fi && \ + conda config --add channels conda && \ + conda config --add channels conda-forge && \ + conda config --add channels anaconda && \ + conda config --add channels microsoft && \ + conda install -qy pip && \ + conda env create --file environment.yml -n "${venv_name}" + +COPY src src +COPY sample.config.toml . +COPY devika.py . +COPY entrypoint.sh /docker-entrypoint.sh + +ARG ollama_endpoint +ENV OLLAMA_ENDPOINT=$ollama_endpoint + +# Patch source files for Docker environment +RUN if [ -n "${debug}" ]; then set -eux; fi && \ + chmod a+x /docker-entrypoint.sh && \ + sed -i 's#OLLAMA = "http://127.0.0.1:11434"#OLLAMA = "OLLAMA_ENDPOINT"#' sample.config.toml && \ + echo "import os" | cat - src/llm/ollama_client.py > temp_file && mv -f temp_file src/llm/ollama_client.py && \ + sed -i 's#Config().get_ollama_api_endpoint()#os.getenv(Config().get_ollama_api_endpoint())#g' src/llm/ollama_client.py + + +ENTRYPOINT [ "/docker-entrypoint.sh" ] + +# Activate Miniconda environment +RUN eval "$(conda shell.bash activate "$venv_name")" +# Make RUN commands use the new environment +SHELL [ "conda", "run", "-n $venv_name /bin/bash -c" ] -# setting up python3 -RUN apt-get update && apt-get upgrade -y -RUN apt-get install -y build-essential software-properties-common curl sudo wget git -RUN apt-get install -y python3 python3-pip -RUN curl -fsSL https://astral.sh/uv/install.sh | sudo -E bash - -RUN $HOME/.cargo/bin/uv venv -ENV PATH="/home/nonroot/devika/.venv/bin:$HOME/.cargo/bin:$PATH" - -# copy devika python engine only -RUN $HOME/.cargo/bin/uv venv -COPY requirements.txt /home/nonroot/devika/ -RUN UV_HTTP_TIMEOUT=100000 $HOME/.cargo/bin/uv pip install -r requirements.txt -RUN playwright install-deps chromium - -COPY src /home/nonroot/devika/src -COPY config.toml /home/nonroot/devika/ -COPY devika.py /home/nonroot/devika/ -RUN chown -R nonroot:nonroot /home/nonroot/devika - -USER nonroot -WORKDIR /home/nonroot/devika -ENV PATH="/home/nonroot/devika/.venv/bin:$HOME/.cargo/bin:$PATH" -RUN mkdir /home/nonroot/devika/db -RUN playwright install chromium - -ENTRYPOINT [ "python3", "-m", "devika" ] +CMD [ "python3", "-m", "devika" ] diff --git a/docker-compose.yaml b/docker-compose.yaml index cb7d06cb..bffb0b9a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,61 +1,82 @@ -version: "3.9" - services: - ollama-service: + devika-ollama-service: image: ollama/ollama:latest - expose: - - 11434 - ports: - - 11434:11434 + hostname: ${OLLAMA_SERVICE_HOST} + # cURL not provided by image healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:11434/ || exit 1"] - interval: 5s + test: ["CMD-SHELL", "wget http://localhost:${OLLAMA_PORT:?}/ > /dev/null || exit 1"] + interval: 15s timeout: 30s - retries: 5 + retries: 4 start_period: 30s networks: - - devika-subnetwork + devika-subnetwork: + # Uncomment below to access Ollama from HOST machine + # ports: + # - "${OLLAMA_PORT_HOST:?}:${OLLAMA_PORT:?}" + environment: + - OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT:?} + - OLLAMA_ORIGINS=${OLLAMA_ORIGINS:?} + - OLLAMA_KEEP_ALIVE=${OLLAMA_KEEP_ALIVE:?} + - OLLAMA_DEBUG=${OLLAMA_DEBUG} + - OLLAMA_MODELS=${OLLAMA_MODELS:?} + volumes: + - ${HOST_OLLAMA_MODELS_DIR}:${OLLAMA_MODELS:?} + tty: true devika-backend-engine: + image: devika-backend:conda-cuda12 + hostname: backend.devika build: context: . dockerfile: devika.dockerfile - depends_on: - - ollama-service - expose: - - 1337 - ports: - - 1337:1337 + args: + - debug=true + - dev_mode=true + - root_dir=${DEVIKA_APP_ROOT} + - host_docker_root=/etc/systemd/system/docker-compose.d + - ollama_endpoint=http://${OLLAMA_SERVICE_HOST}:${OLLAMA_PORT} + env_file: .env environment: - - OLLAMA_HOST=http://ollama-service:11434 + - TOKENIZERS_PARALLELISM=${TOKENIZERS_PARALLELISM:?} healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:1337/ || exit 1"] - interval: 5s + interval: 15s timeout: 30s - retries: 5 + retries: 4 start_period: 30s + ports: + - "${DEVIKA_API_PORT:?}:${DEVIKA_API_PORT:?}" volumes: - - devika-backend-dbstore:/home/nonroot/devika/db + - devika-backend-dbstore:${DEVIKA_APP_ROOT:?}/db + - devika-root-vol:/root networks: - - devika-subnetwork + devika-subnetwork: + working_dir: ${DEVIKA_APP_ROOT:?} + depends_on: + - devika-ollama-service + tty: true devika-frontend-app: build: context: . dockerfile: app.dockerfile args: - - VITE_API_BASE_URL=http://127.0.0.1:1337 + - vite_api_base_url=${VITE_API_BASE_URL:?} depends_on: - devika-backend-engine - expose: - - 3000 + - devika-ollama-service ports: - - 3000:3000 + - ${DEVIKA_UI_PORT:?}:3000 networks: - - devika-subnetwork + devika-subnetwork: + tty: true networks: devika-subnetwork: + ipam: + driver: default volumes: - devika-backend-dbstore: \ No newline at end of file + devika-backend-dbstore: + devika-root-vol: \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..585ba624 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Activate Miniconda environment +eval "$(conda shell.bash activate "$VENV_NAME")" + +echo current directory is: $(pwd) + +if [ -n ${DEBUG} ]; then + set -eux; + nvidia-smi + which python3 + pip show transformers +fi + +echo "Upating Playwright Chromium browser\nPlease wait..." +# Not found if installed at build stage +playwright install --with-deps chromium > /dev/null + +python3 -m devika + diff --git a/environment.yml b/environment.yml new file mode 100644 index 00000000..291fc944 --- /dev/null +++ b/environment.yml @@ -0,0 +1,190 @@ +name: devika_env +channels: + - microsoft + - pytorch + - anaconda + - conda + - conda-forge + - defaults +dependencies: + - _libgcc_mutex=0.1=main + - _openmp_mutex=5.1=1_gnu + - bzip2=1.0.8=h7b6447c_0 + - ca-certificates=2023.08.22=h06a4308_0 + - ld_impl_linux-64=2.38=h1181459_1 + - libffi=3.4.4=h6a678d5_0 + - libgcc-ng=11.2.0=h1234567_1 + - libgomp=11.2.0=h1234567_1 + - libstdcxx-ng=11.2.0=h1234567_1 + - libuuid=1.41.5=h5eee18b_0 + - ncurses=6.4=h6a678d5_0 + - openssl=3.0.12=h7f8727e_0 + - pip=23.3=py311h06a4308_0 + - python=3.11.5=h955ad1f_0 + - readline=8.2=h5eee18b_0 + - setuptools=68.0.0=py311h06a4308_0 + - sqlite=3.41.2=h5eee18b_0 + - tk=8.6.12=h1ccaba5_0 + - tzdata=2023c=h04d1e81_0 + - wheel=0.41.2=py311h06a4308_0 + - xz=5.4.2=h5eee18b_0 + - zlib=1.2.13=h5eee18b_0 + - pip: + - aiohttp==3.9.5 + - aiosignal==1.3.1 + - annotated-types==0.6.0 + - anthropic==0.25.6 + - anyio==4.3.0 + - arabic-reshaper==3.0.0 + - asn1crypto==1.5.1 + - attrs==23.2.0 + - backoff==2.2.1 + - beautifulsoup4==4.12.3 + - bidict==0.23.1 + - blinker==1.7.0 + - cachetools==5.3.3 + - certifi==2024.2.2 + - cffi==1.16.0 + - chardet==5.2.0 + - charset-normalizer==3.3.2 + - click==8.1.7 + - colorama==0.4.6 + - cryptography==42.0.5 + - cssselect2==0.7.0 + - curl-cffi==0.6.3 + - distro==1.9.0 + - dnspython==2.6.1 + - duckduckgo-search==5.3.0 + - eventlet==0.36.1 + - fastlogging==1.0.0 + - filelock==3.13.4 + - flask==3.0.3 + - flask-cors==4.0.0 + - flask-socketio==5.3.6 + - frozenlist==1.4.1 + - fsspec==2024.3.1 + - gevent==24.2.1 + - gevent-websocket==0.10.1 + - gitdb==4.0.11 + - gitpython==3.1.43 + - google-ai-generativelanguage==0.6.2 + - google-api-core==2.18.0 + - google-api-python-client==2.127.0 + - google-auth==2.29.0 + - google-auth-httplib2==0.2.0 + - google-generativeai==0.5.2 + - googleapis-common-protos==1.63.0 + - greenlet==3.0.3 + - groq==0.5.0 + - grpcio==1.62.2 + - grpcio-status==1.62.2 + - h11==0.14.0 + - html5lib==1.1 + - httpcore==1.0.5 + - httplib2==0.22.0 + - httpx==0.27.0 + - huggingface-hub==0.22.2 + - idna==3.7 + - iniconfig==2.0.0 + - itsdangerous==2.2.0 + - jinja2==3.1.3 + - joblib==1.4.0 + - keybert==0.8.4 + - lxml==5.2.1 + - markdown==3.6 + - markdown-it-py==3.0.0 + - markdownify==0.12.1 + - markupsafe==2.1.5 + - mdurl==0.1.2 + - mistletoe==1.3.0 + - mistralai==0.0.8 + - mpmath==1.3.0 + - multidict==6.0.5 + - netlify-py==0.1.0 + - networkx==3.3 + - numpy==1.26.4 + - nvidia-cublas-cu12==12.1.3.1 + - nvidia-cuda-cupti-cu12==12.1.105 + - nvidia-cuda-nvrtc-cu12==12.1.105 + - nvidia-cuda-runtime-cu12==12.1.105 + - nvidia-cudnn-cu12==8.9.2.26 + - nvidia-cufft-cu12==11.0.2.54 + - nvidia-curand-cu12==10.3.2.106 + - nvidia-cusolver-cu12==11.4.5.107 + - nvidia-cusparse-cu12==12.1.0.106 + - nvidia-nccl-cu12==2.19.3 + - nvidia-nvjitlink-cu12==12.4.127 + - nvidia-nvtx-cu12==12.1.105 + - ollama==0.1.8 + - openai==1.23.3 + - orjson==3.10.1 + - oscrypto==1.3.0 + - packaging==24.0 + - pdfminer-six==20231228 + - pillow==10.3.0 + - playwright==1.43.0 + - pluggy==1.5.0 + - proto-plus==1.23.0 + - protobuf==4.25.3 + - pyasn1==0.6.0 + - pyasn1-modules==0.4.0 + - pycparser==2.22 + - pydantic==2.7.1 + - pydantic-core==2.18.2 + - pyee==11.1.0 + - pygments==2.17.2 + - pyhanko==0.23.2 + - pyhanko-certvalidator==0.26.3 + - pyparsing==3.1.2 + - pypdf==4.2.0 + - pypng==0.20220715.0 + - pytest==8.1.1 + - pytest-base-url==2.1.0 + - pytest-playwright==0.4.4 + - python-bidi==0.4.2 + - python-engineio==4.9.0 + - python-slugify==8.0.4 + - python-socketio==5.11.2 + - pyyaml==6.0.1 + - qrcode==7.4.2 + - regex==2024.4.16 + - reportlab==4.0.9 + - requests==2.31.0 + - rich==13.7.1 + - rsa==4.9 + - safetensors==0.4.3 + - scikit-learn==1.4.2 + - scipy==1.13.0 + - sentence-transformers==2.7.0 + - simple-websocket==1.0.0 + - six==1.16.0 + - smmap==5.0.1 + - sniffio==1.3.1 + - soupsieve==2.5 + - sqlalchemy==2.0.29 + - sqlmodel==0.0.16 + - svglib==1.5.1 + - sympy==1.12 + - text-unidecode==1.3 + - threadpoolctl==3.4.0 + - tiktoken==0.6.0 + - tinycss2==1.3.0 + - tokenizers==0.19.1 + - toml==0.10.2 + - torch==2.2.2 + - tqdm==4.66.2 + - transformers==4.40.1 + - triton==2.2.0 + - typing-extensions==4.11.0 + - tzlocal==5.2 + - uritemplate==4.1.1 + - uritools==4.0.2 + - urllib3==1.26.15 + - webencodings==0.5.1 + - werkzeug==3.0.2 + - wsproto==1.2.0 + - xhtml2pdf==0.2.15 + - yarl==1.9.4 + - zope-event==5.0 + - zope-interface==6.3 +prefix: /opt/miniconda3/envs/devika_env diff --git a/src/llm/ollama_client.py b/src/llm/ollama_client.py index 95602036..c97179be 100644 --- a/src/llm/ollama_client.py +++ b/src/llm/ollama_client.py @@ -1,22 +1,62 @@ +""" +This module provides a client for interacting with the Ollama API. + +It initializes the Ollama class, which attempts to establish a connection to the Ollama API endpoint specified in the +configuration file. If successful, it retrieves a list of available models from the API and logs a message indicating +that Ollama is available. If the connection fails, it logs a warning message indicating that Ollama is not available. + +The module defines the `Ollama` class with the following methods: +- `inference(self, model_id: str, prompt: str) -> str`: Inference function that takes in a model ID and a prompt +string, and returns a response string. + +""" + import ollama from src.logger import Logger from src.config import Config log = Logger() +"""_summary_ +Returns: + _type_: _description_ +""" class Ollama: + """ + Initializes the Ollama class. + + This method attempts to establish a connection to the Ollama API endpoint specified in the configuration file. + If successful, it retrieves a list of available models from the API and logs a message indicating that + Ollama is available. If the connection fails, it logs a warning message indicating that Ollama is not available. + + Parameters: + None + + Returns: + None + """ def __init__(self): try: - self.client = ollama.Client(Config().get_ollama_api_endpoint()) + endpoint = Config().get_ollama_api_endpoint() + log.info(f"Connecting to Ollama endpoint at {endpoint}") + self.client = ollama.Client(endpoint) self.models = self.client.list()["models"] log.info("Ollama available") - except: + except ConnectionError as e: self.client = None + print(e) log.warning("Ollama not available") log.warning("run ollama server to use ollama models otherwise use API models") def inference(self, model_id: str, prompt: str) -> str: + """ + Inference function that takes in a model ID and a prompt string, and returns a response string. + + :param model_id: A string representing the ID of the model to use for inference. + :param prompt: A string representing the prompt to use for inference. + :return: A string representing the response generated by the model. + """ response = self.client.generate( model=model_id, prompt=prompt.strip() diff --git a/ui/.gitignore b/ui/.gitignore index f24ad689..040439ce 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -9,4 +9,8 @@ node_modules/ vite.config.js.timestamp-* vite.config.ts.timestamp-* pnpm-lock.yaml -.lockb \ No newline at end of file +.lockb +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/ui/bun.lockb b/ui/bun.lockb deleted file mode 100755 index 38bfc464..00000000 Binary files a/ui/bun.lockb and /dev/null differ diff --git a/ui/package.json b/ui/package.json index f3b046d8..6aa25ddd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,9 +9,11 @@ "preview": "vite preview" }, "devDependencies": { + "@playwright/test": "^1.43.1", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.2", + "@types/node": "^20.12.7", "autoprefixer": "^10.4.16", "postcss": "^8.4.32", "postcss-load-config": "^5.0.2", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 00000000..6148e98d --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run start', + url: 'http://127.0.0.1:3001', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/ui/tests-examples/demo-todo-app.spec.ts b/ui/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 00000000..2fd6016f --- /dev/null +++ b/ui/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,437 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +]; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); + }, title); +} diff --git a/ui/tests/example.spec.ts b/ui/tests/example.spec.ts new file mode 100644 index 00000000..54a906a4 --- /dev/null +++ b/ui/tests/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +});