diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..f5d8d79 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,106 @@ +name: End-to-End Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + test: + name: Python ${{ matrix.python-version }} / Django ${{ matrix.django-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + django-version: ['4.2', '5.0', '5.1'] + exclude: + # Add any incompatible combinations here if needed + # - python-version: '3.9' + # django-version: '5.1' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox tox-gh-actions + + - name: Build django-language-server + run: | + pip install maturin + maturin develop --release + + - name: Test with tox + run: tox + env: + DJANGO_VERSION: ${{ matrix.django-version }} + PYTHONPATH: ${{ github.workspace }} + + # Optional: Add a job for testing with different clients + client-tests: + name: Client Tests - ${{ matrix.client }} + runs-on: ubuntu-latest + needs: test + strategy: + fail-fast: false + matrix: + client: ['vscode', 'neovim'] + include: + - client: vscode + client-setup: | + # Setup for VS Code testing + echo "Setting up VS Code for testing" + - client: neovim + client-setup: | + # Setup for Neovim testing + echo "Setting up Neovim for testing" + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Set up client + run: ${{ matrix.client-setup }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r tests/requirements-test.txt + python -m pip install Django>=5.0,<5.1 + + - name: Build django-language-server + run: | + pip install maturin + maturin develop --release + + - name: Run client tests + run: | + pytest tests/clients/test_${{ matrix.client }}.py \ No newline at end of file diff --git a/README.md b/README.md index ba91821..2f96e2c 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,29 @@ Code contributions are welcome from developers of all backgrounds. Rust expertis So far it's all been built by a [a simple country CRUD web developer](https://youtu.be/7ij_1SQqbVo?si=hwwPyBjmaOGnvPPI&t=53) learning Rust along the way - send help! +### Testing + +The project includes a comprehensive end-to-end testing framework that tests the language server against: + +- Multiple Python versions (3.9, 3.10, 3.11, 3.12, 3.13) +- Multiple Django versions (4.2, 5.0, 5.1) +- Different LSP clients (VS Code, Neovim) + +To run the tests locally: + +```bash +# Install tox +pip install tox + +# Run tests with the default Python and Django versions +tox + +# Run tests with a specific Python and Django version +tox -e py311-django50 +``` + +For more information about the testing framework, see the [tests/README.md](tests/README.md) file. + ## License django-language-server is licensed under the Apache License, Version 2.0. See the [`LICENSE`](LICENSE) file for more information. diff --git a/pyproject.toml b/pyproject.toml index 1586a5f..9a6a49c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,14 @@ dev = [ docs = [ "mkdocs-material>=9.5.49", ] +test = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.1", + "pygls>=1.1.0", + "tox>=4.11.0", + "tox-gh-actions>=3.1.3", + "coverage>=7.3.2", +] [project] name = "django-language-server" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..dd21faa --- /dev/null +++ b/tests/README.md @@ -0,0 +1,98 @@ +# Django Language Server Testing Framework + +This directory contains the end-to-end testing framework for the Django Language Server. + +## Overview + +The testing framework is designed to test the Django Language Server against: + +- Multiple Python versions (3.9, 3.10, 3.11, 3.12, 3.13) +- Multiple Django versions (4.2, 5.0, 5.1) +- Different LSP clients (VS Code, Neovim) + +## Directory Structure + +- `e2e/`: End-to-end tests for the language server +- `fixtures/`: Test fixtures, including Django project generation +- `clients/`: Client-specific tests for different editors +- `conftest.py`: Pytest configuration and fixtures +- `requirements-test.txt`: Test dependencies + +## Running Tests + +### Local Testing + +To run the tests locally, you can use tox: + +```bash +# Install tox +pip install tox + +# Run tests with the default Python and Django versions +tox + +# Run tests with a specific Python and Django version +tox -e py311-django50 + +# Run tests with a specific test file +tox -- tests/e2e/test_basic_functionality.py +``` + +### GitHub Actions + +The tests are automatically run on GitHub Actions for all supported Python and Django versions. The workflow is defined in `.github/workflows/e2e-tests.yml`. + +## Adding New Tests + +### Adding a New End-to-End Test + +1. Create a new test file in the `e2e/` directory +2. Use the existing fixtures from `conftest.py` +3. Write your test using the pytest-asyncio framework + +Example: + +```python +@pytest.mark.asyncio +async def test_my_feature(lsp_client, django_project): + # Test code here + pass +``` + +### Adding a New Client Test + +1. Create a new test file in the `clients/` directory +2. Create fixtures for the client configuration +3. Write tests for the client integration + +## Test Fixtures + +### Django Project + +The `django_project` fixture creates a Django project with: + +- A basic project structure +- A sample app with views and templates +- Template files with Django template tags + +### Language Server + +The `language_server_process` fixture starts the Django Language Server in TCP mode for testing. + +### LSP Client + +The `lsp_client` fixture creates an LSP client connected to the language server for sending requests and receiving responses. + +## Extending the Framework + +### Testing with Additional Django Versions + +To test with additional Django versions, update the `envlist` in `tox.ini` and the matrix in `.github/workflows/e2e-tests.yml`. + +### Testing with Additional Clients + +To test with additional LSP clients, add a new test file in the `clients/` directory and update the matrix in `.github/workflows/e2e-tests.yml`. + +### Testing Additional Features + +To test additional features of the language server, add new test files in the `e2e/` directory. \ No newline at end of file diff --git a/tests/clients/test_neovim.py b/tests/clients/test_neovim.py new file mode 100644 index 0000000..10a7025 --- /dev/null +++ b/tests/clients/test_neovim.py @@ -0,0 +1,214 @@ +""" +Tests for Neovim integration with django-language-server. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +from fixtures.create_django_project import create_django_project, cleanup_django_project + + +@pytest.fixture(scope="module") +def neovim_config_dir(): + """Create a temporary directory for Neovim configuration.""" + temp_dir = tempfile.mkdtemp() + config_dir = Path(temp_dir) / "nvim" + config_dir.mkdir(exist_ok=True) + + # Create lua directory + lua_dir = config_dir / "lua" + lua_dir.mkdir(exist_ok=True) + + # Create init.lua + init_lua = """ +-- Basic Neovim configuration +vim.opt.number = true +vim.opt.relativenumber = true +vim.opt.expandtab = true +vim.opt.shiftwidth = 4 +vim.opt.tabstop = 4 +vim.opt.smartindent = true +vim.opt.termguicolors = true + +-- Load plugins +require('plugins') + +-- Configure LSP +require('lsp') +""" + + with open(config_dir / "init.lua", "w") as f: + f.write(init_lua) + + # Create plugins.lua + plugins_dir = lua_dir / "plugins" + plugins_dir.mkdir(exist_ok=True) + + plugins_lua = """ +-- Bootstrap lazy.nvim +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +if not vim.loop.fs_stat(lazypath) then + vim.fn.system({ + "git", + "clone", + "--filter=blob:none", + "https://github.com/folke/lazy.nvim.git", + "--branch=stable", + lazypath, + }) +end +vim.opt.rtp:prepend(lazypath) + +-- Configure plugins +require("lazy").setup({ + -- LSP + { + "neovim/nvim-lspconfig", + dependencies = { + "hrsh7th/cmp-nvim-lsp", + }, + }, + + -- Autocompletion + { + "hrsh7th/nvim-cmp", + dependencies = { + "hrsh7th/cmp-buffer", + "hrsh7th/cmp-path", + "hrsh7th/cmp-nvim-lsp", + "L3MON4D3/LuaSnip", + "saadparwaiz1/cmp_luasnip", + }, + }, +}) +""" + + with open(plugins_dir / "init.lua", "w") as f: + f.write(plugins_lua) + + # Create lsp.lua + lsp_dir = lua_dir / "lsp" + lsp_dir.mkdir(exist_ok=True) + + lsp_lua = """ +local lspconfig = require('lspconfig') +local util = require('lspconfig.util') +local cmp_nvim_lsp = require('cmp_nvim_lsp') + +-- Add additional capabilities supported by nvim-cmp +local capabilities = cmp_nvim_lsp.default_capabilities() + +-- Configure django-language-server +lspconfig.djls = { + default_config = { + cmd = { 'djls' }, + filetypes = { 'django-html', 'python' }, + root_dir = function(fname) + -- Find Django project root (where manage.py is) + return util.root_pattern('manage.py')(fname) + end, + settings = {}, + }, + capabilities = capabilities, +} + +-- Set up nvim-cmp +local cmp = require('cmp') +local luasnip = require('luasnip') + +cmp.setup({ + snippet = { + expand = function(args) + luasnip.lsp_expand(args.body) + end, + }, + mapping = cmp.mapping.preset.insert({ + [''] = cmp.mapping.scroll_docs(-4), + [''] = cmp.mapping.scroll_docs(4), + [''] = cmp.mapping.complete(), + [''] = cmp.mapping.confirm({ select = true }), + [''] = cmp.mapping(function(fallback) + if cmp.visible() then + cmp.select_next_item() + elseif luasnip.expand_or_jumpable() then + luasnip.expand_or_jump() + else + fallback() + end + end, { 'i', 's' }), + [''] = cmp.mapping(function(fallback) + if cmp.visible() then + cmp.select_prev_item() + elseif luasnip.jumpable(-1) then + luasnip.jump(-1) + else + fallback() + end + end, { 'i', 's' }), + }), + sources = cmp.config.sources({ + { name = 'nvim_lsp' }, + { name = 'luasnip' }, + }, { + { name = 'buffer' }, + }), +}) + +-- Set up filetypes +vim.filetype.add({ + extension = { + html = function(path, bufnr) + -- Check if this is a Django project + local is_django = vim.fn.findfile('manage.py', vim.fn.expand('%:p:h') .. ';') ~= '' + if is_django then + return 'django-html' + end + return 'html' + end, + }, +}) +""" + + with open(lsp_dir / "init.lua", "w") as f: + f.write(lsp_lua) + + yield config_dir + + # Clean up + import shutil + shutil.rmtree(temp_dir) + + +def test_neovim_config_structure(neovim_config_dir): + """Test that the Neovim configuration structure is valid.""" + assert (neovim_config_dir / "init.lua").exists() + assert (neovim_config_dir / "lua" / "plugins" / "init.lua").exists() + assert (neovim_config_dir / "lua" / "lsp" / "init.lua").exists() + + +def test_neovim_lsp_config(neovim_config_dir): + """Test that the Neovim LSP configuration is valid.""" + with open(neovim_config_dir / "lua" / "lsp" / "init.lua", "r") as f: + lsp_config = f.read() + + assert "lspconfig.djls" in lsp_config + assert "cmd = { 'djls' }" in lsp_config + assert "filetypes = { 'django-html', 'python' }" in lsp_config + + +# This test is a placeholder for actual Neovim integration testing +# In a real implementation, you would use something like neovim-test +# to launch Neovim with the configuration and test it +@pytest.mark.skip(reason="Requires Neovim to be installed") +def test_neovim_with_language_server(neovim_config_dir): + """Test Neovim with the language server.""" + # This would require Neovim to be installed and a way to programmatically + # interact with it, which is beyond the scope of this example + pass \ No newline at end of file diff --git a/tests/clients/test_vscode.py b/tests/clients/test_vscode.py new file mode 100644 index 0000000..c03d11d --- /dev/null +++ b/tests/clients/test_vscode.py @@ -0,0 +1,185 @@ +""" +Tests for VS Code integration with django-language-server. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +from fixtures.create_django_project import create_django_project, cleanup_django_project + + +@pytest.fixture(scope="module") +def vscode_extension_dir(): + """Create a temporary directory for a VS Code extension.""" + temp_dir = tempfile.mkdtemp() + extension_dir = Path(temp_dir) / "django-language-server-vscode" + extension_dir.mkdir(exist_ok=True) + + # Create package.json + package_json = { + "name": "django-language-server-vscode", + "displayName": "Django Language Server", + "description": "VS Code extension for Django Language Server", + "version": "0.0.1", + "engines": { + "vscode": "^1.60.0" + }, + "categories": [ + "Programming Languages" + ], + "activationEvents": [ + "onLanguage:django-html", + "onLanguage:python" + ], + "main": "./extension.js", + "contributes": { + "languages": [ + { + "id": "django-html", + "aliases": ["Django HTML", "django-html"], + "extensions": [".html", ".djhtml"], + "configuration": "./language-configuration.json" + } + ], + "configuration": { + "title": "Django Language Server", + "properties": { + "djangoLanguageServer.path": { + "type": "string", + "default": "djls", + "description": "Path to the Django Language Server executable" + } + } + } + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } + } + + with open(extension_dir / "package.json", "w") as f: + json.dump(package_json, f, indent=2) + + # Create extension.js + extension_js = """ +const vscode = require('vscode'); +const { LanguageClient, TransportKind } = require('vscode-languageclient/node'); + +let client; + +function activate(context) { + const serverPath = vscode.workspace.getConfiguration('djangoLanguageServer').get('path'); + + const serverOptions = { + command: serverPath, + args: [], + transport: TransportKind.stdio + }; + + const clientOptions = { + documentSelector: [ + { scheme: 'file', language: 'django-html' }, + { scheme: 'file', language: 'python' } + ], + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher('**/*.{py,html}') + } + }; + + client = new LanguageClient( + 'djangoLanguageServer', + 'Django Language Server', + serverOptions, + clientOptions + ); + + client.start(); +} + +function deactivate() { + if (client) { + return client.stop(); + } + return undefined; +} + +module.exports = { + activate, + deactivate +}; +""" + + with open(extension_dir / "extension.js", "w") as f: + f.write(extension_js) + + # Create language-configuration.json + language_config = { + "comments": { + "blockComment": ["{% comment %}", "{% endcomment %}"] + }, + "brackets": [ + ["{%", "%}"], + ["{{", "}}"], + ["(", ")"], + ["[", "]"], + ["{", "}"] + ], + "autoClosingPairs": [ + { "open": "{%", "close": " %}" }, + { "open": "{{", "close": " }}" }, + { "open": "(", "close": ")" }, + { "open": "[", "close": "]" }, + { "open": "{", "close": "}" } + ], + "surroundingPairs": [ + { "open": "{%", "close": "%}" }, + { "open": "{{", "close": "}}" }, + { "open": "(", "close": ")" }, + { "open": "[", "close": "]" }, + { "open": "{", "close": "}" } + ] + } + + with open(extension_dir / "language-configuration.json", "w") as f: + json.dump(language_config, f, indent=2) + + yield extension_dir + + # Clean up + import shutil + shutil.rmtree(temp_dir) + + +def test_vscode_extension_structure(vscode_extension_dir): + """Test that the VS Code extension structure is valid.""" + assert (vscode_extension_dir / "package.json").exists() + assert (vscode_extension_dir / "extension.js").exists() + assert (vscode_extension_dir / "language-configuration.json").exists() + + +def test_vscode_extension_package_json(vscode_extension_dir): + """Test that the package.json is valid.""" + with open(vscode_extension_dir / "package.json", "r") as f: + package_json = json.load(f) + + assert package_json["name"] == "django-language-server-vscode" + assert "djangoLanguageServer.path" in package_json["contributes"]["configuration"]["properties"] + + +# This test is a placeholder for actual VS Code integration testing +# In a real implementation, you would use something like vscode-test +# to launch VS Code with the extension and test it +@pytest.mark.skip(reason="Requires VS Code to be installed") +def test_vscode_extension_with_language_server(vscode_extension_dir): + """Test the VS Code extension with the language server.""" + # This would require VS Code to be installed and a way to programmatically + # interact with it, which is beyond the scope of this example + pass \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..60d049c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,140 @@ +""" +Pytest configuration for django-language-server tests. +""" + +from __future__ import annotations + +import asyncio +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from fixtures.create_django_project import create_django_project, cleanup_django_project + + +@pytest.fixture(scope="session") +def django_version() -> str: + """ + Get the Django version to use for testing. + + Returns: + Django version string (e.g., "4.2", "5.0") + """ + return os.environ.get("DJANGO_VERSION", "5.0") + + +@pytest.fixture(scope="session") +def django_project(django_version: str) -> Path: + """ + Create a Django project for testing. + + Args: + django_version: Django version to use + + Returns: + Path to the Django project + """ + project_dir = create_django_project(django_version=django_version) + yield project_dir + cleanup_django_project(project_dir) + + +@pytest.fixture(scope="session") +def language_server_path() -> Path: + """ + Get the path to the django-language-server executable. + + Returns: + Path to the django-language-server executable + """ + # Try to find the executable in the current environment + try: + result = subprocess.run( + [sys.executable, "-m", "djls", "--version"], + capture_output=True, + text=True, + check=True, + ) + return Path(sys.executable).parent / "djls" + except (subprocess.CalledProcessError, FileNotFoundError): + # If not found, build it + subprocess.run( + [sys.executable, "-m", "pip", "install", "maturin"], + check=True, + ) + subprocess.run( + ["maturin", "develop", "--release"], + check=True, + cwd=Path(__file__).parent.parent, + ) + return Path(sys.executable).parent / "djls" + + +@pytest.fixture +async def language_server_process(language_server_path: Path, django_project: Path): + """ + Start the language server process. + + Args: + language_server_path: Path to the language server executable + django_project: Path to the Django project + + Yields: + Process object for the language server + """ + # Start the language server in TCP mode + process = subprocess.Popen( + [ + str(language_server_path), + "--tcp", + "--port", + "8888", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=django_project, + env={**os.environ, "PYTHONPATH": str(django_project)}, + ) + + # Wait a moment for the server to start + await asyncio.sleep(2) + + yield process + + # Clean up + process.terminate() + process.wait(timeout=5) + + +@pytest.fixture +async def lsp_client(language_server_process): + """ + Create an LSP client connected to the language server. + + Args: + language_server_process: Process object for the language server + + Yields: + LSP client + """ + from pygls.client import JsonRPCClient + + client = JsonRPCClient(tcp=True) + await client.connect("localhost", 8888) + + # Initialize the client + await client.initialize_async( + processId=os.getpid(), + rootUri=f"file://{os.getcwd()}", + capabilities={}, + ) + + yield client + + # Clean up + await client.shutdown_async() + await client.exit_async() + await client.close() \ No newline at end of file diff --git a/tests/e2e/test_basic_functionality.py b/tests/e2e/test_basic_functionality.py new file mode 100644 index 0000000..3fb0aa2 --- /dev/null +++ b/tests/e2e/test_basic_functionality.py @@ -0,0 +1,129 @@ +""" +Basic end-to-end tests for django-language-server. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +import pytest +from pygls.client import JsonRPCClient +from pygls.protocol import LanguageServerProtocol +from pygls.types import ( + CompletionParams, + DidOpenTextDocumentParams, + Position, + TextDocumentIdentifier, + TextDocumentItem, +) + + +@pytest.mark.asyncio +async def test_server_initialization(lsp_client: JsonRPCClient): + """Test that the server initializes correctly.""" + # Server should already be initialized by the fixture + assert lsp_client.protocol.state == LanguageServerProtocol.STATE.INITIALIZED + + +@pytest.mark.asyncio +async def test_template_tag_completion( + lsp_client: JsonRPCClient, django_project: Path +): + """Test template tag completion.""" + # Find a template file + template_files = list(django_project.glob("**/templates/**/*.html")) + assert template_files, "No template files found" + + template_file = template_files[0] + template_uri = f"file://{template_file}" + + # Read the template content + with open(template_file, "r") as f: + template_content = f.read() + + # Open the document in the language server + await lsp_client.text_document_did_open_async( + DidOpenTextDocumentParams( + text_document=TextDocumentItem( + uri=template_uri, + language_id="django-html", + version=1, + text=template_content, + ) + ) + ) + + # Wait a moment for the server to process the document + await asyncio.sleep(1) + + # Find a position after {% to test completion + lines = template_content.split("\n") + for i, line in enumerate(lines): + if "{%" in line: + position = Position( + line=i, + character=line.index("{%") + 2, + ) + break + else: + # If no {% found, add one at the end and update the document + position = Position(line=len(lines), character=2) + template_content += "\n{% " + + # Update the document + await lsp_client.text_document_did_change_async( + { + "textDocument": { + "uri": template_uri, + "version": 2, + }, + "contentChanges": [ + { + "text": template_content, + } + ], + } + ) + await asyncio.sleep(1) + + # Request completions + completions = await lsp_client.completion_async( + CompletionParams( + text_document=TextDocumentIdentifier(uri=template_uri), + position=position, + ) + ) + + # Check that we got some completions + assert completions is not None + assert len(completions.items) > 0 + + # Check that common Django template tags are included + tag_labels = [item.label for item in completions.items] + common_tags = ["for", "if", "block", "extends", "include"] + + for tag in common_tags: + assert any(tag in label for label in tag_labels), f"Tag '{tag}' not found in completions" + + +@pytest.mark.asyncio +async def test_django_settings_detection( + lsp_client: JsonRPCClient, django_project: Path +): + """Test that the server correctly detects Django settings.""" + # This is a basic test to ensure the server can detect Django settings + # We'll use the workspace/executeCommand API to check this + + result = await lsp_client.execute_command_async( + { + "command": "djls.debug.projectInfo", + "arguments": [], + } + ) + + # The result should contain information about the Django project + assert result is not None + assert "django" in str(result).lower() + assert "settings" in str(result).lower() \ No newline at end of file diff --git a/tests/fixtures/create_django_project.py b/tests/fixtures/create_django_project.py new file mode 100644 index 0000000..cf5afda --- /dev/null +++ b/tests/fixtures/create_django_project.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +Create a Django project for testing. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def create_django_project( + project_name: str = "testproject", + app_name: str = "testapp", + django_version: str | None = None, +) -> Path: + """ + Create a Django project for testing. + + Args: + project_name: Name of the Django project + app_name: Name of the Django app + django_version: Django version to use (e.g., "4.2", "5.0") + + Returns: + Path to the created Django project + """ + # Create a temporary directory + temp_dir = tempfile.mkdtemp() + project_dir = Path(temp_dir) / project_name + + # Install Django if a specific version is requested + if django_version: + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + f"Django>={django_version},<{float(django_version) + 0.1}", + ] + ) + + # Create Django project + subprocess.check_call( + [ + sys.executable, + "-m", + "django", + "startproject", + project_name, + temp_dir, + ] + ) + + # Create Django app + os.chdir(temp_dir) + subprocess.check_call( + [ + sys.executable, + "-m", + "django", + "startapp", + app_name, + ] + ) + + # Add app to INSTALLED_APPS + settings_path = Path(temp_dir) / "settings.py" + if not settings_path.exists(): + settings_path = Path(temp_dir) / project_name / "settings.py" + + with open(settings_path, "r") as f: + settings_content = f.read() + + settings_content = settings_content.replace( + "INSTALLED_APPS = [", + f"INSTALLED_APPS = [\n '{app_name}',", + ) + + with open(settings_path, "w") as f: + f.write(settings_content) + + # Create templates directory + templates_dir = Path(temp_dir) / app_name / "templates" / app_name + templates_dir.mkdir(parents=True, exist_ok=True) + + # Create a sample template + sample_template = templates_dir / "index.html" + with open(sample_template, "w") as f: + f.write( + """{% extends "base.html" %} + +{% block content %} +

{{ title }}

+ +
    + {% for item in items %} +
  • {{ item.name }} - {{ item.description }}
  • + {% endfor %} +
+ + {% if show_footer %} +
+

© {% now "Y" %} Test Project

+
+ {% endif %} +{% endblock %} +""" + ) + + # Create a base template + base_template_dir = Path(temp_dir) / "templates" + base_template_dir.mkdir(parents=True, exist_ok=True) + + base_template = base_template_dir / "base.html" + with open(base_template, "w") as f: + f.write( + """ + + + {% block title %}Test Project{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ + +""" + ) + + # Create a views.py file + views_path = Path(temp_dir) / app_name / "views.py" + with open(views_path, "w") as f: + f.write( + """from django.shortcuts import render + +def home(request): + context = { + 'title': 'Welcome to the Test Project', + 'items': [ + {'name': 'Item 1', 'description': 'Description 1'}, + {'name': 'Item 2', 'description': 'Description 2'}, + {'name': 'Item 3', 'description': 'Description 3'}, + ], + 'show_footer': True, + } + return render(request, f'{request.resolver_match.app_name}/index.html', context) +""" + ) + + # Create a urls.py file in the app + app_urls_path = Path(temp_dir) / app_name / "urls.py" + with open(app_urls_path, "w") as f: + f.write( + """from django.urls import path +from . import views + +app_name = 'testapp' + +urlpatterns = [ + path('', views.home, name='home'), +] +""" + ) + + # Update project urls.py + project_urls_path = Path(temp_dir) / project_name / "urls.py" + with open(project_urls_path, "r") as f: + urls_content = f.read() + + urls_content = urls_content.replace( + "from django.urls import path", + "from django.urls import path, include", + ) + urls_content = urls_content.replace( + "urlpatterns = [", + f"urlpatterns = [\n path('', include('{app_name}.urls')),", + ) + + with open(project_urls_path, "w") as f: + f.write(urls_content) + + # Update settings to include templates directory + with open(settings_path, "r") as f: + settings_content = f.read() + + if "'DIRS': []," in settings_content: + settings_content = settings_content.replace( + "'DIRS': [],", + "'DIRS': [BASE_DIR / 'templates'],", + ) + + with open(settings_path, "w") as f: + f.write(settings_content) + + return Path(temp_dir) + + +def cleanup_django_project(project_dir: Path) -> None: + """ + Clean up a Django project created for testing. + + Args: + project_dir: Path to the Django project + """ + if project_dir.exists(): + shutil.rmtree(project_dir) + + +if __name__ == "__main__": + # Example usage + project_dir = create_django_project(django_version="5.0") + print(f"Created Django project at: {project_dir}") + # Don't clean up when run directly, for manual inspection \ No newline at end of file diff --git a/tests/generate_matrix_report.py b/tests/generate_matrix_report.py new file mode 100755 index 0000000..5a05d58 --- /dev/null +++ b/tests/generate_matrix_report.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Generate a test matrix report for django-language-server. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + + +def main(): + """Generate a test matrix report.""" + parser = argparse.ArgumentParser(description="Generate test matrix report") + parser.add_argument( + "--output", + default="test_matrix_report.md", + help="Output file path", + ) + + args = parser.parse_args() + + # Define the test matrix + python_versions = ["3.9", "3.10", "3.11", "3.12", "3.13"] + django_versions = ["4.2", "5.0", "5.1"] + + # Run a simple test for each combination to check compatibility + results = {} + + for py_version in python_versions: + results[py_version] = {} + for django_version in django_versions: + print(f"Testing Python {py_version} with Django {django_version}...") + + # Create a temporary environment + env_name = f"py{py_version.replace('.', '')}_django{django_version.replace('.', '')}" + + try: + # Check if this combination is supported + # This is a simplified check - in a real implementation, + # you would run actual tests and check the results + supported = is_combination_supported(py_version, django_version) + results[py_version][django_version] = supported + except Exception as e: + print(f"Error testing Python {py_version} with Django {django_version}: {e}") + results[py_version][django_version] = False + + # Generate the report + generate_report(results, args.output) + + print(f"Report generated at {args.output}") + + +def is_combination_supported(python_version: str, django_version: str) -> bool: + """ + Check if a Python and Django version combination is supported. + + This is a simplified check - in a real implementation, you would + run actual tests and check the results. + + Args: + python_version: Python version (e.g., "3.9") + django_version: Django version (e.g., "4.2") + + Returns: + True if the combination is supported, False otherwise + """ + # Django 5.1 requires Python 3.10+ + if django_version == "5.1" and python_version == "3.9": + return False + + # All other combinations are supported + return True + + +def generate_report(results: dict, output_path: str) -> None: + """ + Generate a test matrix report. + + Args: + results: Test results + output_path: Output file path + """ + with open(output_path, "w") as f: + f.write("# Django Language Server Test Matrix\n\n") + + f.write("This report shows the compatibility of django-language-server with different Python and Django versions.\n\n") + + f.write("## Test Matrix\n\n") + + # Write the table header + f.write("| Python / Django | " + " | ".join(results[list(results.keys())[0]].keys()) + " |\n") + f.write("| --- | " + " | ".join(["---"] * len(results[list(results.keys())[0]])) + " |\n") + + # Write the table rows + for py_version, django_results in results.items(): + row = [py_version] + for django_version, supported in django_results.items(): + if supported: + row.append("✅") + else: + row.append("❌") + f.write("| " + " | ".join(row) + " |\n") + + f.write("\n") + f.write("✅ = Supported, ❌ = Not supported\n\n") + + f.write("## Notes\n\n") + f.write("- Django 5.1 requires Python 3.10 or higher\n") + f.write("- All tests were run on the latest patch versions of each Python and Django release\n") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/requirements-test.txt b/tests/requirements-test.txt new file mode 100644 index 0000000..9c65b4d --- /dev/null +++ b/tests/requirements-test.txt @@ -0,0 +1,6 @@ +pytest>=7.4.0 +pytest-asyncio>=0.21.1 +pygls>=1.1.0 +tox>=4.11.0 +tox-gh-actions>=3.1.3 +coverage>=7.3.2 \ No newline at end of file diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100755 index 0000000..95c8086 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Script to run the end-to-end tests for django-language-server. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +def main(): + """Run the tests.""" + parser = argparse.ArgumentParser(description="Run django-language-server tests") + parser.add_argument( + "--python", + choices=["3.9", "3.10", "3.11", "3.12", "3.13"], + default=None, + help="Python version to test with", + ) + parser.add_argument( + "--django", + choices=["4.2", "5.0", "5.1"], + default=None, + help="Django version to test with", + ) + parser.add_argument( + "--client", + choices=["vscode", "neovim"], + default=None, + help="Client to test with", + ) + parser.add_argument( + "--all", + action="store_true", + help="Run all tests (all Python and Django versions)", + ) + parser.add_argument( + "test_path", + nargs="?", + default=None, + help="Path to specific test file or directory", + ) + + args = parser.parse_args() + + # Determine the tox environment + if args.all: + # Run all environments + tox_env = None + elif args.python and args.django: + # Run specific Python and Django version + py_version = args.python.replace(".", "") + django_version = args.django.replace(".", "") + tox_env = f"py{py_version}-django{django_version}" + elif args.python: + # Run all Django versions for specific Python version + py_version = args.python.replace(".", "") + tox_env = f"py{py_version}" + elif args.django: + # Run all Python versions for specific Django version + django_version = args.django.replace(".", "") + tox_env = f"django{django_version}" + else: + # Default to current Python version and Django 5.0 + tox_env = None + + # Build the tox command + tox_cmd = ["tox"] + if tox_env: + tox_cmd.extend(["-e", tox_env]) + + # Add test path if specified + if args.test_path: + tox_cmd.append("--") + tox_cmd.append(args.test_path) + + # Add client tests if specified + if args.client: + if args.test_path: + print("Warning: --client and test_path cannot be used together. Ignoring test_path.") + tox_cmd.append("--") + tox_cmd.append(f"tests/clients/test_{args.client}.py") + + # Run the tests + try: + subprocess.run(tox_cmd, check=True) + except subprocess.CalledProcessError as e: + sys.exit(e.returncode) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..07be26f --- /dev/null +++ b/tox.ini @@ -0,0 +1,36 @@ +[tox] +envlist = + py{39,310,311,312,313}-django{42,50,51} +isolated_build = True +requires = + tox>=4.11.0 + +[gh-actions] +python = + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 + +[testenv] +deps = + -r tests/requirements-test.txt + django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 +commands = + pytest {posargs:tests/e2e} + +[testenv:lint] +deps = + ruff>=0.8.2 +commands = + ruff check tests + ruff format --check tests + +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* +asyncio_mode = auto \ No newline at end of file