diff --git a/.github/workflows/deploytest.yml b/.github/workflows/deploytest.yml
index e96c175511..cda563cf81 100644
--- a/.github/workflows/deploytest.yml
+++ b/.github/workflows/deploytest.yml
@@ -45,6 +45,15 @@ jobs:
pnpm rebuild node-sass
- name: Build frontend
run: pnpm run build
+ - name: Upload frontend bundle
+ uses: actions/upload-artifact@v7
+ with:
+ name: studio-frontend-bundle
+ path: |
+ contentcuration/contentcuration/static/studio/
+ contentcuration/build/webpack-stats.json
+ if-no-files-found: error
+ retention-days: 1
make_messages:
name: Build all message files
needs: pre_job
@@ -79,3 +88,145 @@ jobs:
sudo apt-get install -y gettext
- name: Test Django makemessages
run: python contentcuration/manage.py makemessages --all
+ browser_smoke_test:
+ name: Browser smoke test
+ needs: [pre_job, build_assets]
+ if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: learningequality
+ POSTGRES_PASSWORD: kolibri
+ POSTGRES_DB: kolibri-studio
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+ redis:
+ image: redis:6.0.9
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 6379:6379
+ env:
+ DJANGO_SETTINGS_MODULE: contentcuration.settings
+ DATA_DB_HOST: localhost
+ AWS_S3_ENDPOINT_URL: http://localhost:9000
+ AWS_BUCKET_NAME: content
+ CELERY_BROKER_ENDPOINT: localhost
+ CELERY_REDIS_DB: "0"
+ CELERY_REDIS_PASSWORD: ""
+ RUN_MODE: ci
+ SMOKE_EMAIL: smokeadmin@example.com
+ SMOKE_PASSWORD: smokepass1234
+ SCREENSHOT_DIR: ${{ runner.temp }}/smoke_test_screenshots
+ steps:
+ - uses: actions/checkout@v6
+ - name: Set up MinIO
+ run: |
+ docker run -d -p 9000:9000 --name minio \
+ -e "MINIO_ROOT_USER=development" \
+ -e "MINIO_ROOT_PASSWORD=development" \
+ -e "MINIO_DEFAULT_BUCKETS=content:public" \
+ bitnamilegacy/minio:2024.5.28
+ - name: Install system deps (gettext, nginx)
+ run: |
+ sudo apt-get update -y
+ sudo apt-get install -y gettext nginx
+ # nginx's postinst auto-starts the system service on the ubuntu-latest
+ # runner image. We launch our own nginx with a custom config below, so
+ # the default instance has to go first — otherwise our nginx -c fails
+ # on the default pid path or :80 collision.
+ sudo systemctl stop nginx
+ - name: Install uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ python-version: '3.10'
+ activate-environment: "true"
+ enable-cache: "true"
+ - name: Install python dependencies
+ run: uv pip sync requirements.txt
+ - name: Download frontend bundle
+ uses: actions/download-artifact@v8
+ with:
+ name: studio-frontend-bundle
+ - name: Cache Playwright browsers
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-chromium-${{ runner.os }}-v1
+ - name: Install Chromium and system deps
+ run: uvx --from "playwright<2" playwright install --with-deps chromium
+ - name: Prepare database
+ run: |
+ python contentcuration/manage.py migrate --noinput
+ python contentcuration/manage.py loadconstants
+ - name: Create smoke test user
+ shell: python
+ run: |
+ import os
+ import sys
+ # contentcuration package lives one level down; put it on sys.path
+ # so django.setup() can import contentcuration.settings.
+ sys.path.insert(0, "contentcuration")
+ import django
+ django.setup()
+ from django.contrib.auth import get_user_model
+ # Studio's custom User model: create_superuser(email, first_name, last_name, password).
+ # is_active defaults to False, so we have to flip it explicitly —
+ # otherwise the user exists but can't log in.
+ u = get_user_model().objects.create_superuser(
+ os.environ["SMOKE_EMAIL"],
+ "Smoke",
+ "Admin",
+ password=os.environ["SMOKE_PASSWORD"],
+ )
+ u.is_active = True
+ u.save()
+ - name: Prepare static and translations
+ run: |
+ python contentcuration/manage.py collectstatic --noinput
+ cd contentcuration && python manage.py compilemessages
+ - name: Start gunicorn
+ run: |
+ cd contentcuration && \
+ nohup gunicorn contentcuration.wsgi:application \
+ --timeout=120 --workers=1 --threads=1 \
+ --bind=0.0.0.0:8081 --log-level=info \
+ > "${{ runner.temp }}/gunicorn.log" 2>&1 &
+ echo "gunicorn started in background"
+ - name: Start nginx (serves /static/, proxies / to gunicorn)
+ run: |
+ # Substitute the static dir into the checked-in template. Escape
+ # sed replacement metacharacters (& and \) so paths containing them
+ # don't corrupt the substitution.
+ STATIC_DIR="$GITHUB_WORKSPACE/contentcuration/static"
+ ESCAPED=$(printf '%s' "$STATIC_DIR" | sed -e 's/[\\&]/\\&/g')
+ NGINX_CONF="${{ runner.temp }}/smoke-nginx.conf"
+ sed "s|__STATIC_DIR__|$ESCAPED|" integration_testing/nginx-smoke.conf > "$NGINX_CONF"
+ sudo nginx -c "$NGINX_CONF" -g 'daemon on;'
+ - name: Run browser smoke test
+ run: uv run --script integration_testing/smoke_test.py
+ - name: Upload screenshots
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: smoke_test_screenshots
+ path: ${{ runner.temp }}/smoke_test_screenshots
+ if-no-files-found: ignore
+ - name: Upload gunicorn log on failure
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: smoke_test_gunicorn_log
+ path: ${{ runner.temp }}/gunicorn.log
+ if-no-files-found: ignore
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000..d5f42b0e8f
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,176 @@
+
+
+# Kolibri Studio Development Guide for AI Coding Agents
+
+**Project:** Kolibri Studio — web app for authoring and publishing learning channels to Kolibri
+**Stack:** Python/Django backend, Vue.js 2.7 frontend, Kolibri Design System (KDS) + legacy Vuetify 1.5, Postgres/Redis/MinIO/Celery services, pytest/Jest testing
+**Platform:** web (server-deployed, not packaged for clients)
+
+## Quick Start
+
+```bash
+uv pip sync requirements.txt requirements-dev.txt # Python deps
+pnpm install # Node deps
+pre-commit install # Required — commits fail without this
+make dcservicesup # Bring up postgres / redis / minio in docker
+pnpm devsetup # Migrate + load sample data + create admin
+pnpm devserver # Django :8080 + Webpack watcher
+```
+
+→ Full setup: `README.md` | Local production-shape stack: `make dcup` (uses `docker-compose.yml`)
+
+## Critical Gotchas
+
+### ⚠️ BEFORE Writing Any Vue Component, Search for Existing Ones
+
+Do not create a new component without first searching for an existing solution:
+1. **Kolibri Design System** ([docs](https://design-system.learningequality.org/)) — `KButton`, `KCircularLoader`, `KTextbox`, `KSelect`, `KModal`, `KCheckbox`, `KIcon`, `KTable`, etc.
+2. **`contentcuration/contentcuration/frontend/shared/`** — Studio-specific shared components.
+3. **Vuetify 1.5** — for legacy widgets KDS doesn't cover (data tables, complex layout primitives). See the next gotcha before reaching for Vuetify in new code.
+
+If a component does 80% of what you need, wrap it — do not rewrite.
+
+### ⚠️ Use KDS, Not Vuetify, for New Code
+
+KDS is the design-system source of truth. Vuetify is legacy and being phased out. The rule applies to **new files / brand-new components** — if you're working inside a file that already uses Vuetify, match it (see "Match the surrounding file's API style" below) rather than mixing the two. **Do not introduce Vuetify into a fresh component.** When you encounter Vuetify being used where a KDS equivalent exists, leave it alone unless the work is genuinely about replacing it — opportunistic refactors in unrelated PRs add review noise.
+
+### ⚠️ Use Theme Tokens, Not Hard-Coded Colors
+
+Never use raw color values. Access theme colors via `$themeTokens` and `$themePalette`:
+```vue
+
+
+ secondary text
+
+
+```
+For computed dynamic styles, use `$computedClass`.
+
+### ⚠️ Style Blocks, Not Inline — RTL Depends On It
+
+Non-dynamic styles go in `