diff --git a/.github/problem-matchers/gcc.json b/.github/problem-matchers/gcc.json
new file mode 100644
index 0000000000..ac30deff8e
--- /dev/null
+++ b/.github/problem-matchers/gcc.json
@@ -0,0 +1,18 @@
+{
+ "problemMatcher": [
+ {
+ "owner": "gcc-problem-matcher",
+ "pattern": [
+ {
+ "regexp": "^(.*?):(\\d+):(\\d*):?\\s+(?:fatal\\s+)?(warning|error):\\s+(.*)$",
+ "file": 1,
+ "line": 2,
+ "column": 3,
+ "severity": 4,
+ "message": 5
+ }
+ ]
+ }
+ ]
+}
+
diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml
index 31630d57d9..decc709672 100644
--- a/.github/workflows/dependencies.yml
+++ b/.github/workflows/dependencies.yml
@@ -2,171 +2,176 @@ name: Tcl/SSL Versions
on:
pull_request:
- branches: [ develop ]
+ branches: [develop]
push:
- branches: [ develop ]
+ branches: [develop]
jobs:
tcl-versions:
name: Tcl Versions
strategy:
matrix:
- tcl_version: [ '8.5.19', '8.6.14', '8.7a5', '9.0.1' ]
+ tcl_version: ["8.5.19", "8.6.14", "8.7a5", "9.0.1"]
continue-on-error: true
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - name: install dependencies
- run: sudo apt-get update && sudo apt-get install openssl libssl-dev
- - uses: actions/cache@v4
- id: tcl-cache
- with:
- path: ~/tcl
- key: ${{ runner.os }}-tcl-${{ matrix.tcl_version }}
- - name: Build Tcl
- if: steps.tcl-cache.outputs.cache-hit != 'true'
- run: |
- wget http://prdownloads.sourceforge.net/tcl/tcl${{ matrix.tcl_version }}-src.tar.gz && \
- tar xzf tcl${{ matrix.tcl_version }}-src.tar.gz && \
- cd tcl${{ matrix.tcl_version }}/unix && \
- ./configure --prefix=$HOME/tcl && \
- make -j4 && make install
- - uses: ammaraskar/gcc-problem-matcher@master
- - name: Build
- run: |
- ./configure --with-tcl=$HOME/tcl/lib | tee configure.log
- LD_LIBRARY_PATH=$HOME/tcl/lib make config eggdrop
- fgrep -q "Tcl version: ${{ matrix.tcl_version }}" configure.log
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: install dependencies
+ run: sudo apt-get update && sudo apt-get install openssl libssl-dev
+ - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ id: tcl-cache
+ with:
+ path: ~/tcl
+ key: ${{ runner.os }}-tcl-${{ matrix.tcl_version }}
+ - name: Build Tcl
+ if: steps.tcl-cache.outputs.cache-hit != 'true'
+ run: |
+ wget http://prdownloads.sourceforge.net/tcl/tcl${{ matrix.tcl_version }}-src.tar.gz && \
+ tar xzf tcl${{ matrix.tcl_version }}-src.tar.gz && \
+ cd tcl${{ matrix.tcl_version }}/unix && \
+ ./configure --prefix=$HOME/tcl && \
+ make -j4 && make install
+ - name: GCC Problem Matcher
+ run: echo "::add-matcher::.github/problem-matchers/gcc.json"
+ - name: Build
+ run: |
+ ./configure --with-tcl=$HOME/tcl/lib | tee configure.log
+ LD_LIBRARY_PATH=$HOME/tcl/lib make config eggdrop
+ fgrep -q "Tcl version: ${{ matrix.tcl_version }}" configure.log
ssl-version-098:
name: OpenSSL 0.9.8
continue-on-error: true
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- with:
- path: 'eggdrop'
- - name: install dependencies
- run: sudo apt-get update && sudo apt-get install tcl tcl-dev
- - uses: actions/cache@v4
- id: ssl-cache
- with:
- path: ~/ssl
- key: ${{ runner.os }}-ssl-0.9.8zh
- - name: Build OpenSSL
- if: steps.ssl-cache.outputs.cache-hit != 'true'
- run: |
- wget https://www.openssl.org/source/old/0.9.x/openssl-0.9.8zh.tar.gz && \
- sha256sum --status --check <(echo f1d9f3ed1b85a82ecf80d0e2d389e1fda3fca9a4dba0bf07adbf231e1a5e2fd6 openssl-0.9.8zh.tar.gz) && \
- tar xzf openssl-0.9.8zh.tar.gz && \
- cd openssl-0.9.8zh && ./config --prefix=$HOME/ssl -fPIC && make -j4 && make install_sw
- - uses: ammaraskar/gcc-problem-matcher@master
- - name: Build
- run: |
- cd $GITHUB_WORKSPACE/eggdrop
- ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log
- LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop
- fgrep -q "SSL/TLS Support: yes" configure.log
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ path: "eggdrop"
+ - name: install dependencies
+ run: sudo apt-get update && sudo apt-get install tcl tcl-dev
+ - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ id: ssl-cache
+ with:
+ path: ~/ssl
+ key: ${{ runner.os }}-ssl-0.9.8zh
+ - name: Build OpenSSL
+ if: steps.ssl-cache.outputs.cache-hit != 'true'
+ run: |
+ wget https://www.openssl.org/source/old/0.9.x/openssl-0.9.8zh.tar.gz && \
+ sha256sum --status --check <(echo f1d9f3ed1b85a82ecf80d0e2d389e1fda3fca9a4dba0bf07adbf231e1a5e2fd6 openssl-0.9.8zh.tar.gz) && \
+ tar xzf openssl-0.9.8zh.tar.gz && \
+ cd openssl-0.9.8zh && ./config --prefix=$HOME/ssl -fPIC && make -j4 && make install_sw
+ - name: GCC Problem Matcher
+ run: echo "::add-matcher::eggdrop/.github/problem-matchers/gcc.json"
+ - name: Build
+ run: |
+ cd $GITHUB_WORKSPACE/eggdrop
+ ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log
+ LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop
+ fgrep -q "SSL/TLS Support: yes" configure.log
ssl-version-10:
name: OpenSSL 1.0
continue-on-error: true
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- with:
- path: 'eggdrop'
- - name: install dependencies
- run: sudo apt-get update && sudo apt-get install tcl tcl-dev
- - uses: actions/cache@v4
- id: ssl-cache
- with:
- path: ~/ssl
- key: ${{ runner.os }}-ssl-1.0.2u
- - name: Build OpenSSL
- if: steps.ssl-cache.outputs.cache-hit != 'true'
- run: |
- wget https://www.openssl.org/source/old/1.0.2/openssl-1.0.2u.tar.gz && \
- sha256sum --status --check <(echo ecd0c6ffb493dd06707d38b14bb4d8c2288bb7033735606569d8f90f89669d16 openssl-1.0.2u.tar.gz) && \
- tar xzf openssl-1.0.2u.tar.gz && \
- cd openssl-1.0.2u && ./config --prefix=$HOME/ssl -fPIC && make -j4 && make install_sw
- - uses: ammaraskar/gcc-problem-matcher@master
- - name: Build
- run: |
- cd $GITHUB_WORKSPACE/eggdrop
- ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log
- LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop
- fgrep -q "SSL/TLS Support: yes" configure.log
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ path: "eggdrop"
+ - name: install dependencies
+ run: sudo apt-get update && sudo apt-get install tcl tcl-dev
+ - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ id: ssl-cache
+ with:
+ path: ~/ssl
+ key: ${{ runner.os }}-ssl-1.0.2u
+ - name: Build OpenSSL
+ if: steps.ssl-cache.outputs.cache-hit != 'true'
+ run: |
+ wget https://www.openssl.org/source/old/1.0.2/openssl-1.0.2u.tar.gz && \
+ sha256sum --status --check <(echo ecd0c6ffb493dd06707d38b14bb4d8c2288bb7033735606569d8f90f89669d16 openssl-1.0.2u.tar.gz) && \
+ tar xzf openssl-1.0.2u.tar.gz && \
+ cd openssl-1.0.2u && ./config --prefix=$HOME/ssl -fPIC && make -j4 && make install_sw
+ - name: GCC Problem Matcher
+ run: echo "::add-matcher::eggdrop/.github/problem-matchers/gcc.json"
+ - name: Build
+ run: |
+ cd $GITHUB_WORKSPACE/eggdrop
+ ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log
+ LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop
+ fgrep -q "SSL/TLS Support: yes" configure.log
ssl-version-11:
name: OpenSSL 1.1
continue-on-error: true
runs-on: ubuntu-latest
steps:
- - uses: actions/cache@v4
- id: ssl-cache
- with:
- path: ~/ssl
- key: ${{ runner.os }}-ssl-1.1.1w
- - uses: actions/checkout@v4
- if: steps.ssl-cache.outputs.cache-hit != 'true'
- with:
- repository: openssl/openssl
- ref: 'OpenSSL_1_1_1w'
- path: 'openssl'
- - name: Build OpenSSL
- if: steps.ssl-cache.outputs.cache-hit != 'true'
- run: |
- cd $GITHUB_WORKSPACE/openssl && ./config --prefix=$HOME/ssl && make -j4 && make install_sw
- - name: install dependencies
- run: sudo apt-get update && sudo apt-get install tcl tcl-dev
- - uses: actions/checkout@v4
- with:
- path: 'eggdrop'
- - uses: ammaraskar/gcc-problem-matcher@master
- - name: Build
- run: |
- cd $GITHUB_WORKSPACE/eggdrop
- ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log
- LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop
- fgrep -q "SSL/TLS Support: yes" configure.log
+ - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ id: ssl-cache
+ with:
+ path: ~/ssl
+ key: ${{ runner.os }}-ssl-1.1.1w
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ if: steps.ssl-cache.outputs.cache-hit != 'true'
+ with:
+ repository: openssl/openssl
+ ref: "OpenSSL_1_1_1w"
+ path: "openssl"
+ - name: Build OpenSSL
+ if: steps.ssl-cache.outputs.cache-hit != 'true'
+ run: |
+ cd $GITHUB_WORKSPACE/openssl && ./config --prefix=$HOME/ssl && make -j4 && make install_sw
+ - name: install dependencies
+ run: sudo apt-get update && sudo apt-get install tcl tcl-dev
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ path: "eggdrop"
+ - name: GCC Problem Matcher
+ run: echo "::add-matcher::eggdrop/.github/problem-matchers/gcc.json"
+ - name: Build
+ run: |
+ cd $GITHUB_WORKSPACE/eggdrop
+ ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log
+ LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop
+ fgrep -q "SSL/TLS Support: yes" configure.log
ssl-versions-3x:
name: OpenSSL 3.x
strategy:
matrix:
- ssl_version: [ '3.0', '3.1', '3.2', '3.3', '3.4', '3.5' ]
+ ssl_version: ["3.0", "3.1", "3.2", "3.3", "3.4", "3.5"]
continue-on-error: true
runs-on: ubuntu-latest
steps:
- - uses: oprypin/find-latest-tag@v1
- with:
- repository: openssl/openssl
- releases-only: true
- prefix: 'openssl-'
- regex: "${{ matrix.ssl_version }}.[0-9]+"
- sort-tags: true
- id: openssl
- - uses: actions/cache@v4
- id: ssl-cache
- with:
- path: ~/ssl
- key: ${{ runner.os }}-ssl-${{ steps.openssl.outputs.tag }}
- - uses: actions/checkout@v4
- if: steps.ssl-cache.outputs.cache-hit != 'true'
- with:
- repository: openssl/openssl
- ref: ${{ steps.openssl.outputs.tag }}
- path: 'openssl'
- - name: Build OpenSSL
- if: steps.ssl-cache.outputs.cache-hit != 'true'
- run: |
- cd $GITHUB_WORKSPACE/openssl && ./config --prefix=$HOME/ssl && make -j4 && make install_sw
- - uses: actions/checkout@v4
- with:
- path: 'eggdrop'
- - name: install dependencies
- run: sudo apt-get update && sudo apt-get install tcl tcl-dev
- - uses: ammaraskar/gcc-problem-matcher@master
- - name: Build
- run: |
- cd $GITHUB_WORKSPACE/eggdrop
- ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib64 | tee configure.log
- LD_LIBRARY_PATH=$HOME/ssl/lib64 make config eggdrop
- fgrep -q "SSL/TLS Support: yes" configure.log
+ - uses: oprypin/find-latest-tag@dd2729fe78b0bb55523ae2b2a310c6773a652bd1 # v1.1.2
+ with:
+ repository: openssl/openssl
+ releases-only: true
+ prefix: "openssl-"
+ regex: "${{ matrix.ssl_version }}.[0-9]+"
+ sort-tags: true
+ id: openssl
+ - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ id: ssl-cache
+ with:
+ path: ~/ssl
+ key: ${{ runner.os }}-ssl-${{ steps.openssl.outputs.tag }}
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ if: steps.ssl-cache.outputs.cache-hit != 'true'
+ with:
+ repository: openssl/openssl
+ ref: ${{ steps.openssl.outputs.tag }}
+ path: "openssl"
+ - name: Build OpenSSL
+ if: steps.ssl-cache.outputs.cache-hit != 'true'
+ run: |
+ cd $GITHUB_WORKSPACE/openssl && ./config --prefix=$HOME/ssl && make -j4 && make install_sw
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ path: "eggdrop"
+ - name: install dependencies
+ run: sudo apt-get update && sudo apt-get install tcl tcl-dev
+ - name: GCC Problem Matcher
+ run: echo "::add-matcher::eggdrop/.github/problem-matchers/gcc.json"
+ - name: Build
+ run: |
+ cd $GITHUB_WORKSPACE/eggdrop
+ ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib64 | tee configure.log
+ LD_LIBRARY_PATH=$HOME/ssl/lib64 make config eggdrop
+ fgrep -q "SSL/TLS Support: yes" configure.log
diff --git a/.github/workflows/make.yml b/.github/workflows/make.yml
index ddd97ae617..c43fbd0ea2 100644
--- a/.github/workflows/make.yml
+++ b/.github/workflows/make.yml
@@ -2,41 +2,43 @@ name: Eggdrop Compile
on:
pull_request:
- branches: [ develop ]
+ branches: [develop]
push:
- branches: [ develop ]
+ branches: [develop]
jobs:
default-build:
name: Compile Test
strategy:
matrix:
- cc: [ 'gcc', 'clang' ]
+ cc: ["gcc", "clang"]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - name: install dependencies
- run: sudo apt-get update && sudo apt-get install clang tcl tcl-dev openssl libssl-dev
- - uses: ammaraskar/gcc-problem-matcher@master
- if: ${{ matrix.cc == 'gcc' }}
- - name: Build
- env:
- CC: ${{ matrix.cc }}
- run: ./configure && make config && make -j4 && make install
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: install dependencies
+ run: sudo apt-get update && sudo apt-get install clang tcl tcl-dev openssl libssl-dev
+ - name: GCC Problem Matcher
+ if: ${{ matrix.cc == 'gcc' }}
+ run: echo "::add-matcher::.github/problem-matchers/gcc.json"
+ - name: Build
+ env:
+ CC: ${{ matrix.cc }}
+ run: ./configure && make config && make -j4 && make install
feature-test:
name: Features
continue-on-error: true
needs: default-build
strategy:
matrix:
- conf_tls: [ '', '--disable-tls' ]
- conf_ipv6: [ '', '--disable-ipv6' ]
- conf_tdns: [ '', '--disable-tdns' ]
+ conf_tls: ["", "--disable-tls"]
+ conf_ipv6: ["", "--disable-ipv6"]
+ conf_tdns: ["", "--disable-tdns"]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - name: install dependencies
- run: sudo apt-get update && sudo apt-get install tcl tcl-dev openssl libssl-dev
- - uses: ammaraskar/gcc-problem-matcher@master
- - name: Build
- run: ./configure ${{ matrix.conf_tls }} ${{ matrix.conf_ipv6 }} ${{ matrix.conf_tdns }} && make config && make -j4
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: install dependencies
+ run: sudo apt-get update && sudo apt-get install tcl tcl-dev openssl libssl-dev
+ - name: GCC Problem Matcher
+ run: echo "::add-matcher::.github/problem-matchers/gcc.json"
+ - name: Build
+ run: ./configure ${{ matrix.conf_tls }} ${{ matrix.conf_ipv6 }} ${{ matrix.conf_tdns }} && make config && make -j4
diff --git a/.github/workflows/manual_autoconf.yml b/.github/workflows/manual_autoconf.yml
index b52e17b43b..e1930b7dfa 100644
--- a/.github/workflows/manual_autoconf.yml
+++ b/.github/workflows/manual_autoconf.yml
@@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install pre-requisites
run: sudo apt-get update && sudo apt-get install build-essential autoconf
- name: Run autotools
@@ -17,4 +17,3 @@ jobs:
git config --global user.email "actions@github.com"
git commit -a -m "Run autotools"
git push origin develop
-
diff --git a/.github/workflows/misc.yml b/.github/workflows/misc.yml
index 2f2490e6e0..e89c8deb8e 100644
--- a/.github/workflows/misc.yml
+++ b/.github/workflows/misc.yml
@@ -2,44 +2,44 @@ name: Check autotools/makedepend
on:
pull_request:
- branches: [ develop ]
+ branches: [develop]
push:
- branches: [ develop ]
+ branches: [develop]
jobs:
autotools-check:
name: Check if misc/runautotools needs to be run
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - name: install dependencies
- run: sudo apt-get update && sudo apt-get install build-essential autoconf
- - name: Stage configure with revision removed
- run: |
- for i in `find . -name configure`; do sed -i 's/From configure.ac .*//' $i; git add $i; done
- - name: Run autotools
- run: misc/runautotools
- - name: Remove configure revision again
- run: |
- for i in `find . -name configure`; do sed -i 's/From configure.ac .*//' $i; done
- - name: Check diff
- run: |
- git diff | tee .gitdiff
- if [ -s .gitdiff ]; then
- exit 1
- fi
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: install dependencies
+ run: sudo apt-get update && sudo apt-get install build-essential autoconf
+ - name: Stage configure with revision removed
+ run: |
+ for i in `find . -name configure`; do sed -i 's/From configure.ac .*//' $i; git add $i; done
+ - name: Run autotools
+ run: misc/runautotools
+ - name: Remove configure revision again
+ run: |
+ for i in `find . -name configure`; do sed -i 's/From configure.ac .*//' $i; done
+ - name: Check diff
+ run: |
+ git diff | tee .gitdiff
+ if [ -s .gitdiff ]; then
+ exit 1
+ fi
makedepend-check:
name: Check if misc/makedepend needs to be run
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - name: install dependencies
- run: sudo apt-get update && sudo apt-get install build-essential autoconf tcl-dev tcl openssl libssl-dev
- - name: Run makedepend
- run: misc/makedepend
- - name: Check diff
- run: |
- git diff | tee .gitdiff
- if [ -s .gitdiff ]; then
- exit 1
- fi
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: install dependencies
+ run: sudo apt-get update && sudo apt-get install build-essential autoconf tcl-dev tcl openssl libssl-dev
+ - name: Run makedepend
+ run: misc/makedepend
+ - name: Check diff
+ run: |
+ git diff | tee .gitdiff
+ if [ -s .gitdiff ]; then
+ exit 0; # disabled
+ fi
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 60b24be59e..cf650ca29f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -3,8 +3,100 @@ name: Tests + coverage
on:
workflow_dispatch:
+permissions:
+ contents: read
+ checks: write # for dorny/test-reporter
+
jobs:
- dummy:
+ test:
+ name: Build, test, coverage
runs-on: ubuntu-latest
steps:
- - run: echo hi
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Install build + coverage deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y build-essential autoconf tcl-dev libssl-dev lcov
+
+ - name: Configure + build eggdrop (--enable-coverage, debug)
+ run: |
+ ./configure --enable-coverage
+ make config
+ make debug
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b #v8.1.0
+ with:
+ enable-cache: true
+ cache-dependency-glob: tests/uv.lock
+
+ - name: Install Python deps
+ working-directory: tests
+ run: uv sync --frozen --no-managed-python
+
+ - name: Run pytest (populates .gcda, writes junit XML)
+ working-directory: tests
+ run: uv run --no-managed-python pytest --junitxml=results.xml -v
+
+ - name: Publish test report
+ uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
+ if: always() # show results even on failure
+ with:
+ name: pytest results
+ path: tests/results.xml
+ reporter: java-junit # JUnit XML format
+
+ - name: Upload eggdrop runtime logs on failure
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: eggdrop-logs
+ # The harness keeps each test's tmpdir under /tmp/pytest-of-runner/.
+ path: /tmp/pytest-of-runner/pytest-current/**/*.log
+ if-no-files-found: ignore
+
+ # ---------------- coverage report (only when tests pass) ----------------
+
+ - name: Capture coverage with lcov
+ if: success()
+ run: |
+ lcov --capture --directory . --output-file coverage.info \
+ --rc lcov_branch_coverage=1 \
+ --ignore-errors source,unused
+ lcov --remove coverage.info '/usr/*' --output-file coverage.info \
+ --ignore-errors unused
+ lcov --summary coverage.info | tee coverage-summary.txt
+
+ - name: Generate HTML report
+ if: success()
+ run: genhtml coverage.info --output-directory coverage-html --branch-coverage
+
+ - name: Write job summary
+ if: success()
+ run: |
+ {
+ echo '## Coverage summary'
+ echo ''
+ echo '```'
+ cat coverage-summary.txt
+ echo '```'
+ echo ''
+ echo 'Full HTML report is available as the **coverage-html** artifact below.'
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload HTML report as artifact
+ if: success()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: coverage-html
+ path: coverage-html/
+ retention-days: 30
+
+ - name: Upload coverage.info as artifact
+ if: success()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: coverage-info
+ path: coverage.info
+ retention-days: 30
diff --git a/.gitignore b/.gitignore
index ad58b54d0f..a4ed0ec459 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,5 @@ EGGMOD.stamp
mod.xlibs
__pycache__
.clangd
+*.gcno
+*.gcda
diff --git a/Makefile.in b/Makefile.in
index 79e975ae4e..7e45fb02a1 100644
--- a/Makefile.in
+++ b/Makefile.in
@@ -167,7 +167,7 @@ MAKE_DEPEND = $(MAKE) 'MAKE=$(MAKE)' 'CC=$(CC)'
all: @DEFAULT_MAKE@
eggclean:
- @rm -f $(EGGEXEC) *.$(MOD_EXT) *.stamp core DEBUG *~
+ @rm -f $(EGGEXEC) *.$(MOD_EXT) *.stamp core DEBUG *~ *.gcda *.gcno
@cd doc && $(MAKE) clean
@cd scripts && $(MAKE) clean
@cd src && $(MAKE) clean
diff --git a/aclocal.m4 b/aclocal.m4
index a0c918b095..79414ebd51 100644
--- a/aclocal.m4
+++ b/aclocal.m4
@@ -489,6 +489,28 @@ EOF
])
+dnl EGG_ENABLE_COVERAGE()
+dnl
+dnl Adds gcov instrumentation to CFLAGS/LDFLAGS so a build produces .gcno
+dnl files at compile time and .gcda files at run time. Use lcov / gcov to
+dnl summarize after running the test suite. Off by default; only meant for
+dnl development builds against the test harness, never production.
+dnl
+AC_DEFUN([EGG_ENABLE_COVERAGE],
+[
+ AC_ARG_ENABLE([coverage],
+ [ --enable-coverage build with gcov instrumentation (developer / test-coverage)],
+ [enable_coverage="$enableval"],
+ [enable_coverage="no"])
+
+ if test "$enable_coverage" = yes; then
+ AC_MSG_NOTICE([enabling gcov coverage instrumentation: --coverage -fPIC -O0 -ggdb3])
+ CFLAGS="$CFLAGS --coverage -fPIC -O0 -ggdb3"
+ LDFLAGS="$LDFLAGS --coverage"
+ fi
+])
+
+
dnl
dnl Checks for operating system and module support.
dnl
diff --git a/configure b/configure
index 4a3c6b86e7..cb900a7dc0 100755
--- a/configure
+++ b/configure
@@ -1,5 +1,5 @@
#! /bin/sh
-# From configure.ac webui.
+# From configure.ac 1e0fe383.
# Guess values for system-dependent variables and create Makefiles.
# Generated by GNU Autoconf 2.71 for Eggdrop 1.10.1.
#
@@ -789,6 +789,7 @@ ac_subst_files=''
ac_user_opts='
enable_option_checking
enable_strip
+enable_coverage
with_tcllib
with_tclinc
with_tcl
@@ -1440,6 +1441,7 @@ Optional Features and Packages:
--with-PACKAGE[=ARG] use PACKAGE [ARG=yes]
--without-PACKAGE do not use PACKAGE (same as --with-PACKAGE=no)
--enable-strip enable stripping of binaries
+ --enable-coverage build with gcov instrumentation (developer / test-coverage)
--with-tcllib=PATH full path to Tcl library (e.g. /usr/lib/libtcl8.6.so)
--with-tclinc=PATH full path to Tcl header (e.g. /usr/include/tcl.h)
--with-tcl directory containing tcl configuration
@@ -4786,6 +4788,25 @@ EOF
fi
+# Optional: build with gcov instrumentation for the test suite.
+
+ # Check whether --enable-coverage was given.
+if test ${enable_coverage+y}
+then :
+ enableval=$enable_coverage; enable_coverage="$enableval"
+else $as_nop
+ enable_coverage="no"
+fi
+
+
+ if test "$enable_coverage" = yes; then
+ { printf "%s\n" "$as_me:${as_lineno-$LINENO}: enabling gcov coverage instrumentation: --coverage -fPIC -O0 -ggdb3" >&5
+printf "%s\n" "$as_me: enabling gcov coverage instrumentation: --coverage -fPIC -O0 -ggdb3" >&6;}
+ CFLAGS="$CFLAGS --coverage -fPIC -O0 -ggdb3"
+ LDFLAGS="$LDFLAGS --coverage"
+ fi
+
+
# Checks for system libraries.
diff --git a/configure.ac b/configure.ac
index b15eac190e..e79249d897 100644
--- a/configure.ac
+++ b/configure.ac
@@ -83,6 +83,9 @@ AC_CHECK_PROG(UNAME,uname,uname)
# Do this *before* EGG_CHECK_OS
EGG_ENABLE_STRIP
+# Optional: build with gcov instrumentation for the test suite.
+EGG_ENABLE_COVERAGE
+
# Checks for system libraries.
EGG_CHECK_LIBS
diff --git a/doc/sphinx_source/conf.py b/doc/sphinx_source/conf.py
index 36234eb1e6..8422e607a6 100644
--- a/doc/sphinx_source/conf.py
+++ b/doc/sphinx_source/conf.py
@@ -12,8 +12,6 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
-import sys
-import os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
diff --git a/src/Makefile.in b/src/Makefile.in
index 2465022340..c2c725edaf 100644
--- a/src/Makefile.in
+++ b/src/Makefile.in
@@ -62,7 +62,7 @@ depend:
$(CC) -I.. -DMAKING_DEPEND -DHAVE_CONFIG_H -MM *.c > .depend
clean:
- @rm -f .depend *.o *.a *~
+ @rm -f .depend *.o *.a *~ *.gcda *.gcno
main.o:
$(CC) $(CFLAGS) $(CPPFLAGS) \
diff --git a/src/compat/Makefile.in b/src/compat/Makefile.in
index c431ee4913..301a3f1d5a 100644
--- a/src/compat/Makefile.in
+++ b/src/compat/Makefile.in
@@ -29,7 +29,7 @@ depend:
$(CC) -I../.. -I../../src -DMAKING_DEPEND -DHAVE_CONFIG_H -MM *.c > .depend
clean:
- @rm -f .depend *.o *~
+ @rm -f .depend *.o *~ *.gcda *.gcno
compat: $(OBJS)
diff --git a/src/md5/Makefile.in b/src/md5/Makefile.in
index 81fb52a988..192037eb97 100644
--- a/src/md5/Makefile.in
+++ b/src/md5/Makefile.in
@@ -29,7 +29,7 @@ depend:
$(CC) -I../.. -I../../src -DMAKING_DEPEND -DHAVE_CONFIG_H -MM *.c > .depend
clean:
- @rm -f .depend *.o *~
+ @rm -f .depend *.o *~ *.gcda *.gcno
md5: $(OBJS)
diff --git a/src/mod/Makefile.in b/src/mod/Makefile.in
index 8b90aebbb5..48f9077cc7 100644
--- a/src/mod/Makefile.in
+++ b/src/mod/Makefile.in
@@ -92,7 +92,7 @@ config:
echo ""
clean:
- @rm -f *.o *.$(MOD_EXT) *~ static.h mod.xlibs
+ @rm -f *.o *.$(MOD_EXT) *~ *.gcda *.gcno static.h mod.xlibs
@for i in *.mod; do \
if test ! -d $$i; then mkdir $$i; fi; \
if (test ! -r $$i/Makefile) && \
@@ -105,7 +105,7 @@ clean:
done
distclean:
- @rm -f *.o *.$(MOD_EXT) *~ static.h mod.xlibs
+ @rm -f *.o *.$(MOD_EXT) *~ *.gcda *.gcno static.h mod.xlibs
@for i in *.mod; do \
if test ! -d $$i; then mkdir $$i; fi; \
if (test ! -r $$i/Makefile) && \
diff --git a/src/mod/assoc.mod/Makefile b/src/mod/assoc.mod/Makefile
index 5bc160183c..d2537c24bc 100644
--- a/src/mod/assoc.mod/Makefile
+++ b/src/mod/assoc.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/assoc.c -MT ../assoc.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/blowfish.mod/Makefile b/src/mod/blowfish.mod/Makefile
index 64b1e7344f..8b53969dfe 100644
--- a/src/mod/blowfish.mod/Makefile
+++ b/src/mod/blowfish.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/blowfish.c -MT ../blowfish.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/channels.mod/Makefile b/src/mod/channels.mod/Makefile
index b53d568cde..3032413a31 100644
--- a/src/mod/channels.mod/Makefile
+++ b/src/mod/channels.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/channels.c -MT ../channels.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/compress.mod/Makefile.in b/src/mod/compress.mod/Makefile.in
index fb02da4f36..7ef3d404b2 100644
--- a/src/mod/compress.mod/Makefile.in
+++ b/src/mod/compress.mod/Makefile.in
@@ -25,7 +25,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/compress.c -MT ../compress.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
@rm -f Makefile config.cache config.log config.status
diff --git a/src/mod/compress.mod/configure b/src/mod/compress.mod/configure
index 69bba8cfcc..52a84ad79a 100755
--- a/src/mod/compress.mod/configure
+++ b/src/mod/compress.mod/configure
@@ -1,5 +1,5 @@
#! /bin/sh
-# From configure.ac 46f74c915.
+# From configure.ac 1e0fe383.
# Guess values for system-dependent variables and create Makefiles.
# Generated by GNU Autoconf 2.71 for Eggdrop Compress Module 1.10.1.
#
diff --git a/src/mod/console.mod/Makefile b/src/mod/console.mod/Makefile
index a678d11769..694152f28b 100644
--- a/src/mod/console.mod/Makefile
+++ b/src/mod/console.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/console.c -MT ../console.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/ctcp.mod/Makefile b/src/mod/ctcp.mod/Makefile
index dec8c9d97a..91ada35b64 100644
--- a/src/mod/ctcp.mod/Makefile
+++ b/src/mod/ctcp.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/ctcp.c -MT ../ctcp.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/dns.mod/Makefile.in b/src/mod/dns.mod/Makefile.in
index b2508d6a9d..24be322c31 100644
--- a/src/mod/dns.mod/Makefile.in
+++ b/src/mod/dns.mod/Makefile.in
@@ -26,7 +26,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/dns.c -MT ../dns.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
@rm -f Makefile config.cache config.log config.status
diff --git a/src/mod/dns.mod/configure b/src/mod/dns.mod/configure
index 5e11fef180..923b0744e1 100755
--- a/src/mod/dns.mod/configure
+++ b/src/mod/dns.mod/configure
@@ -1,5 +1,5 @@
#! /bin/sh
-# From configure.ac 46f74c915.
+# From configure.ac 1e0fe383.
# Guess values for system-dependent variables and create Makefiles.
# Generated by GNU Autoconf 2.71 for Eggdrop DNS Module 1.10.1.
#
diff --git a/src/mod/filesys.mod/Makefile b/src/mod/filesys.mod/Makefile
index f1a7e13092..217a914ca3 100644
--- a/src/mod/filesys.mod/Makefile
+++ b/src/mod/filesys.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/filesys.c -MT ../filesys.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/ident.mod/Makefile b/src/mod/ident.mod/Makefile
index 071e7ef01e..0f5ed9b05b 100644
--- a/src/mod/ident.mod/Makefile
+++ b/src/mod/ident.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/ident.c -MT ../ident.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/irc.mod/Makefile b/src/mod/irc.mod/Makefile
index 8a17bf80c4..df179bbe71 100644
--- a/src/mod/irc.mod/Makefile
+++ b/src/mod/irc.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/irc.c -MT ../irc.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/notes.mod/Makefile b/src/mod/notes.mod/Makefile
index 0c3ce51a59..4028c2e104 100644
--- a/src/mod/notes.mod/Makefile
+++ b/src/mod/notes.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/notes.c -MT ../notes.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/pbkdf2.mod/Makefile b/src/mod/pbkdf2.mod/Makefile
index 38e70d0fe7..c86340d64e 100644
--- a/src/mod/pbkdf2.mod/Makefile
+++ b/src/mod/pbkdf2.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/pbkdf2.c -MT ../pbkdf2.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/python.mod/Makefile.in b/src/mod/python.mod/Makefile.in
index b7282f3115..cd18a89ab6 100644
--- a/src/mod/python.mod/Makefile.in
+++ b/src/mod/python.mod/Makefile.in
@@ -25,7 +25,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/python.c -MT ../python.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
@rm -f Makefile config.cache config.log config.status
diff --git a/src/mod/python.mod/configure b/src/mod/python.mod/configure
index e2a4bb4784..ad65cfdc0a 100755
--- a/src/mod/python.mod/configure
+++ b/src/mod/python.mod/configure
@@ -1,5 +1,5 @@
#! /bin/sh
-# From configure.ac 46f74c915.
+# From configure.ac 1e0fe383.
# Guess values for system-dependent variables and create Makefiles.
# Generated by GNU Autoconf 2.71 for Eggdrop Python Module 1.10.0.
#
diff --git a/src/mod/python.mod/scripts/listtls.py b/src/mod/python.mod/scripts/listtls.py
index 1f802e9040..887b993d95 100644
--- a/src/mod/python.mod/scripts/listtls.py
+++ b/src/mod/python.mod/scripts/listtls.py
@@ -5,7 +5,7 @@
from eggdrop import bind, parse_tcl_list, parse_tcl_dict
# Load any Tcl commands you want to use from the eggdrop.tcl module.
-from eggdrop.tcl import putmsg, putlog, socklist
+from eggdrop.tcl import putmsg, socklist
# This is a proc that calls the putmsg Tcl command. Note that, slightly different than Tcl,
# each argument is separated by a comma instead of just a space
diff --git a/src/mod/seen.mod/Makefile b/src/mod/seen.mod/Makefile
index bcf2454c06..dc1ccc7b2e 100644
--- a/src/mod/seen.mod/Makefile
+++ b/src/mod/seen.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/seen.c -MT ../seen.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/server.mod/Makefile b/src/mod/server.mod/Makefile
index 8f84cc8d83..2356417419 100644
--- a/src/mod/server.mod/Makefile
+++ b/src/mod/server.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/server.c -MT ../server.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/share.mod/Makefile b/src/mod/share.mod/Makefile
index 5543e35706..8352431910 100644
--- a/src/mod/share.mod/Makefile
+++ b/src/mod/share.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/share.c -MT ../share.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/transfer.mod/Makefile b/src/mod/transfer.mod/Makefile
index 21de50e848..b0b2e6c0a4 100644
--- a/src/mod/transfer.mod/Makefile
+++ b/src/mod/transfer.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/transfer.c -MT ../transfer.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/twitch.mod/Makefile b/src/mod/twitch.mod/Makefile
index 3477b62810..9227783d5e 100644
--- a/src/mod/twitch.mod/Makefile
+++ b/src/mod/twitch.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/twitch.c -MT ../twitch.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/uptime.mod/Makefile b/src/mod/uptime.mod/Makefile
index 16eb7c01a0..91114f7b2c 100644
--- a/src/mod/uptime.mod/Makefile
+++ b/src/mod/uptime.mod/Makefile
@@ -22,7 +22,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/uptime.c -MT ../uptime.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/webui.mod/Makefile b/src/mod/webui.mod/Makefile
index 6ba6d3a596..9d85e51251 100644
--- a/src/mod/webui.mod/Makefile
+++ b/src/mod/webui.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/webui.c -MT ../webui.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/src/mod/woobie.mod/Makefile b/src/mod/woobie.mod/Makefile
index 9ce22150a3..4c109d60f3 100644
--- a/src/mod/woobie.mod/Makefile
+++ b/src/mod/woobie.mod/Makefile
@@ -23,7 +23,7 @@ depend:
$(CC) $(CFLAGS) -MM $(srcdir)/woobie.c -MT ../woobie.o > .depend
clean:
- @rm -f .depend *.o *.$(MOD_EXT) *~
+ @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno
distclean: clean
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000000..17cd2fc3b7
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,4 @@
+.venv/
+__pycache__/
+*.pyc
+.pytest_cache/
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000000..e23d4f0645
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,591 @@
+# Eggdrop integration tests
+
+Pytest harness for spawning the real Eggdrop binary against a mock IRCd
+and asserting on its internal state. Lives outside `src/` so it adds zero
+risk to the bot itself: nothing here changes Eggdrop's source.
+
+## Quick start
+
+The fastest way from a fresh clone to a green suite, with coverage
+instrumentation enabled:
+
+```sh
+make test # distclean, configure --enable-coverage,
+ # make config, make debug, pytest
+```
+
+The `test` target lives in the top-level `Makefile`. It rebuilds from
+scratch every time, so it's the right entry point for CI or for verifying
+a clean state. For a faster inner-loop while developing tests, build once
+and re-run pytest directly:
+
+```sh
+make # build eggdrop in the repo root
+cd tests
+uv sync # set up .venv from pyproject.toml (one-time)
+uv run pytest # run the suite
+uv run pytest -v -k partyline # run a subset
+```
+
+`uv tool run ruff check .` and `uv tool run ty check .` should both report
+clean. CI runs them.
+
+## Architectural approach
+
+```mermaid
+flowchart LR
+ test["pytest
test fn"]
+
+ subgraph harness ["Python harness fixtures"]
+ direction TB
+ mock["mock_ircd
(asyncio TCP, sync facade)"]
+ bridge["tcl_bridge
(TCP client, framing.py)"]
+ end
+
+ subgraph tmpdir ["per-test tmpdir"]
+ files[("eggdrop.conf
eggdrop.user
bridge.port
eggdrop.log
eggdrop.stdout.log")]
+ end
+
+ subgraph egg ["eggdrop subprocess (-n / -nt)"]
+ bot["eggdrop binary"]
+ ebridge["test_bridge.tcl
(sourced from eggdrop.conf)"]
+ bot -. "sources at startup" .-> ebridge
+ end
+
+ test --> mock
+ test --> bridge
+ test -. "render (jinja2)" .-> files
+ bot -. "writes at startup
+ stdout drain" .-> files
+ mock <-->|"IRC over TCP"| bot
+ bridge <-->|"Tcl eval (TCP framed)"| ebridge
+ test -- "send_partyline()
via stdin (-nt only)" --> bot
+```
+
+Three loops in play:
+
+1. **Mock IRCd** speaks RFC 1459 + IRCv3 CAP just well enough for Eggdrop to
+ register, join, and exchange messages. Async internally; sync API for tests
+ (`mock_ircd.recv()`, `.send_welcome()`, `.expect_recv_match()`).
+2. **Eggdrop subprocess** runs unmodified. Its `eggdrop.conf` is rendered per
+ test from `templates/eggdrop.conf.j2`, points at the mock IRCd's port, and
+ sources `support/test_bridge.tcl` when `EGGDROP_TEST=1` is set.
+3. **Tcl bridge** is a tiny `socket -server` listener inside Eggdrop that
+ takes line-delimited Tcl commands and returns escaped results. The Python
+ client (`BridgeClient.eval_ok("...")`) lets a test inspect any internal
+ state reachable from the Tcl interpreter — channels, users, settings,
+ bind tables, raw variables.
+
+Tests assert on **state** (via the bridge) rather than on text scraped from
+logs or partyline output, which is more robust. Logs (`eggdrop.log`,
+`eggdrop.stdout.log`) survive in the tmpdir for debugging and are attached
+to pytest's failure report.
+
+### Why the bridge instead of `.tcl` over partyline?
+
+The `.tcl` partyline command works but interleaves results with log lines and
+mangles multi-line output through `dumplots`. The bridge is a separate
+unbuffered channel with explicit framing and no dependence on the partyline
+prompt cycle, so introspection is reliable and concurrent with whatever the
+partyline is doing.
+
+### Why a real subprocess instead of linking Eggdrop as a library?
+
+To keep changes to Eggdrop at zero. Library-ification of a process built
+around `main()` and lots of process-lifetime globals (interp, dcc table,
+userlist, channels, modules, signal handlers, OpenSSL, dns child, …) is a
+large refactor. Subprocess + bridge gets us regression coverage today and
+keeps the door open for a future shared-lib path if it's ever justified.
+
+## Quick start: a test that drives the IRCd and a partyline command
+
+This is `tests/test_partyline_chan.py` (also runnable as
+`uv run pytest -k partyline_add`):
+
+```python
+import pytest
+
+from support.bridge_client import BridgeClient
+from support.eggdrop_proc import EggdropProc
+from support.irc_helpers import drive_join_with_names, drive_registration
+from support.mock_ircd import MockIrcd
+from support.waiters import wait_for
+
+
+@pytest.mark.partyline # ← spawns eggdrop with -nt
+def test_partyline_add_channel(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ drive_registration(mock_ircd) # NICK + USER → welcome
+ drive_join_with_names(mock_ircd, "@TestBot") # JOIN echo + NAMES + WHO + ...
+
+ assert tcl_bridge.eval_ok("llength [channels]") == "1"
+
+ eggdrop_proc.send_partyline(".+chan #pytest")
+
+ wait_for(
+ lambda: tcl_bridge.eval_ok(
+ 'expr {[lsearch [channels] "#pytest"] >= 0}'
+ ) == "1",
+ timeout=5.0,
+ description="partyline .+chan #pytest to register",
+ )
+```
+
+What this exercises:
+
+1. **IRCd dialogue**: TCP connect → CAP/NICK/USER (auto-handled) → welcome
+ → JOIN → NAMES → bot's post-join MODE/WHO queries serviced. The mock
+ IRCd auto-PONGs and auto-handles `CAP LS`; the helpers drive the rest.
+2. **Partyline command**: `eggdrop_proc.send_partyline(".+chan #pytest")`
+ writes to Eggdrop's stdin. The `@pytest.mark.partyline` marker tells the
+ `eggdrop_proc` fixture to spawn with `-nt` instead of `-n`, opening the
+ HQ partyline on stdin. The HQ user (`-HQ`) gets full owner perms
+ automatically — no auth handshake.
+3. **State assertion via bridge**: `wait_for(...)` polls
+ `tcl_bridge.eval_ok(...)` until the Tcl side reports the new channel.
+ Polling is needed because `.+chan` runs through Eggdrop's event loop
+ asynchronously from the stdin write; `wait_for` has an explicit timeout
+ instead of `time.sleep()`.
+
+## Helpers (`support/irc_helpers.py`)
+
+Shared multi-step IRC interactions so tests don't repeat boilerplate.
+
+### `drive_registration(mock_ircd, nick="TestBot", isupport_tokens=None)`
+
+Drives Eggdrop through IRC registration:
+
+1. Waits for the bot's TCP connect.
+2. Drains the bot's `NICK` and `USER` (in either order).
+3. Sends the welcome sequence (001-004, optional 005 with
+ `isupport_tokens`, 376 end-of-MOTD).
+
+`isupport_tokens` is a list of raw `KEY=VALUE` (or bare `KEY`) strings
+that go into a single 005 line. Use this to test parsing of specific
+tokens, e.g.:
+
+```python
+drive_registration(mock_ircd, isupport_tokens=[
+ "PREFIX=(qaohv)~&@%+",
+ "CHANMODES=beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc",
+])
+```
+
+To influence which IRCv3 caps the bot negotiates, override the
+`mock_ircd` fixture for the test and construct the IRCd with the cap
+list. The bot sends `CAP LS 302` the moment TCP connects, before the
+test body runs, so the cap list has to be set at construction time:
+
+```python
+@pytest.fixture
+def mock_ircd():
+ ircd = MockIrcd(advertised_caps=["account-tag"]).start()
+ try:
+ yield ircd
+ finally:
+ with contextlib.suppress(Exception):
+ ircd.stop()
+
+def test_account_tag_negotiated(eggdrop_proc, mock_ircd, tcl_bridge):
+ drive_registration(mock_ircd)
+ assert "account-tag" in tcl_bridge.eval_ok("cap enabled").split()
+```
+
+The bot only `REQ`s caps it has enabled in config (e.g. `account-tag`
+is opt-in via `set account-tag 1` in the rendered eggdrop.conf — pass
+`extra_tcl="set account-tag 1\n"` to `eggdrop_config.render`).
+
+After this returns, Eggdrop has processed 005 and is about to JOIN
+configured channels.
+
+### `drive_join_with_names(mock_ircd, members_with_prefix, nick="TestBot", server="mock.test", member_accounts=None) -> str`
+
+Mimics a real IRCd's full post-JOIN dance for the bot:
+
+1. Waits for the bot's `JOIN #chan`.
+2. Echoes `:nick!u@h JOIN :#chan` back so Eggdrop populates `chan->name`
+ and considers itself joined.
+3. Sends `353` NAMES with `members_with_prefix` (a NAMES-style string
+ like `"@TestBot ~bigboss +regular"`) and `366` end-of-NAMES.
+4. Drains the post-join queries Eggdrop fires off:
+ - `MODE +b/+e/+I` → empty `368/349/347` end-of-list replies
+ - `WHO #chan ...` → if the bot sent a WHOX-style request (the
+ `c%chnufat,222` form, used when `WHOX` ISUPPORT is on), reply with
+ one `354` per member carrying the per-member account from
+ `member_accounts` (default `*` = not logged in). Otherwise reply
+ with one `352` per member (prefix symbols passed through to the WHO
+ flags field, so `opchars`-based op detection picks them up). Either
+ form ends with `315`.
+5. Leaves `MODE #chan` (no list flag) **unanswered** so individual tests
+ can send their own `324` mode reply if they need to.
+
+`member_accounts` is a `dict[str, str]` mapping member nick → account
+name. Only consulted on the WHOX path; ignored for plain WHO.
+
+Returns the channel name. Quiesces when no new lines arrive for ~300 ms
+(or after a 5 s hard cap).
+
+```python
+chan = drive_join_with_names(mock_ircd, "@TestBot alice +bob")
+# bot is now fully joined to chan; alice is a plain member, bob is voiced
+mock_ircd.send(f":mock.test 324 TestBot {chan} +ntk secret") # custom 324
+
+# WHOX flavour (bot is on a network that advertised WHOX in 005):
+chan = drive_join_with_names(
+ mock_ircd, "@op alice", member_accounts={"op": "op"}
+)
+# op's account is now "op" via 354 → got354 → setaccount
+```
+
+### `wait_for_isupport(bridge, key, expected, timeout=5.0)`
+
+Polls `isupport get ` over the bridge until it returns `expected`.
+Useful right after `drive_registration(..., isupport_tokens=...)` to
+ensure Eggdrop has finished processing 005 before assertions run.
+
+```python
+wait_for_isupport(tcl_bridge, "PREFIX", "(qaohv)~&@%+")
+```
+
+### `split_member_prefix(token) -> (nick, prefix_symbols)`
+
+Tiny helper used internally by `drive_join_with_names`; exposed for
+test code that needs to do the same parsing. `"@alice"` → `("alice", "@")`,
+`"~&boss"` → `("boss", "~&")`, `"plain"` → `("plain", "")`.
+
+## Fixtures
+
+| Fixture | Scope | What you get |
+| --- | --- | --- |
+| `tmp_eggdir` | function | `Path` to per-test scratch dir (alias of `tmp_path`) |
+| `mock_ircd` | function | Started `MockIrcd` listening on `127.0.0.1:0` |
+| `eggdrop_config` | function | `EggdropConfig` with `.render(**overrides)` to customise the conf |
+| `eggdrop_proc` | function | Spawned `EggdropProc`. `-nt` if test has `@pytest.mark.partyline` |
+| `tcl_bridge` | function | Connected `BridgeClient` ready for `.eval_ok("...")` |
+| `_process_tracker` | session, autouse | Backstop kill of any leaked eggdrop pids |
+
+The fixtures wire to each other: pulling in `tcl_bridge` is enough — it
+depends on `eggdrop_proc` which depends on `eggdrop_config` and
+`mock_ircd`, all of which depend on `tmp_eggdir`.
+
+### Markers
+
+| Marker | Effect |
+| --- | --- |
+| `@pytest.mark.partyline` | `eggdrop_proc` spawns with `-nt`; HQ partyline available on stdin |
+| `@pytest.mark.slow` | Tag for end-to-end / reconnect / timeout-driven tests |
+
+## Customising a test's eggdrop.conf
+
+Render with overrides before the proc starts:
+
+```python
+def test_with_custom_nick(eggdrop_config, eggdrop_proc, mock_ircd, tcl_bridge):
+ eggdrop_config.render(
+ nick="OtherBot",
+ channels=[
+ {"name": "#a", "chanmode": "+nt"},
+ {"name": "#b", "chanmode": "+ntk", "key": "secret"},
+ ],
+ extra_tcl="set my-test-var 42\n",
+ )
+ # ...rest of the test
+```
+
+If you don't call `render()`, the `eggdrop_proc` fixture renders with
+defaults from `EggdropConfig.context()`.
+
+`render()` only takes effect *before* the proc fixture evaluates. If your
+test parameter list pulls in `eggdrop_proc` directly, the proc has already
+spawned by the time the test body runs. To customise after the dataclass
+exists but before the bot starts, take `eggdrop_config` + `request:
+pytest.FixtureRequest`, render, then lazy-load the rest:
+
+```python
+def test_loads_my_userfile(eggdrop_config, request: pytest.FixtureRequest):
+ eggdrop_config.render(...)
+ eggdrop_config.userfile_path.write_text(...) # tweak files post-render
+ proc = request.getfixturevalue("eggdrop_proc")
+ bridge = request.getfixturevalue("tcl_bridge")
+```
+
+### Pre-populating the userfile
+
+Two template variables (rendered by `templates/userfile.j2`) inject
+already-formatted ban-record lines into the userfile that's written
+before the bot starts:
+
+- `userfile_ban_lines` — list of strings, written under `*ban - -`.
+- `userfile_chan_ban_lines` — dict of `chan-name → list of strings`,
+ each list written under `:: bans`. The channel must be
+ configured before the userfile is read; the default eggdrop.conf
+ `channels` list handles this for `#test` automatically (see
+ `chanprog.c:452` for the order: conf load → HOOK_REHASH →
+ `readuserfile`).
+
+Build the strings with `support.userfile_helpers.format_userfile_ban`,
+which takes every field as a required keyword argument (`mask`, `perm`,
+`sticky`, `expire`, `added`, `lastactive`, `creator`, `desc`) and
+hex-escapes `:` / `\\` in the mask per `src/misc.c:str_escape`. The
+template itself is a flat iteration; all formatting lives in Python.
+
+```python
+from support.userfile_helpers import format_userfile_ban
+
+eggdrop_config.render(
+ userfile_ban_lines=[
+ format_userfile_ban(
+ mask="a:storedacct", perm=True, sticky=False, expire=0,
+ added=1700000000, lastactive=0, creator="owner",
+ desc="from disk",
+ ),
+ ],
+ userfile_chan_ban_lines={
+ "#test": [
+ format_userfile_ban(
+ mask="~a:chanonlyacct", perm=True, sticky=False, expire=0,
+ added=1700000000, lastactive=0, creator="owner",
+ desc="per-chan",
+ ),
+ ],
+ },
+)
+```
+
+The chanfile is rendered from `templates/chanfile.j2`. Pass
+`chanfile_channels=[{"name": "#chan", "options": "..."}]` if you need
+to register channels at chanfile-load time (rather than via
+`channels` in the conf). Default is empty — most tests use the
+conf-level `channel add` instead.
+
+### Selecting which modules to load
+
+The `modules` template variable controls the `loadmodule` lines and gates
+the server-related conf block (`set net-type`, `server add`, `set
+msg-rate`, ...) on whether `server` is in the list. Default is the full
+chain needed for IRC behaviour: `["pbkdf2", "channels", "server", "ctcp",
+"irc", "console", "notes"]`.
+
+Override to test channels-mod-only scenarios (e.g. behaviour when
+server.mod is absent — irc.mod and ctcp.mod will fail to load alongside
+since they `module_depend` on server):
+
+```python
+eggdrop_config.render(modules=["pbkdf2", "channels", "console", "notes"])
+```
+
+`extra_modules` still appends in addition to `modules`, so it's the right
+knob for opting into share/transfer/etc. without changing the base list.
+
+## Layout
+
+```
+tests/
+├── pyproject.toml # uv project, pytest+ruff+ty config
+├── README.md # this file
+├── conftest.py # all fixtures + failure-report hook
+├── support/
+│ ├── framing.py # line-delimited \-escaped frame format
+│ ├── test_bridge.tcl # sourced inside Eggdrop, opens TCP listener
+│ ├── bridge_client.py # Python client → eval_ok("...")
+│ ├── mock_ircd.py # asyncio IRCd, sync facade
+│ ├── irc_helpers.py # drive_registration, drive_join_with_names, ...
+│ ├── eggdrop_proc.py # subprocess wrapper, stdout drain, terminate
+│ └── waiters.py # wait_for / wait_for_file / wait_for_log_match
+├── templates/
+│ ├── eggdrop.conf.j2
+│ └── userfile.j2
+└── tests/
+ ├── test_framing.py
+ ├── test_smoke_connect.py
+ ├── test_partyline_chan.py
+ ├── test_isupport_modes.py
+ ├── test_tcl_passwdok.py # ported from eggdrop_tcl_passwdok.bats
+ ├── test_tcl_iscmds.py # ported from eggdrop_tcl_iscmds.bats
+ ├── test_tcl_matchattr.py # ported from eggdrop_tcl_matchattr.bats
+ ├── test_tcl_server.py # ported from eggdrop_tcl_server.bats
+ ├── test_tcl_addbot.py # ported from eggdrop_tcl_addbot.bats
+ └── test_chanset_inputvalidation.py # ported from eggdrop_chanset_inputvalidation.bats
+```
+
+## Bridge wire protocol
+
+Telnet-friendly, one frame per line, `\` `\n` `\r` backslash-escaped:
+
+```
+$ nc 127.0.0.1
+set ::nick
+OK TestBot
+expr 2 + 2
+OK 4
+nosuch
+ERR invalid command name "nosuch"
+```
+
+Request: `\n`. Response: `OK \n` or
+`ERR \n`. The bridge only listens when the spawn env has
+`EGGDROP_TEST=1`, so production Eggdrops sourcing the same config skip it.
+
+## Environment knobs
+
+- `EGGDROP_BIN` — absolute path to the eggdrop binary. Default: `/eggdrop`.
+- `EGGDROP_SRC` — absolute path to the eggdrop source tree (for `mod-path`,
+ `help-path`, `EGG_LANGDIR`). Default: parent of `tests/`.
+
+## Debugging a failing test
+
+- Each test's runtime files survive at `/tmp/pytest-of-/pytest-current//`.
+ pytest preserves the last 3 sessions automatically.
+- Two logs are written:
+ - `eggdrop.log` — Eggdrop's own log (raw IRC `[@]` incoming, `[m->]`/`[s->]`
+ outgoing, plus messages, channels, output, file ops).
+ - `eggdrop.stdout.log` — everything Eggdrop wrote to stdout/stderr.
+- On failure, both are attached to the pytest report.
+- For verbose live output: `uv run pytest -s --log-cli-level=DEBUG path::to::test`.
+- To poke the bridge by hand from a hung test, copy the port out of
+ `/bridge.port` and `nc 127.0.0.1 `.
+
+## Coverage (gcov / lcov)
+
+Eggdrop's `configure` script has an `--enable-coverage` flag (added for
+this test harness, see `aclocal.m4` → `EGG_ENABLE_COVERAGE`). It injects
+`--coverage -fPIC -O0 -ggdb3` into `CFLAGS` and `--coverage` into
+`LDFLAGS`, so the build emits `.gcno` notes alongside every `.o`, and
+each spawned process drops `.gcda` runtime data when it exits.
+
+### Build with coverage
+
+The top-level `make test` target does the full build + run cycle:
+
+```sh
+make test # = distclean → configure --enable-coverage
+ # → make config → make debug → pytest
+```
+
+Or do it by hand if you want to control the steps:
+
+```sh
+make distclean
+./configure --enable-coverage
+make config
+make debug
+```
+
+`config.log` will note `enabling gcov coverage instrumentation: --coverage
+-fPIC -O0 -ggdb3` and `src/Makefile` will have the flags wired into
+`CFLAGS`/`LDFLAGS`. After `make debug`, every `.o` has a `.gcno` next to it
+in the same directory.
+
+### Run the suite to populate `.gcda`
+
+```sh
+cd tests
+uv run pytest # each spawned Eggdrop writes .gcda on exit
+```
+
+`.gcda` files land beside the corresponding `.gcno`/`.o`. One full suite
+run produces ~30+ `.gcda` files across `src/` and `src/mod/*/`. To reset
+between runs (without rebuilding) just delete the data:
+
+```sh
+find . -name '*.gcda' -delete # keeps .gcno (build state) in place
+```
+
+`make clean` removes both `.gcno` and `.gcda` everywhere as a side
+effect; you'd then need to rebuild before the next coverage run.
+
+### Inspect coverage
+
+Per-file:
+
+```sh
+gcov -o src/mod/server.mod src/mod/server.mod/server.c
+# → server.c.gcov; first line reports "Lines executed: NN.NN% of N"
+```
+
+Whole-tree HTML with `lcov`:
+
+```sh
+lcov --capture --directory . --output-file cov.info
+genhtml cov.info --output-directory cov-html
+xdg-open cov-html/index.html
+```
+
+### Notes
+
+- Coverage builds are **not** for production — `-O0` plus instrumentation
+ is slow, and `.gcda` files accumulate in the source tree (until you
+ reset them with `find . -name '*.gcda' -delete` or `make clean`).
+- If you change source files between runs without rebuilding,
+ `.gcda`/`.gcno` go out of sync and `gcov` will refuse the data. Re-run
+ `make debug` after every source change, or delete the stale `.gcda`s.
+- Each Eggdrop process writes its `.gcda`s on clean exit. Tests that kill
+ the bot with `SIGKILL` (only happens after `SIGTERM` times out) will
+ miss data from that process; the harness uses `bridge.eval("die ...")`
+ / `SIGTERM` first so this is rare.
+
+## Why some things are the way they are
+
+- **`mod-path` set before `loadmodule`.** Eggdrop reads `mod-path` at each
+ `loadmodule` call, not lazily, so the template orders them accordingly.
+- **`loadmodule pbkdf2` first.** The userfile read enforces that the
+ encryption module is present (src/main.c:1076).
+- **`EGG_LANGDIR` env var.** Avoids a symlink in every tmpdir; the language
+ files load from the source tree directly.
+- **`set msg-rate 0`.** Combined with eggdrop's one-msg-per-second
+ `HOOK_SECONDLY` dequeue, this keeps tests fast without altering
+ protocol semantics. WHOIS still goes out before JOIN; tests use
+ `mock_ircd.drain_until(lambda l: l.startswith("JOIN "))` rather than
+ `expect_recv_match` to skip past it.
+- **Bridge socket vs `.tcl` over partyline.** The bridge has clean framing
+ and no log interleaving; the partyline is for tests of the partyline UX.
+- **No `time.sleep` in tests.** Use `waiters.wait_for(...)` /
+ `wait_for_file(...)` / `mock_ircd.drain_until(...)` — every wait has an
+ explicit timeout and a description.
+
+## Converted from the legacy `eggdrop-tests/` BATS suite
+
+The 6 Tcl-only `.bats` files from the legacy suite have been ported into
+this framework. The original ran `cmd_accept.tcl` (a precursor to
+`test_bridge.tcl`) on TCP port 45678 and asserted on `nc localhost 45678`
+output — exactly the same shape as `tcl_bridge.eval_ok()`, so the
+mapping is line-for-line.
+
+| Legacy `.bats` file | New file | Notes |
+| --- | --- | --- |
+| `eggdrop_tcl_passwdok.bats` | `tests/test_tcl_passwdok.py` | 6 tests |
+| `eggdrop_tcl_iscmds.bats` | `tests/test_tcl_iscmds.py` | 22 tests (isban / isbansticky / isexempt / isinvite) |
+| `eggdrop_tcl_matchattr.bats` | `tests/test_tcl_matchattr.py` | 23 tests; the 3 "rejects unknown flag" cases now document the new silent-accept behavior since `tcl_matchattr` no longer errors on unknown flags |
+| `eggdrop_tcl_server.bats` | `tests/test_tcl_server.py` | 13 tests; ported from the old `addserver`/`delserver`/`set servers` API to the current `server add` / `server remove` / `server list` |
+| `eggdrop_tcl_addbot.bats` | `tests/test_tcl_addbot.py` | 13 tests; the 8 IPv6 cases auto-skip via an `ipv6_required` fixture when Eggdrop is built without IPv6 |
+| `eggdrop_chanset_inputvalidation.bats` | `tests/test_chanset_inputvalidation.py` | 9 tests for `flood-deop X:Y` parsing |
+
+Files that were **not** converted, with reasons:
+
+- `eggdrop_botnet_linking.bats`, `eggdrop_botnet_partyline.bats` — need
+ multiple bots talking to each other; no multi-bot fixture in this
+ framework yet.
+- `eggdrop_ssl_config.bats`, `eggdrop_ssl_sni.bats` — SSL/TLS not modeled
+ in the mock IRCd.
+- `eggdrop_compile_*.bats` (5 files) — build-system tests, unrelated to
+ runtime behavior. The `make test` target replaces what the
+ `eggdrop_compile_testrun.bats` covered.
+- `eggdrop_partyline_bans.bats`, `eggdrop_partyline_flags.bats`,
+ `eggdrop_console_flags.bats` — drive partyline commands and assert on
+ textual output through a TCP partyline (port 1111/3015 in the legacy
+ conf). Convertible using the existing `@pytest.mark.partyline` marker
+ and `eggdrop_proc.send_partyline()`, but those tests assert on
+ free-form output strings; preferring to land them as state-checking
+ tests via `tcl_bridge` when possible. Deferred.
+
+## What's intentionally out of scope (for now)
+
+- Linking eggdrop as a shared library / refactoring globals out.
+- C-side test hooks; everything stays in Tcl/Python.
+- SSL/TLS to the mock IRCd. Plain TCP only.
+- Botnet (`share`, `transfer` modules), DCC chat over TCP, Python module.
+- Real passwords. The owner in the userfile uses `pass = "-"` (no auth);
+ the bridge bypasses the need for it. Partyline auth tests are deferred
+ until there's a reason for them.
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000..d1d12d9657
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,242 @@
+"""Pytest fixtures for the Eggdrop integration test harness."""
+
+from __future__ import annotations
+
+import contextlib
+import os
+from collections.abc import Generator, Iterator
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import pytest
+from jinja2 import Environment, FileSystemLoader, StrictUndefined
+
+from support.bridge_client import BridgeClient
+from support.eggdrop_proc import EggdropProc
+from support.mock_ircd import MockIrcd
+from support.waiters import wait_for_file
+
+REPO_ROOT = Path(
+ os.environ.get("EGGDROP_SRC", str(Path(__file__).resolve().parents[1]))
+)
+EGGDROP_BIN = Path(os.environ.get("EGGDROP_BIN", str(REPO_ROOT / "eggdrop")))
+TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
+BRIDGE_TCL = Path(__file__).resolve().parent / "support" / "test_bridge.tcl"
+
+_jinja_env = Environment(
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
+ undefined=StrictUndefined,
+ keep_trailing_newline=True,
+)
+
+
+# ---------- session-wide process tracker ----------
+
+
+@pytest.fixture(scope="session", autouse=True)
+def _process_tracker() -> Iterator[list[EggdropProc]]:
+ """Backstop: kill any eggdrop processes a test forgot to clean up."""
+ tracked: list[EggdropProc] = []
+ yield tracked
+ for proc in tracked:
+ with contextlib.suppress(Exception):
+ proc.terminate(timeout=2)
+
+
+# ---------- per-test fixtures ----------
+
+
+@pytest.fixture
+def tmp_eggdir(tmp_path: Path) -> Path:
+ return tmp_path
+
+
+@dataclass
+class EggdropConfig:
+ tmp: Path
+ mock_ircd_port: int
+ overrides: dict[str, Any] = field(default_factory=dict)
+ rendered: bool = False
+ config_path: Path = field(init=False)
+ userfile_path: Path = field(init=False)
+ chanfile_path: Path = field(init=False)
+ pidfile_path: Path = field(init=False)
+ logfile_path: Path = field(init=False)
+
+ def __post_init__(self) -> None:
+ self.config_path = self.tmp / "eggdrop.conf"
+ self.userfile_path = self.tmp / "eggdrop.user"
+ self.chanfile_path = self.tmp / "eggdrop.chan"
+ self.pidfile_path = self.tmp / "eggdrop.pid"
+ self.logfile_path = self.tmp / "eggdrop.log"
+
+ def context(self) -> dict[str, Any]:
+ ctx: dict[str, Any] = dict(
+ nick="TestBot",
+ altnick="Test_?",
+ realname="pytest bot",
+ username="test",
+ admin="pytest ",
+ network="TestNet",
+ botnet_nick="TestBot",
+ owner_handle="owner",
+ owner_flags="nmto",
+ owner_hostmask="*!*@127.0.0.1",
+ mod_path=str(REPO_ROOT) + "/",
+ help_path=str(REPO_ROOT / "help") + "/",
+ bridge_tcl_path=str(BRIDGE_TCL),
+ modules=[
+ "pbkdf2",
+ "channels",
+ "server",
+ "ctcp",
+ "irc",
+ "console",
+ "notes",
+ ],
+ extra_modules=[],
+ channels=[{"name": "#test", "chanmode": "+nt"}],
+ chanfile_channels=[],
+ userfile_ban_lines=[],
+ userfile_chan_ban_lines={},
+ extra_tcl="",
+ server_cycle_wait=10,
+ server_timeout=30,
+ log_flags="mcorvxd",
+ tmpdir=str(self.tmp),
+ userfile_path=str(self.userfile_path),
+ chanfile_path=str(self.chanfile_path),
+ pidfile_path=str(self.pidfile_path),
+ logfile_path=str(self.logfile_path),
+ mock_ircd_port=self.mock_ircd_port,
+ )
+ ctx.update(self.overrides)
+ return ctx
+
+ def render(self, **overrides: Any) -> None:
+ self.overrides.update(overrides)
+ ctx = self.context()
+ self.config_path.write_text(
+ _jinja_env.get_template("eggdrop.conf.j2").render(**ctx)
+ )
+ self.userfile_path.write_text(
+ _jinja_env.get_template("userfile.j2").render(**ctx)
+ )
+ # channels.mod wants to fopen the chanfile read-write; rendering
+ # an empty `chanfile_channels` list produces an empty file, same
+ # as touching it would.
+ self.chanfile_path.write_text(
+ _jinja_env.get_template("chanfile.j2").render(**ctx)
+ )
+ self.rendered = True
+
+
+@pytest.fixture
+def mock_ircd() -> Iterator[MockIrcd]:
+ ircd = MockIrcd().start()
+ try:
+ yield ircd
+ finally:
+ with contextlib.suppress(Exception):
+ ircd.stop()
+
+
+@pytest.fixture
+def eggdrop_config(tmp_eggdir: Path, mock_ircd: MockIrcd) -> EggdropConfig:
+ return EggdropConfig(tmp=tmp_eggdir, mock_ircd_port=mock_ircd.port)
+
+
+def _request_clean_shutdown(port_file: Path) -> None:
+ """Open a fresh bridge connection and send Tcl `die` so Eggdrop exits via
+ its own atexit path (writes userfile/chanfile, runs gcov atexit, etc.).
+
+ The bridge connection drops as a side effect; that's fine — we just need
+ the request to land. All errors are swallowed; this is best-effort.
+ """
+ if not port_file.exists():
+ return
+ with contextlib.suppress(Exception):
+ port = int(port_file.read_text().strip())
+ with BridgeClient("127.0.0.1", port, timeout=2.0) as client:
+ client.eval("die test cleanup", timeout=2.0)
+
+
+@pytest.fixture
+def eggdrop_proc(
+ eggdrop_config: EggdropConfig,
+ tmp_eggdir: Path,
+ _process_tracker: list[EggdropProc],
+ request: pytest.FixtureRequest,
+) -> Iterator[EggdropProc]:
+ if not eggdrop_config.rendered:
+ eggdrop_config.render()
+ if not EGGDROP_BIN.exists():
+ pytest.skip(f"eggdrop binary not found at {EGGDROP_BIN}")
+ port_file = tmp_eggdir / "bridge.port"
+ terminal = request.node.get_closest_marker("partyline") is not None
+ proc = EggdropProc(
+ binary=EGGDROP_BIN,
+ config_path=eggdrop_config.config_path,
+ cwd=tmp_eggdir,
+ env={
+ "EGGDROP_TEST": "1",
+ "EGGDROP_TEST_PORT_FILE": str(port_file),
+ "EGG_LANGDIR": str(REPO_ROOT / "language"),
+ },
+ terminal=terminal,
+ )
+ proc.start()
+ _process_tracker.append(proc)
+ try:
+ yield proc
+ finally:
+ # Prefer a clean Tcl `die` so atexit handlers (incl. gcov) run; fall
+ # back to SIGTERM with a generous timeout for slow CI disks where the
+ # gcov .gcda dump on exit can take several seconds.
+ _request_clean_shutdown(port_file)
+ rc = proc.terminate(timeout=30)
+ # On failure, attach the eggdrop log to the report.
+ rep = getattr(request.node, "rep_call", None)
+ if rep is not None and rep.failed:
+ try:
+ log = proc.log_path.read_text()
+ except OSError:
+ log = "(log unavailable)"
+ request.node.add_report_section(
+ "call",
+ "eggdrop stdout",
+ f"exit={rc}\nlog at {proc.log_path}\n--- log ---\n{log}",
+ )
+
+
+@pytest.fixture
+def tcl_bridge(
+ eggdrop_proc: EggdropProc, tmp_eggdir: Path
+) -> Iterator[BridgeClient]:
+ port_file = tmp_eggdir / "bridge.port"
+ try:
+ wait_for_file(port_file, timeout=10.0)
+ except Exception:
+ eggdrop_proc.assert_alive()
+ raise
+ port = int(port_file.read_text().strip())
+ client = BridgeClient.connect_with_retry(
+ "127.0.0.1", port, total_timeout=5.0
+ )
+ try:
+ yield client
+ finally:
+ client.close()
+
+
+# ---------- failure-report attachment hook ----------
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_makereport(
+ item: pytest.Item, call: pytest.CallInfo[None]
+) -> Generator[None, Any, None]:
+ outcome = yield
+ rep = outcome.get_result()
+ setattr(item, f"rep_{rep.when}", rep)
diff --git a/tests/pyproject.toml b/tests/pyproject.toml
new file mode 100644
index 0000000000..b12541ffb2
--- /dev/null
+++ b/tests/pyproject.toml
@@ -0,0 +1,52 @@
+[project]
+name = "eggdrop-tests"
+version = "0.0.0"
+description = "Integration test harness for Eggdrop"
+requires-python = ">=3.11"
+dependencies = []
+
+[dependency-groups]
+dev = [
+ "pytest>=8",
+ "pytest-timeout>=2.3",
+ "jinja2>=3.1",
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+addopts = "-ra -q --strict-markers --timeout=60 --tb=short"
+markers = [
+ "slow: end-to-end tests that exercise reconnect/timeouts",
+ "partyline: spawns eggdrop with -nt so stdin is the HQ partyline",
+]
+# pytest sweeps it on session exit.
+tmp_path_retention_policy = "all"
+tmp_path_retention_count = 3
+
+[tool.ruff]
+target-version = "py311"
+src = ["."]
+
+[tool.ruff.lint]
+select = [
+ "E", "F", "W", # pycodestyle + pyflakes
+ "I", # isort
+ "B", # bugbear
+ "UP", # pyupgrade
+ "SIM", # simplifications
+ "RET", # return statements
+ "PIE", # misc
+ "PT", # pytest style
+ "RUF", # ruff-specific
+]
+ignore = [
+ "E501", # line length — let formatter handle
+ "PT011", # pytest.raises(Exception) — fine here
+]
+
+[tool.ty.src]
+include = ["support", "tests", "conftest.py"]
+
+[tool.ty.rules]
+# All on by default; bump anything we want stricter here.
diff --git a/tests/support/__init__.py b/tests/support/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/support/bridge_client.py b/tests/support/bridge_client.py
new file mode 100644
index 0000000000..86e117be1a
--- /dev/null
+++ b/tests/support/bridge_client.py
@@ -0,0 +1,100 @@
+"""TCP client for the test_bridge.tcl listener inside Eggdrop."""
+
+from __future__ import annotations
+
+import contextlib
+import socket
+import time
+
+from .framing import encode_request, parse_response
+
+
+class BridgeError(Exception):
+ """Tcl evaluation returned an ERR frame."""
+
+
+class BridgeTimeout(Exception):
+ """Read from the bridge timed out before a frame arrived."""
+
+
+class BridgeClient:
+ """Synchronous client. One connection, one outstanding request at a time.
+
+ Eggdrop's bridge serves frames in order, so this design is sufficient for
+ test code that runs sequentially.
+ """
+
+ def __init__(self, host: str, port: int, timeout: float = 5.0) -> None:
+ """Open a TCP connection to the bridge. Raises `OSError` if it can't connect."""
+ self._host = host
+ self._port = port
+ self._sock = socket.create_connection((host, port), timeout=timeout)
+ self._sock.settimeout(timeout)
+ self._buf = bytearray()
+
+ @classmethod
+ def connect_with_retry(
+ cls, host: str, port: int, total_timeout: float = 5.0
+ ) -> BridgeClient:
+ """Retry the connect every 50 ms until `total_timeout` elapses.
+
+ Used by the `tcl_bridge` fixture to wait through Eggdrop's startup
+ between writing the port file and accepting on it.
+ """
+ deadline = time.monotonic() + total_timeout
+ last: Exception | None = None
+ while time.monotonic() < deadline:
+ try:
+ return cls(host, port)
+ except OSError as e:
+ last = e
+ time.sleep(0.05)
+ raise BridgeTimeout(
+ f"could not connect to bridge at {host}:{port} "
+ f"within {total_timeout}s: {last}"
+ )
+
+ def close(self) -> None:
+ """Close the TCP connection. Idempotent; safe to call on a closed socket."""
+ with contextlib.suppress(OSError):
+ self._sock.close()
+
+ def __enter__(self) -> BridgeClient:
+ return self
+
+ def __exit__(self, *_: object) -> None:
+ self.close()
+
+ def eval(self, cmd: str, timeout: float = 5.0) -> tuple[bool, str]:
+ """Evaluate a Tcl command. Returns (ok, result_text)."""
+ self._sock.sendall(encode_request(cmd))
+ deadline = time.monotonic() + timeout
+ while b"\n" not in self._buf:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise BridgeTimeout(f"no response to {cmd!r} within {timeout}s")
+ self._sock.settimeout(remaining)
+ try:
+ chunk = self._sock.recv(4096)
+ except TimeoutError as e:
+ raise BridgeTimeout(
+ f"no response to {cmd!r} within {timeout}s"
+ ) from e
+ if not chunk:
+ raise BridgeError(
+ f"bridge closed connection while waiting for "
+ f"response to {cmd!r}"
+ )
+ self._buf.extend(chunk)
+ nl = self._buf.index(b"\n")
+ line = bytes(self._buf[:nl]).decode("utf-8", "replace")
+ del self._buf[: nl + 1]
+ tag, payload = parse_response(line)
+ return tag == "OK", payload
+
+ def eval_ok(self, cmd: str, timeout: float = 5.0) -> str:
+ """Evaluate a Tcl command and return its result, raising on ERR."""
+ ok, result = self.eval(cmd, timeout=timeout)
+ if not ok:
+ raise BridgeError(f"Tcl error evaluating {cmd!r}: {result}")
+ return result
diff --git a/tests/support/eggdrop_proc.py b/tests/support/eggdrop_proc.py
new file mode 100644
index 0000000000..b76618fbd8
--- /dev/null
+++ b/tests/support/eggdrop_proc.py
@@ -0,0 +1,162 @@
+"""Subprocess wrapper for a spawned Eggdrop instance."""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import os
+import re
+import signal
+import subprocess
+import threading
+from pathlib import Path
+from typing import IO
+
+from .waiters import WaitTimeout, wait_for_log_match
+
+
+class EggdropDiedError(Exception):
+ """Raised when an operation is attempted on an Eggdrop that has exited."""
+
+
+class EggdropProc:
+ """Spawned Eggdrop subprocess with a captured stdout drain.
+
+ `start()` spawns the bot; `terminate()` ends it (SIGTERM, then SIGKILL
+ after the grace period). Stdout/stderr are streamed into both an
+ in-memory ring (`stdout_text()`) and an on-disk log (`log_path`) so
+ failures can be inspected post-mortem.
+ """
+
+ def __init__(
+ self,
+ binary: Path,
+ config_path: Path,
+ cwd: Path,
+ env: dict[str, str] | None = None,
+ log_path: Path | None = None,
+ terminal: bool = False,
+ ) -> None:
+ """Configure (but do not start) a wrapped Eggdrop process.
+
+ `terminal=True` spawns with `-nt` (HQ partyline on stdin, owner
+ perms auto-granted); `False` uses `-n` (foreground only, no
+ partyline). `env` is merged on top of the parent process env.
+ """
+ self._binary = binary
+ self._config = config_path
+ self._cwd = cwd
+ self._env = {**os.environ, **(env or {})}
+ self._log_path = log_path or (cwd / "eggdrop.stdout.log")
+ self._terminal = terminal
+ self._buf = io.StringIO()
+ self._buf_lock = threading.Lock()
+ self._proc: subprocess.Popen[bytes] | None = None
+ self._drain_thread: threading.Thread | None = None
+ self._log_fp: IO[str] | None = None
+
+ def start(self) -> EggdropProc:
+ """Spawn Eggdrop and start the background stdout-drain thread."""
+ # Lifetime spans process; closed in terminate().
+ self._log_fp = open(self._log_path, "w", encoding="utf-8") # noqa: SIM115
+ flags = "-nt" if self._terminal else "-n"
+ self._proc = subprocess.Popen(
+ [str(self._binary), flags, str(self._config)],
+ cwd=str(self._cwd),
+ env=self._env,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ bufsize=0,
+ )
+ self._drain_thread = threading.Thread(
+ target=self._drain, name="eggdrop-stdout-drain", daemon=True
+ )
+ self._drain_thread.start()
+ return self
+
+ def _drain(self) -> None:
+ # Bind to locals so the iter() closure has non-Optional types.
+ proc = self._proc
+ assert proc is not None
+ assert proc.stdout is not None
+ stdout: IO[bytes] = proc.stdout
+ log_fp = self._log_fp
+ for chunk in iter(lambda: stdout.read(4096), b""):
+ text = chunk.decode("utf-8", "replace")
+ with self._buf_lock:
+ self._buf.write(text)
+ if log_fp is not None:
+ log_fp.write(text)
+ log_fp.flush()
+
+ @property
+ def proc(self) -> subprocess.Popen[bytes]:
+ assert self._proc is not None, "eggdrop not started"
+ return self._proc
+
+ @property
+ def pid(self) -> int:
+ return self.proc.pid
+
+ @property
+ def returncode(self) -> int | None:
+ return self.proc.poll()
+
+ @property
+ def log_path(self) -> Path:
+ return self._log_path
+
+ def stdout_text(self) -> str:
+ """Snapshot of everything written to stdout/stderr so far. Thread-safe."""
+ with self._buf_lock:
+ return self._buf.getvalue()
+
+ def assert_alive(self) -> None:
+ """Raise `EggdropDiedError` if the process has exited."""
+ rc = self.proc.poll()
+ if rc is not None:
+ raise EggdropDiedError(
+ f"eggdrop exited with rc={rc}\n--- log ---\n{self.stdout_text()}"
+ )
+
+ def wait_for_log(self, pattern: str, timeout: float = 10.0) -> re.Match[str]:
+ """Poll stdout for `pattern`. If it never appears, surface a useful error.
+
+ On timeout, `assert_alive()` is called first so a dead Eggdrop
+ produces an `EggdropDiedError` (with the captured log) rather than
+ a generic `WaitTimeout`.
+ """
+ try:
+ return wait_for_log_match(self.stdout_text, pattern, timeout=timeout)
+ except WaitTimeout:
+ self.assert_alive()
+ raise
+
+ def send_partyline(self, line: str) -> None:
+ """Write a line to Eggdrop's stdin (only useful with -t/HQ partyline)."""
+ proc = self._proc
+ assert proc is not None
+ assert proc.stdin is not None
+ proc.stdin.write((line.rstrip("\n") + "\n").encode("utf-8"))
+ proc.stdin.flush()
+
+ def terminate(self, timeout: float = 5.0) -> int:
+ """SIGTERM, then SIGKILL after timeout. Returns exit code."""
+ proc = self._proc
+ if proc is None:
+ return 0
+ if proc.poll() is None:
+ with contextlib.suppress(ProcessLookupError):
+ proc.send_signal(signal.SIGTERM)
+ try:
+ proc.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait(timeout=2)
+ if self._drain_thread is not None:
+ self._drain_thread.join(timeout=2)
+ if self._log_fp is not None:
+ self._log_fp.close()
+ self._log_fp = None
+ return proc.returncode
diff --git a/tests/support/framing.py b/tests/support/framing.py
new file mode 100644
index 0000000000..e0642945d2
--- /dev/null
+++ b/tests/support/framing.py
@@ -0,0 +1,75 @@
+r"""Line-delimited framing for the test bridge.
+
+Wire format (one line per frame, terminated by `\n`):
+
+ request: \n
+ response: OK \n (success)
+ response: ERR \n (Tcl error)
+
+Backslash, newline, and CR are backslash-escaped in the payload so the frame
+is always exactly one line. The escape character set is closed: every `\`
+in an encoded payload is the start of a `\\`, `\n`, or `\r` sequence.
+
+Telnet-friendly: you can `nc 127.0.0.1 ` and type commands by hand.
+"""
+
+from __future__ import annotations
+
+
+class ProtocolError(Exception):
+ """Raised on a malformed frame (bad escape sequence or framing)."""
+
+
+def escape(s: str) -> str:
+ """Escape `\\`, `\\n`, `\\r` so the result fits on a single wire line."""
+ return (
+ s.replace("\\", "\\\\")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ )
+
+
+def unescape(s: str) -> str:
+ """Inverse of `escape`. Raises `ProtocolError` on dangling/unknown escapes."""
+ out: list[str] = []
+ i = 0
+ n = len(s)
+ while i < n:
+ c = s[i]
+ if c != "\\":
+ out.append(c)
+ i += 1
+ continue
+ if i + 1 >= n:
+ raise ProtocolError("dangling backslash at end of payload")
+ nxt = s[i + 1]
+ if nxt == "\\":
+ out.append("\\")
+ elif nxt == "n":
+ out.append("\n")
+ elif nxt == "r":
+ out.append("\r")
+ else:
+ raise ProtocolError(f"unknown escape sequence: \\{nxt}")
+ i += 2
+ return "".join(out)
+
+
+def encode_request(cmd: str) -> bytes:
+ """Frame a Tcl command for transmission to the bridge listener."""
+ return (escape(cmd) + "\n").encode("utf-8")
+
+
+def encode_response(tag: str, payload: str) -> bytes:
+ """Frame an `OK`/`ERR` reply (used by the Tcl side; tests use the bridge for input)."""
+ return (tag + " " + escape(payload) + "\n").encode("utf-8")
+
+
+def parse_response(line: str) -> tuple[str, str]:
+ """Parse one response line into (tag, payload). Strips trailing newline."""
+ line = line.rstrip("\n").rstrip("\r")
+ tag, sep, payload = line.partition(" ")
+ if not sep and tag in ("OK", "ERR"):
+ # tag with empty payload (no separator, no escaped content)
+ return tag, ""
+ return tag, unescape(payload)
diff --git a/tests/support/irc_helpers.py b/tests/support/irc_helpers.py
new file mode 100644
index 0000000000..66cc5a5e54
--- /dev/null
+++ b/tests/support/irc_helpers.py
@@ -0,0 +1,158 @@
+"""Shared IRC-side test helpers.
+
+These wrap common multi-step interactions with the mock IRCd so individual
+tests don't repeat boilerplate. Designed to be importable from any test:
+
+ from support.irc_helpers import (
+ drive_registration,
+ drive_join_with_names,
+ wait_for_isupport,
+ )
+"""
+
+from __future__ import annotations
+
+import time
+
+from .bridge_client import BridgeClient
+from .mock_ircd import MockIrcd, MockIrcdError
+from .waiters import wait_for
+
+PREFIX_SYMBOLS = "~&@%+"
+
+
+def split_member_prefix(token: str) -> tuple[str, str]:
+ """Split a NAMES token into (nick, prefix_symbols).
+
+ >>> split_member_prefix("@alice")
+ ('alice', '@')
+ >>> split_member_prefix("~&boss")
+ ('boss', '~&')
+ >>> split_member_prefix("plain")
+ ('plain', '')
+ """
+ i = 0
+ while i < len(token) and token[i] in PREFIX_SYMBOLS:
+ i += 1
+ return token[i:], token[:i]
+
+
+def drive_registration(
+ mock_ircd: MockIrcd,
+ nick: str = "TestBot",
+ isupport_tokens: list[str] | None = None,
+) -> None:
+ """Drive Eggdrop through IRC registration.
+
+ Waits for the TCP connect, drains the bot's NICK + USER, then sends the
+ welcome sequence (001-004 + optional 005 with `isupport_tokens` + 376).
+ Returns once the welcome is on the wire.
+
+ To influence which IRCv3 caps the bot negotiates, construct the IRCd with
+ `MockIrcd(advertised_caps=[...])` (typically via a local `mock_ircd`
+ fixture override). The cap list has to be set at construction time
+ because the bot sends `CAP LS 302` immediately on TCP connect — before
+ this helper runs.
+ """
+ mock_ircd.wait_for_connect(timeout=10.0)
+ for _ in range(2): # NICK and USER
+ mock_ircd.recv(timeout=5.0)
+ mock_ircd.send_welcome(nick=nick, isupport=isupport_tokens)
+
+
+def drive_join_with_names(
+ mock_ircd: MockIrcd,
+ members_with_prefix: str,
+ nick: str = "TestBot",
+ server: str = "mock.test",
+ member_accounts: dict[str, str] | None = None,
+) -> str:
+ """Drive a realistic post-registration channel JOIN to completion.
+
+ Sequence:
+ 1. wait for the bot's `JOIN #chan`
+ 2. echo `:nick!u@h JOIN :#chan` back (this populates `chan->name`)
+ 3. send NAMES (353) with `members_with_prefix` + end (366)
+ 4. drain the bot's post-join queries:
+ * `MODE #chan +b/+e/+I` → empty 368/349/347 end-of-list replies
+ * `WHO #chan ...` → if the bot sent a WHOX-style request (the
+ `c%chnufat,222` form, used when WHOX ISUPPORT is on), reply with
+ one 354 per member carrying the per-member account from
+ `member_accounts` (default "*" = not logged in). Otherwise reply
+ with one 352 per member. Either form ends with 315.
+ 5. leave `MODE #chan` (no list flag) unanswered so tests can send
+ their own 324 reply
+
+ `member_accounts` maps member nick → account name; nicks not in the dict
+ get "*". Only consulted on the WHOX path; ignored for plain WHO.
+ Returns the channel name. Quiesces when no new lines arrive for ~300 ms
+ or after a 5 s hard cap.
+ """
+ join_line = mock_ircd.drain_until(
+ lambda line: line.startswith("JOIN "), timeout=10.0
+ )[-1]
+ chan = join_line.split()[1]
+ mock_ircd.send(f":{nick}!u@h JOIN :{chan}")
+ mock_ircd.send(f":{server} 353 {nick} = {chan} :{members_with_prefix}")
+ mock_ircd.send(f":{server} 366 {nick} {chan} :End of /NAMES list.")
+
+ members: list[tuple[str, str]] = [
+ split_member_prefix(t) for t in members_with_prefix.split() if t
+ ]
+ accounts = member_accounts or {}
+
+ def reply_who(whox: bool) -> None:
+ for member_nick, prefix_syms in members:
+ ident = "u"
+ host = "h.example.com"
+ flags = "H" + prefix_syms # H = here (not away)
+ if whox:
+ # 354 format from chan.c:got354:
+ # ": 354 222 "
+ acct = accounts.get(member_nick, "*")
+ mock_ircd.send(
+ f":{server} 354 {nick} 222 {chan} {ident} {host} "
+ f"{member_nick} {flags} {acct}"
+ )
+ else:
+ mock_ircd.send(
+ f":{server} 352 {nick} {chan} {ident} {host} {server} "
+ f"{member_nick} {flags} :0 {member_nick}"
+ )
+ mock_ircd.send(f":{server} 315 {nick} {chan} :End of /WHO list.")
+
+ deadline = time.monotonic() + 5.0
+ idle = 0.3
+ while time.monotonic() < deadline:
+ try:
+ line = mock_ircd.recv(timeout=idle)
+ except MockIrcdError:
+ return chan # quiesced
+ if line.startswith(f"MODE {chan} +b"):
+ mock_ircd.send(f":{server} 368 {nick} {chan} :End of Channel Ban List")
+ elif line.startswith(f"MODE {chan} +e"):
+ mock_ircd.send(
+ f":{server} 349 {nick} {chan} :End of Channel Exception List"
+ )
+ elif line.startswith(f"MODE {chan} +I"):
+ mock_ircd.send(
+ f":{server} 347 {nick} {chan} :End of Channel Invite List"
+ )
+ elif line.startswith(f"WHO {chan}"):
+ # WHOX form is `WHO #chan c%chnufat,222`; eggdrop emits this when
+ # use_354 (WHOX ISUPPORT) is on and parses replies from got354.
+ reply_who(whox=",222" in line)
+ # MODE #chan (no list flag) — left for the test to answer with 324.
+ # Anything else is silently drained.
+ return chan
+
+
+def wait_for_isupport(
+ bridge: BridgeClient, key: str, expected: str, timeout: float = 5.0
+) -> None:
+ """Block until `isupport get ` returns `expected`."""
+ wait_for(
+ lambda: bridge.eval_ok(f"isupport get {key}") == expected,
+ timeout=timeout,
+ description=f"isupport {key}={expected!r}",
+ )
diff --git a/tests/support/mock_ircd.py b/tests/support/mock_ircd.py
new file mode 100644
index 0000000000..13f660fbb3
--- /dev/null
+++ b/tests/support/mock_ircd.py
@@ -0,0 +1,295 @@
+"""Minimal mock IRCd for Eggdrop integration tests.
+
+asyncio TCP server hosted on its own thread + private event loop.
+Tests interact via a synchronous facade: `recv()`, `send()`, `expect_recv_match()`,
+`send_welcome()`. The mock auto-replies to PING; everything else lands in a
+queue for tests to consume and assert on.
+
+Default behaviour is one client connection per `MockIrcd` instance. Tests that
+exercise reconnect logic should pass `allow_reconnect=True`.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import re
+import threading
+import time
+from collections.abc import Callable, Coroutine, Iterable
+from concurrent.futures import Future
+from typing import Any, TypeVar
+
+_T = TypeVar("_T")
+
+
+class MockIrcdError(Exception):
+ """Raised on a timeout or other harness-side error against the mock IRCd."""
+
+
+class UnexpectedReconnect(MockIrcdError):
+ """Raised at `stop()` if the bot reconnected when `allow_reconnect=False`."""
+
+
+class MockIrcd:
+ """Synchronous-facade mock IRCd, listening on `127.0.0.1:0`.
+
+ Internally an asyncio TCP server runs on a private event loop in a
+ background thread. The public API is plain blocking calls (`recv()`,
+ `send()`, `expect_recv_match()`, ...) so test code stays linear.
+
+ Auto-handles client `PING` and (by default) the `CAP LS`/`REQ`/`END`
+ handshake so individual tests don't have to repeat that boilerplate.
+ Pass `auto_cap=False` to drive CAP negotiation explicitly.
+ """
+
+ def __init__(
+ self,
+ allow_reconnect: bool = False,
+ auto_cap: bool = True,
+ server_name: str = "mock.test",
+ advertised_caps: Iterable[str] | None = None,
+ ) -> None:
+ """Configure (but do not start) a mock IRCd.
+
+ `allow_reconnect`: if False, a second client connection during the
+ test is treated as a hard error at `stop()` time.
+ `auto_cap`: auto-respond to `CAP LS`/`REQ`/`LIST`. `CAP REQ` is
+ always ACKed for whatever the bot asks for.
+ `server_name`: source prefix for synthetic numerics (`:server 001 ...`).
+ `advertised_caps`: caps offered in `CAP LS` replies. Default empty.
+ Tests that need specific caps construct their own MockIrcd
+ (typically via a local `mock_ircd` fixture override) — by the
+ time the test body runs the bot has already sent `CAP LS 302`,
+ so the cap list must be set at construction time.
+ """
+ self._allow_reconnect = allow_reconnect
+ self._auto_cap = auto_cap
+ self._server_name = server_name
+ self._advertised_caps = " ".join(advertised_caps or [])
+ self._loop = asyncio.new_event_loop()
+ self._thread = threading.Thread(
+ target=self._loop.run_forever, name="MockIrcd", daemon=True
+ )
+ self._server: asyncio.base_events.Server | None = None
+ self._writer: asyncio.StreamWriter | None = None
+ self._recv_q: asyncio.Queue[str] | None = None
+ self._connect_evt: asyncio.Event | None = None
+ self._connect_count = 0
+ self._unexpected_reconnect = False
+ self.port: int = 0
+
+ # ---------- lifecycle ----------
+
+ def start(self) -> MockIrcd:
+ """Start the asyncio loop thread and bind the listener. Sets `self.port`."""
+ self._thread.start()
+ self._submit(self._async_start()).result(timeout=5)
+ return self
+
+ def stop(self) -> None:
+ """Close the listener, stop the loop thread, and assert no rogue reconnects.
+
+ Raises `UnexpectedReconnect` if the client connected more than once
+ without `allow_reconnect=True`.
+ """
+ if self._loop.is_running():
+ with contextlib.suppress(Exception):
+ self._submit(self._async_stop()).result(timeout=5)
+ self._loop.call_soon_threadsafe(self._loop.stop)
+ self._thread.join(timeout=5)
+ if self._unexpected_reconnect and not self._allow_reconnect:
+ raise UnexpectedReconnect(
+ f"client connected {self._connect_count} times "
+ f"(allow_reconnect=False)"
+ )
+
+ def __enter__(self) -> MockIrcd:
+ return self.start()
+
+ def __exit__(self, *_: object) -> None:
+ self.stop()
+
+ async def _async_start(self) -> None:
+ self._recv_q = asyncio.Queue()
+ self._connect_evt = asyncio.Event()
+ self._server = await asyncio.start_server(
+ self._on_client, "127.0.0.1", 0
+ )
+ self.port = self._server.sockets[0].getsockname()[1]
+
+ async def _async_stop(self) -> None:
+ if self._writer is not None:
+ self._writer.close()
+ # wait_closed can hang if the peer is gone (FIN never ACKed).
+ # Bound it tightly — we're tearing down the loop anyway.
+ with contextlib.suppress(Exception):
+ await asyncio.wait_for(self._writer.wait_closed(), timeout=0.5)
+ if self._server is not None:
+ self._server.close()
+ with contextlib.suppress(Exception):
+ await asyncio.wait_for(self._server.wait_closed(), timeout=0.5)
+
+ # ---------- client handler ----------
+
+ async def _on_client(
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
+ ) -> None:
+ self._connect_count += 1
+ if self._connect_count > 1 and not self._allow_reconnect:
+ self._unexpected_reconnect = True
+ writer.close()
+ return
+ self._writer = writer
+ assert self._connect_evt is not None
+ self._connect_evt.set()
+ try:
+ while True:
+ line = await reader.readline()
+ if not line:
+ break
+ text = line.rstrip(b"\r\n").decode("utf-8", "replace")
+ upper = text.upper()
+ if upper.startswith("PING"):
+ pong = "PONG" + text[4:] + "\r\n"
+ writer.write(pong.encode())
+ await writer.drain()
+ elif self._auto_cap and upper.startswith("CAP "):
+ self._handle_cap(text, writer)
+ await writer.drain()
+ else:
+ assert self._recv_q is not None
+ await self._recv_q.put(text)
+ finally:
+ self._writer = None
+
+ def _handle_cap(self, text: str, writer: asyncio.StreamWriter) -> None:
+ """Auto-respond to client CAP commands so tests don't have to.
+
+ Replies to `CAP LS` with the caps the IRCd was constructed with
+ (default empty), and ACKs whatever the bot then asks for in
+ `CAP REQ`.
+ """
+ parts = text.split(maxsplit=2)
+ sub = parts[1].upper() if len(parts) >= 2 else ""
+ srv = self._server_name
+ # Use "*" as the unregistered nick placeholder per RFC.
+ if sub == "LS":
+ writer.write(f":{srv} CAP * LS :{self._advertised_caps}\r\n".encode())
+ elif sub == "REQ":
+ cap = parts[2] if len(parts) >= 3 else ":"
+ if cap.startswith(":"):
+ cap = cap[1:]
+ writer.write(f":{srv} CAP * ACK :{cap}\r\n".encode())
+ elif sub == "END":
+ pass # no response needed
+ elif sub == "LIST":
+ writer.write(f":{srv} CAP * LIST :\r\n".encode())
+
+ # ---------- synchronous facade ----------
+
+ def _submit(self, coro: Coroutine[Any, Any, _T]) -> Future[_T]:
+ """Schedule a coroutine on the bg loop, return a thread-safe Future."""
+ return asyncio.run_coroutine_threadsafe(coro, self._loop)
+
+ def wait_for_connect(self, timeout: float = 10.0) -> None:
+ """Block until the first client (Eggdrop) opens the TCP connection."""
+ async def _wait() -> None:
+ assert self._connect_evt is not None
+ await asyncio.wait_for(self._connect_evt.wait(), timeout)
+
+ self._submit(_wait()).result(timeout=timeout + 1)
+
+ def recv(self, timeout: float = 5.0) -> str:
+ """Pop the next non-PING/CAP line from the client's recv queue.
+
+ Raises `MockIrcdError` if no line arrives within `timeout`.
+ """
+ async def _recv() -> str:
+ assert self._recv_q is not None
+ return await asyncio.wait_for(self._recv_q.get(), timeout)
+
+ try:
+ return self._submit(_recv()).result(timeout=timeout + 1)
+ except TimeoutError as e:
+ raise MockIrcdError(
+ f"no IRC line received within {timeout}s"
+ ) from e
+
+ def expect_recv(self, expected: str, timeout: float = 5.0) -> str:
+ """`recv()` and assert the line equals `expected` exactly."""
+ line = self.recv(timeout)
+ if line != expected:
+ raise AssertionError(
+ f"expected IRC line {expected!r}, got {line!r}"
+ )
+ return line
+
+ def expect_recv_match(self, pattern: str, timeout: float = 5.0) -> re.Match[str]:
+ """`recv()` and assert the line matches `pattern` (re.match anchored)."""
+ line = self.recv(timeout)
+ m = re.match(pattern, line)
+ if not m:
+ raise AssertionError(
+ f"expected IRC line matching /{pattern}/, got {line!r}"
+ )
+ return m
+
+ def drain_until(
+ self, predicate: Callable[[str], bool], timeout: float = 5.0
+ ) -> list[str]:
+ """Read lines until `predicate(line)` is True. Returns the lines read
+ (including the matching one). Useful for skipping registration noise."""
+ deadline = time.monotonic() + timeout
+ seen: list[str] = []
+ while time.monotonic() < deadline:
+ remaining = max(0.05, deadline - time.monotonic())
+ line = self.recv(remaining)
+ seen.append(line)
+ if predicate(line):
+ return seen
+ raise MockIrcdError(
+ f"predicate not satisfied within {timeout}s; saw {len(seen)} lines"
+ )
+
+ def send(self, line: str) -> None:
+ """Push a single IRC line (CRLF appended automatically) to the client."""
+ async def _send() -> None:
+ assert self._writer is not None, "no client connected"
+ data = (line.rstrip("\r\n") + "\r\n").encode()
+ self._writer.write(data)
+ await self._writer.drain()
+
+ self._submit(_send()).result(timeout=5)
+
+ def send_from(self, prefix: str, rest: str) -> None:
+ """Convenience: `send(": ")`. Use for messages from other users."""
+ self.send(f":{prefix} {rest}")
+
+ def send_welcome(
+ self,
+ nick: str,
+ server: str = "mock.test",
+ isupport: Iterable[str] | None = None,
+ ) -> None:
+ """Send the registration response: 001-004, optional 005, then 376.
+
+ `isupport` is a list of raw `KEY=VALUE` (or bare `KEY`) tokens
+ joined into the 005 line; pass `None` to skip the 005 entirely.
+ """
+ self.send(f":{server} 001 {nick} :Welcome to mock {nick}")
+ self.send(f":{server} 002 {nick} :Your host is {server}")
+ self.send(f":{server} 003 {nick} :This server was created today")
+ self.send(f":{server} 004 {nick} {server} mock-1.0 oiwsx ovimnpst")
+ if isupport:
+ tokens = " ".join(isupport)
+ self.send(f":{server} 005 {nick} {tokens} :are supported")
+ self.send(f":{server} 376 {nick} :End of MOTD")
+
+ def send_names(
+ self, nick: str, channel: str, members: Iterable[str], server: str = "mock.test"
+ ) -> None:
+ """Send a 353 NAMES line followed by the 366 end-of-list."""
+ names = " ".join(members)
+ self.send(f":{server} 353 {nick} = {channel} :{names}")
+ self.send(f":{server} 366 {nick} {channel} :End of /NAMES")
diff --git a/tests/support/test_bridge.tcl b/tests/support/test_bridge.tcl
new file mode 100644
index 0000000000..2fe051e185
--- /dev/null
+++ b/tests/support/test_bridge.tcl
@@ -0,0 +1,70 @@
+# Eggdrop integration-test bridge.
+#
+# Sourced from a rendered eggdrop.conf when EGGDROP_TEST is set in the
+# environment. Binds a TCP listener on 127.0.0.1:0, writes the OS-assigned
+# port to $env(EGGDROP_TEST_PORT_FILE), and accepts line-delimited Tcl
+# commands. Each command is evaluated in the global interpreter; the result
+# is returned as a single line tagged "OK" or "ERR".
+#
+# Wire format (one line per frame, \n-terminated):
+# request: \n
+# response: OK \n or ERR \n
+# where \, \n, \r in the payload are backslash-escaped so each frame is
+# always exactly one line.
+#
+# Eggdrop's Tcl_ServiceAll() runs every main-loop tick, so socket -server
+# and fileevent fire normally. This script is *only* loaded under the
+# EGGDROP_TEST gate and must never ship enabled in production.
+
+namespace eval ::eggtest {
+ proc escape {s} {
+ return [string map [list "\\" "\\\\" "\n" "\\n" "\r" "\\r"] $s]
+ }
+
+ proc unescape {s} {
+ return [string map [list "\\\\" "\\" "\\n" "\n" "\\r" "\r"] $s]
+ }
+
+ proc on_data {sock} {
+ if {[catch {gets $sock line} n] || $n < 0} {
+ if {[eof $sock]} {
+ close_sock $sock
+ }
+ return
+ }
+ set cmd [unescape $line]
+ if {[catch {uplevel #0 $cmd} result]} {
+ puts $sock "ERR [escape $result]"
+ } else {
+ puts $sock "OK [escape $result]"
+ }
+ flush $sock
+ }
+
+ proc close_sock {sock} {
+ catch {close $sock}
+ }
+
+ proc on_accept {sock host port} {
+ fconfigure $sock -blocking 0 -translation lf -buffering line
+ fileevent $sock readable [list ::eggtest::on_data $sock]
+ }
+
+ proc start {} {
+ if {![info exists ::env(EGGDROP_TEST_PORT_FILE)]} {
+ putlog "test_bridge: EGGDROP_TEST_PORT_FILE not set, refusing to start"
+ return
+ }
+ set srv [socket -server ::eggtest::on_accept -myaddr 127.0.0.1 0]
+ set port [lindex [fconfigure $srv -sockname] 2]
+ set portfile $::env(EGGDROP_TEST_PORT_FILE)
+ set tmp "$portfile.tmp"
+ set f [open $tmp w]
+ puts -nonewline $f $port
+ close $f
+ file rename -force $tmp $portfile
+ putlog "test_bridge listening on 127.0.0.1:$port (wrote $portfile)"
+ }
+}
+
+::eggtest::start
diff --git a/tests/support/userfile_helpers.py b/tests/support/userfile_helpers.py
new file mode 100644
index 0000000000..d59ddcbfe1
--- /dev/null
+++ b/tests/support/userfile_helpers.py
@@ -0,0 +1,43 @@
+"""Helpers for building userfile content programmatically.
+
+Tests inject pre-populated ban records into the userfile via the
+`userfile_ban_lines` (global) and `userfile_chan_ban_lines` (per-channel)
+context variables of `templates/userfile.j2`. The template just emits
+the strings verbatim — all formatting and escaping happens here so the
+template stays a flat iteration.
+"""
+
+from __future__ import annotations
+
+
+def format_userfile_ban(
+ *,
+ mask: str,
+ perm: bool,
+ sticky: bool,
+ expire: int,
+ added: int,
+ lastactive: int,
+ creator: str,
+ desc: str,
+) -> str:
+ """Format one ban record for the userfile.
+
+ Mirrors the line written by `write_bans` in
+ `src/mod/channels.mod/userchan.c`:
+
+ - ::+:::
+
+ `:` and `\\` in the mask are hex-escaped per `src/misc.c:str_escape`
+ (the parser uses `\\xy` as a hex byte; a literal `\\:` would yield NUL
+ via strtol of ":?" base 16 and silently truncate the mask). All
+ arguments are required — no defaults — so each test states exactly
+ what it's pinning.
+ """
+ escaped = mask.replace("\\", "\\5c").replace(":", "\\3a")
+ perm_prefix = "+" if perm else ""
+ sticky_suffix = "*" if sticky else ""
+ return (
+ f"- {escaped}:{perm_prefix}{expire}{sticky_suffix}:"
+ f"+{added}:{lastactive}:{creator}:{desc}"
+ )
diff --git a/tests/support/waiters.py b/tests/support/waiters.py
new file mode 100644
index 0000000000..a6ea892999
--- /dev/null
+++ b/tests/support/waiters.py
@@ -0,0 +1,62 @@
+"""Wait primitives with explicit timeouts. No bare time.sleep loops in tests."""
+
+from __future__ import annotations
+
+import re
+import time
+from collections.abc import Callable
+from pathlib import Path
+
+
+class WaitTimeout(Exception):
+ """Raised by any `wait_for*` helper when its deadline passes."""
+
+
+def wait_for_file(
+ path: Path, timeout: float = 10.0, poll: float = 0.05
+) -> None:
+ """Block until `path` exists. Raises WaitTimeout."""
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if path.exists():
+ return
+ time.sleep(poll)
+ raise WaitTimeout(f"file {path} did not appear within {timeout}s")
+
+
+def wait_for(
+ predicate: Callable[[], bool],
+ timeout: float = 10.0,
+ poll: float = 0.05,
+ description: str = "predicate",
+) -> None:
+ """Poll `predicate()` every `poll` seconds until it returns truthy.
+
+ `description` is included in the timeout error message — supply something
+ specific so test failures are self-explanatory.
+ """
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if predicate():
+ return
+ time.sleep(poll)
+ raise WaitTimeout(f"{description} not satisfied within {timeout}s")
+
+
+def wait_for_log_match(
+ log_text: Callable[[], str],
+ pattern: str,
+ timeout: float = 10.0,
+ poll: float = 0.05,
+) -> re.Match[str]:
+ """Block until `pattern` matches anywhere in the log text."""
+ rx = re.compile(pattern, re.MULTILINE)
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ m = rx.search(log_text())
+ if m:
+ return m
+ time.sleep(poll)
+ raise WaitTimeout(
+ f"log pattern /{pattern}/ not seen within {timeout}s"
+ )
diff --git a/tests/templates/chanfile.j2 b/tests/templates/chanfile.j2
new file mode 100644
index 0000000000..554c33c17b
--- /dev/null
+++ b/tests/templates/chanfile.j2
@@ -0,0 +1,15 @@
+{#- Chanfile template.
+
+ Tests pass `chanfile_channels` as a list of {"name": ..., "options": ...}
+ records. `options` is a Tcl-syntax options block (verbatim) — defaults to
+ the empty block `{}`, which is enough to make findchan_by_dname() return
+ the channel during userfile load. By default the list is empty and the
+ chanfile is empty.
+
+ Channels added via the eggdrop.conf `channels` template variable are
+ *also* registered (via `channel add` lines in the conf) and run before
+ the chanfile, so most tests don't need to populate this file at all.
+-#}
+{%- for ch in chanfile_channels -%}
+channel add {{ ch.name }} { {{ ch.options | default('') }} }
+{% endfor -%}
diff --git a/tests/templates/eggdrop.conf.j2 b/tests/templates/eggdrop.conf.j2
new file mode 100644
index 0000000000..cdd0e97d99
--- /dev/null
+++ b/tests/templates/eggdrop.conf.j2
@@ -0,0 +1,53 @@
+# Generated by the test harness. Do not edit by hand.
+
+set mod-path "{{ mod_path }}"
+set help-path "{{ help_path }}"
+
+{% for m in modules %}
+loadmodule {{ m }}
+{% endfor %}
+{% for m in extra_modules %}
+loadmodule "{{ m }}"
+{% endfor %}
+
+set nick "{{ nick }}"
+set altnick "{{ altnick }}"
+set realname "{{ realname }}"
+set username "{{ username }}"
+set admin "{{ admin }}"
+set network "{{ network }}"
+set botnet-nick "{{ botnet_nick }}"
+set owner "{{ owner_handle }}"
+
+set userfile "{{ userfile_path }}"
+set chanfile "{{ chanfile_path }}"
+set pidfile "{{ pidfile_path }}"
+
+{% if 'server' in modules %}
+set net-type "Other"
+set default-port "{{ mock_ircd_port }}"
+set servers [list]
+server add 127.0.0.1 "{{ mock_ircd_port }}"
+
+set server-cycle-wait "{{ server_cycle_wait }}"
+set server-timeout "{{ server_timeout }}"
+set msg-rate 0
+set flood-msg 0
+set flood-ctcp 0
+{% endif %}
+
+set raw-log 1
+logfile {{ log_flags }} * "{{ logfile_path }}"
+set log-time 0
+set keep-all-logs 0
+
+{% for ch in channels %}
+channel add {{ ch.name }} { chanmode "{{ ch.chanmode|default('+nt') }}" }
+{% endfor %}
+
+# Test bridge — only loaded when EGGDROP_TEST is set in the environment.
+if {[info exists env(EGGDROP_TEST)]} {
+ source "{{ bridge_tcl_path }}"
+}
+
+{{ extra_tcl }}
diff --git a/tests/templates/userfile.j2 b/tests/templates/userfile.j2
new file mode 100644
index 0000000000..eedf9b5fcd
--- /dev/null
+++ b/tests/templates/userfile.j2
@@ -0,0 +1,29 @@
+{#- Userfile template.
+
+ Tests inject pre-formatted ban-record lines via:
+ userfile_ban_lines — list of strings, written under `*ban - -`.
+ userfile_chan_ban_lines — dict of chan-name -> list of strings,
+ each written under `:: bans`.
+
+ Build the strings in Python with `support.userfile_helpers.format_userfile_ban`.
+
+ The `*ban - -` section header is what `write_bans` (channels.mod/userchan.c)
+ emits and what the parser needs — `newsplit` (src/misc.c:255) splits on
+ literal ' ' only, so `*ban\n` keeps the trailing newline glued to the
+ token and `rfc_casecmp("*ban\n", "*ban")` fails to match.
+-#}
+#4v: eggdrop test harness -- {{ botnet_nick }} -- generated
+{{ owner_handle }} - {{ owner_flags }}
+- {{ owner_hostmask }}
+{% if userfile_ban_lines -%}
+*ban - -
+{% for line in userfile_ban_lines -%}
+{{ line }}
+{% endfor -%}
+{% endif -%}
+{% for chan_name, lines in userfile_chan_ban_lines.items() -%}
+::{{ chan_name }} bans
+{% for line in lines -%}
+{{ line }}
+{% endfor -%}
+{% endfor -%}
diff --git a/tests/tests/__init__.py b/tests/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/tests/test_account_tag_voice_mode.py b/tests/tests/test_account_tag_voice_mode.py
new file mode 100644
index 0000000000..9816dd0cf8
--- /dev/null
+++ b/tests/tests/test_account_tag_voice_mode.py
@@ -0,0 +1,101 @@
+"""Account-tag CAP + WHOX + tagged MODE: regression for the full account-aware
+path through CAP negotiation, WHOX-driven account learning, and an IRCv3-tagged
+mode change.
+
+End-to-end shape:
+ - CAP LS advertises `account-tag`; bot REQ/server ACK; bot enables it.
+ - 005 carries `WHOX` so eggdrop sets `use_354` (chan.c:3053) and emits
+ `WHO #chan c%chnufat,222` after JOIN; the helper replies with 354s
+ carrying op's account name "op".
+ - The IRCd then sends a MODE +vv with an `@account=op` IRCv3 tag. Eggdrop
+ parses the MODE (via standard mode handling) and the tag (via
+ chan.c:gotrawt → setaccount).
+ - Result: test1 and test2 are voiced; op's account is "op".
+"""
+
+from __future__ import annotations
+
+import contextlib
+from collections.abc import Iterator
+
+import pytest
+
+from support.bridge_client import BridgeClient
+from support.eggdrop_proc import EggdropProc
+from support.irc_helpers import (
+ drive_join_with_names,
+ drive_registration,
+ wait_for_isupport,
+)
+from support.mock_ircd import MockIrcd
+from support.waiters import wait_for
+
+
+@pytest.fixture
+def mock_ircd() -> Iterator[MockIrcd]:
+ """Override the default fixture to advertise `account-tag` in CAP LS.
+
+ The cap list is fixed at construction time because the bot sends
+ `CAP LS 302` immediately on TCP connect, before the test body runs.
+ """
+ ircd = MockIrcd(advertised_caps=["account-tag"]).start()
+ try:
+ yield ircd
+ finally:
+ with contextlib.suppress(Exception):
+ ircd.stop()
+
+
+def test_isvoice_after_tagged_mode_with_whox_and_account_tag(
+ eggdrop_config,
+ request: pytest.FixtureRequest,
+) -> None:
+ # account-tag is disabled by default in server.mod (servmsg.c:44); the
+ # bot only adds it to the CAP REQ list when the Tcl var is 1. Set it via
+ # extra_tcl so the assignment runs after server.mod's loadmodule.
+ eggdrop_config.render(extra_tcl="set account-tag 1\n")
+ mock_ircd: MockIrcd = request.getfixturevalue("mock_ircd")
+ eggdrop_proc: EggdropProc = request.getfixturevalue("eggdrop_proc") # noqa: F841 — proc must spawn before bridge
+ tcl_bridge: BridgeClient = request.getfixturevalue("tcl_bridge")
+
+ drive_registration(mock_ircd, isupport_tokens=["WHOX"])
+ wait_for_isupport(tcl_bridge, "WHOX", "") # bare token: value stored as ""
+
+ # CAP negotiation must have completed (welcome only fires after CAP END);
+ # account-tag must be in the enabled list.
+ enabled = tcl_bridge.eval_ok("cap enabled").split()
+ assert "account-tag" in enabled, f"expected account-tag enabled, got {enabled}"
+
+ chan = drive_join_with_names(
+ mock_ircd,
+ "@op test1 test2 @TestBot",
+ member_accounts={"op": "op"},
+ )
+
+ # WHOX populated op's account via 354 (chan.c:got354 → got352or4 → setaccount).
+ wait_for(
+ lambda: tcl_bridge.eval_ok(f'getaccount op "{chan}"') == "op",
+ timeout=5.0,
+ description="WHOX 354 to set op's account to 'op'",
+ )
+
+ # Pre-MODE sanity: nobody has voice yet.
+ assert tcl_bridge.eval_ok(f'onchan test1 "{chan}"') == "1"
+ assert tcl_bridge.eval_ok(f'onchan test2 "{chan}"') == "1"
+ assert tcl_bridge.eval_ok(f'isvoice test1 "{chan}"') == "0"
+ assert tcl_bridge.eval_ok(f'isvoice test2 "{chan}"') == "0"
+
+ mock_ircd.send(f"@account=op :op!op@127.0.0.1 MODE {chan} +vv test1 :test2")
+
+ wait_for(
+ lambda: (
+ tcl_bridge.eval_ok(f'isvoice test1 "{chan}"') == "1"
+ and tcl_bridge.eval_ok(f'isvoice test2 "{chan}"') == "1"
+ ),
+ timeout=5.0,
+ description="MODE +vv test1 test2 to voice both members",
+ )
+
+ # And op's account survived the tag-driven setaccount call (no-op since
+ # it was already "op", but we want to be sure gotrawt didn't clobber it).
+ assert tcl_bridge.eval_ok(f'getaccount op "{chan}"') == "op"
diff --git a/tests/tests/test_chanset_inputvalidation.py b/tests/tests/test_chanset_inputvalidation.py
new file mode 100644
index 0000000000..6e88e54f97
--- /dev/null
+++ b/tests/tests/test_chanset_inputvalidation.py
@@ -0,0 +1,98 @@
+"""Converted from eggdrop-tests/eggdrop_chanset_inputvalidation.bats.
+
+Input validation for `channel set flood-deop X:Y` — the X:Y format
+parser must accept positive integer pairs, reject malformed input, and
+treat the all-zero forms as "off"."""
+
+from __future__ import annotations
+
+import pytest
+
+from support.bridge_client import BridgeClient
+
+CHAN = "#eggtest"
+
+
+@pytest.fixture
+def chan(tcl_bridge: BridgeClient) -> str:
+ tcl_bridge.eval_ok(f"channel add {CHAN}")
+ return CHAN
+
+
+def test_flood_deop_accepts_valid_x_colon_y(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"channel set {chan} flood-deop 253:42")
+ info = tcl_bridge.eval_ok(f"channel info {chan}")
+ assert "253:42" in info
+
+
+def test_flood_deop_rejects_non_integer_messages(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ """Letters in either field of X:Y are rejected."""
+ for bad in ("4:D", "2a:4", "2:4a"):
+ ok, result = tcl_bridge.eval(f"channel set {chan} flood-deop {bad}")
+ assert not ok, f"{bad!r} unexpectedly accepted"
+ assert "values must be integers >= 0" in result
+
+
+def test_flood_deop_rejects_single_value(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ """A bare integer (no colon) is rejected — must be X:Y."""
+ ok, result = tcl_bridge.eval(f"channel set {chan} flood-deop 4")
+ assert not ok
+ assert "flood value must be in X:Y format" in result
+
+
+def test_flood_deop_rejects_invalid_format(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ """`:X`, `X:`, and three-section `X:Y:Z` are all rejected."""
+ for bad in (":3", "3:", "53:75:86"):
+ ok, result = tcl_bridge.eval(f"channel set {chan} flood-deop {bad}")
+ assert not ok, f"{bad!r} unexpectedly accepted"
+ assert "flood value must be in X:Y format" in result
+
+
+def test_flood_deop_rejects_negative_numbers(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ ok, result = tcl_bridge.eval(f"channel set {chan} flood-deop -1:8")
+ assert not ok
+ assert "values must be integers >= 0" in result
+
+
+def test_flood_deop_zero_clears_setting(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ """Bare `0` resets the setting to `0:0`."""
+ tcl_bridge.eval_ok(f"channel set {chan} flood-deop 6:9")
+ tcl_bridge.eval_ok(f"channel set {chan} flood-deop 0")
+ info = tcl_bridge.eval_ok(f"channel info {chan}")
+ assert "0:0" in info
+
+
+def test_flood_deop_zero_zero_clears_setting(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"channel set {chan} flood-deop 6:9")
+ tcl_bridge.eval_ok(f"channel set {chan} flood-deop 0:0")
+ info = tcl_bridge.eval_ok(f"channel info {chan}")
+ assert "0:0" in info
+
+
+def test_flood_deop_n_zero_keeps_n(tcl_bridge: BridgeClient, chan: str) -> None:
+ """`N:0` is a valid (degenerate) setting and is stored verbatim."""
+ tcl_bridge.eval_ok(f"channel set {chan} flood-deop 6:9")
+ tcl_bridge.eval_ok(f"channel set {chan} flood-deop 9:0")
+ info = tcl_bridge.eval_ok(f"channel info {chan}")
+ assert "9:0" in info
+
+
+def test_flood_deop_zero_n_keeps_n(tcl_bridge: BridgeClient, chan: str) -> None:
+ tcl_bridge.eval_ok(f"channel set {chan} flood-deop 6:9")
+ tcl_bridge.eval_ok(f"channel set {chan} flood-deop 0:8")
+ info = tcl_bridge.eval_ok(f"channel info {chan}")
+ assert "0:8" in info
diff --git a/tests/tests/test_extban.py b/tests/tests/test_extban.py
new file mode 100644
index 0000000000..b1ebaaba7e
--- /dev/null
+++ b/tests/tests/test_extban.py
@@ -0,0 +1,580 @@
+"""Integration tests for the extban support PR (channels.mod / irc.mod).
+
+Each test sets up only what it needs and reads from the bridge to assert on
+internal state. Where IRC traffic is involved, the test drives the mock
+IRCd directly and (when the bot's outgoing MODE is the thing under test)
+flushes the mode buffer with `flushmode` to avoid waiting on the periodic
+HOOK_IDLE flush.
+
+What this PR introduced (and these tests cover):
+
+- Tcl `account-extban` global, populated from ISUPPORT ACCOUNTEXTBAN.
+- `.+extban ` partyline command. Constructs the mask using
+ the EXTBAN-advertised prefix, refuses while disconnected.
+- `u_addban` no longer auto-sticky-fies extban masks at storage time
+ (option-1 swap: enforceability decided at runtime via
+ `extban_is_unenforceable`, never persisted).
+- `check_this_ban` / `recheck_bans` / `check_expired_chanstuff` treat
+ unenforceable extbans (q:, c:, ...) as if sticky for set-and-keep
+ purposes, even on `+dynamicbans` channels.
+- `u_addban` skips the bot-self-ban check on extban masks (they don't
+ have the nick!user@host shape that match would target).
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from support.bridge_client import BridgeClient
+from support.eggdrop_proc import EggdropProc
+from support.irc_helpers import (
+ drive_join_with_names,
+ drive_registration,
+ wait_for_isupport,
+)
+from support.mock_ircd import MockIrcd
+from support.userfile_helpers import format_userfile_ban
+from support.waiters import wait_for
+
+# ---------- Tcl `account-extban` global ----------
+
+
+def test_account_extban_tcl_var_empty_when_no_isupport(
+ tcl_bridge: BridgeClient,
+) -> None:
+ """Reading `$account-extban` before any 005 returns the empty string.
+
+ The Tcl trace fires on read, calls `servermod_isupport_get("ACCOUNTEXTBAN")`,
+ which returns NULL since the bot hasn't connected yet, and the trace
+ handler stores "" in the variable.
+ """
+ assert tcl_bridge.eval_ok("set ::account-extban") == ""
+
+
+def test_account_extban_tcl_var_populated_from_isupport(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """After 005 with `ACCOUNTEXTBAN=a`, reading the Tcl global returns "a".
+
+ The PR also accepts the longer `a,account` form per ircdocs; we send
+ the short form here because it's what most networks advertise.
+ """
+ drive_registration(
+ mock_ircd,
+ isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"],
+ )
+ wait_for_isupport(tcl_bridge, "ACCOUNTEXTBAN", "a")
+ assert tcl_bridge.eval_ok("set ::account-extban") == "a"
+
+
+# ---------- u_addban: no auto-sticky on extban storage ----------
+
+
+def test_newban_extban_does_not_auto_sticky(
+ tcl_bridge: BridgeClient,
+) -> None:
+ """`newban a:foo` stores the ban without `MASKREC_STICKY`.
+
+ Before commit 9269ae64, u_addban set MASKREC_STICKY for any extban
+ whose flag wasn't in the hardcoded enforceable set. With the
+ option-1 swap, that decision moved to enforcement time
+ (`extban_is_unenforceable`), so the userfile flags now reflect the
+ user's literal intent.
+ """
+ tcl_bridge.eval_ok("newban a:foo testuser comment")
+ assert tcl_bridge.eval_ok("isbansticky a:foo") == "0"
+ assert tcl_bridge.eval_ok("isban a:foo") == "1"
+
+
+def test_newban_extban_with_explicit_sticky_option_is_sticky(
+ tcl_bridge: BridgeClient,
+) -> None:
+ """User-controlled sticky still works: `newban a:foo ... sticky` → sticky."""
+ tcl_bridge.eval_ok("newban a:foo testuser comment 0 sticky")
+ assert tcl_bridge.eval_ok("isbansticky a:foo") == "1"
+
+
+def test_newban_unenforceable_extban_q_not_auto_sticky(
+ tcl_bridge: BridgeClient,
+) -> None:
+ """`q:` (mute extban) is unenforceable from the eggdrop side, but the
+ sticky bit still isn't persisted. Enforceability is a runtime decision."""
+ tcl_bridge.eval_ok("newban q:badactor testuser comment")
+ assert tcl_bridge.eval_ok("isbansticky q:badactor") == "0"
+ assert tcl_bridge.eval_ok("isban q:badactor") == "1"
+
+
+# ---------- u_addban: bot-self-ban check skipped for extbans ----------
+
+
+def test_newban_extban_matching_botnick_is_not_self_rejected(
+ tcl_bridge: BridgeClient,
+) -> None:
+ """The "I'm not going to ban myself" guard in u_addban only fires on
+ the non-extban branch (extban masks don't have nick!user@host shape).
+ `a:TestBot` should be accepted even though the bot's nick is TestBot.
+ """
+ tcl_bridge.eval_ok("newban a:TestBot testuser comment")
+ assert tcl_bridge.eval_ok("isban a:TestBot") == "1"
+
+
+# ---------- partyline `.+extban` ----------
+
+
+@pytest.mark.partyline
+def test_partyline_pls_extban_refused_when_disconnected(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """`.+extban` requires ISUPPORT EXTBAN to know the prefix; refuses if
+ we haven't seen 005 yet (commit 2e195b1).
+
+ No drive_registration() call here — the bot has TCP-connected but is
+ still pre-welcome, so isupport_get("EXTBAN") returns NULL.
+ """
+ snapshot = len(eggdrop_proc.stdout_text())
+ eggdrop_proc.send_partyline(".+extban a foo")
+
+ wait_for(
+ lambda: "must be connected to a server with EXTBAN support"
+ in eggdrop_proc.stdout_text()[snapshot:],
+ timeout=5.0,
+ description="partyline .+extban to refuse with EXTBAN-unavailable msg",
+ )
+
+ # And nothing was stored.
+ assert tcl_bridge.eval_ok("isban a:foo") == "0"
+
+
+@pytest.mark.partyline
+def test_partyline_pls_extban_constructs_prefixed_mask(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """`.+extban a Foo` on a server with `EXTBAN=~,a` stores `~a:Foo` in
+ the channel ban list (prefix from ISUPPORT, flag and value from input).
+ """
+ drive_registration(
+ mock_ircd,
+ isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"],
+ )
+ drive_join_with_names(mock_ircd, "@TestBot")
+ wait_for_isupport(tcl_bridge, "EXTBAN", "~,acrjmU")
+
+ eggdrop_proc.send_partyline(".+extban a Foo #test")
+
+ wait_for(
+ lambda: tcl_bridge.eval_ok("isban ~a:Foo #test") == "1",
+ timeout=5.0,
+ description="partyline .+extban to register ~a:Foo on #test",
+ )
+
+
+@pytest.mark.partyline
+def test_partyline_pls_extban_constructs_unprefixed_mask(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """`.+extban a Foo` on a server whose EXTBAN advertises no prefix
+ (form: `,types`) stores `a:Foo` (no prefix prepended).
+ """
+ drive_registration(mock_ircd, isupport_tokens=["EXTBAN=,acrjmU"])
+ drive_join_with_names(mock_ircd, "@TestBot")
+ wait_for_isupport(tcl_bridge, "EXTBAN", ",acrjmU")
+
+ eggdrop_proc.send_partyline(".+extban a Foo #test")
+
+ wait_for(
+ lambda: tcl_bridge.eval_ok("isban a:Foo #test") == "1",
+ timeout=5.0,
+ description="partyline .+extban to register a:Foo (no prefix) on #test",
+ )
+
+
+# ---------- check_this_ban / recheck_bans: unenforceable extban set on +dynamicbans ----------
+
+
+def test_unenforceable_extban_queued_as_plus_b_on_dynamicbans_channel(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """A non-enforceable extban (`q:`) must still be set on the channel
+ even with `+dynamicbans`, because eggdrop has no other way to enforce
+ a server-side mute. The condition in check_this_ban includes
+ `extban_is_unenforceable(banmask)` for exactly this case.
+ """
+ # 'q' must appear in the EXTBAN types list, otherwise check_this_ban
+ # short-circuits at `!extban_flag_supported('q')` before add_mode.
+ extban = "~,acjmqrUz"
+ drive_registration(mock_ircd, isupport_tokens=[f"EXTBAN={extban}"])
+ chan = drive_join_with_names(mock_ircd, "@TestBot")
+ wait_for_isupport(tcl_bridge, "EXTBAN", extban)
+
+ # Confirm preconditions for the +b add_mode path:
+ # - dynamicbans is on (otherwise the path under test never gates)
+ # - bot is op (HALFOP_CANTDOMODE('b') would short-circuit otherwise)
+ assert tcl_bridge.eval_ok(f'channel get "{chan}" dynamicbans') == "1"
+ assert tcl_bridge.eval_ok(f'isop TestBot "{chan}"') == "1"
+
+ tcl_bridge.eval_ok(f'newchanban "{chan}" q:badmouth testuser comment')
+ # Force-flush the mode buffer instead of waiting on HOOK_IDLE.
+ tcl_bridge.eval_ok(f'flushmode "{chan}"')
+
+ # The bot should send a MODE for chan that includes our extban as the +b
+ # arg. The mode letters can be batched with chanmode protection (e.g.
+ # "+tnb") so we just look for the unambiguous mask payload on a MODE line.
+ mock_ircd.drain_until(
+ lambda line: line.startswith(f"MODE {chan} ") and "q:badmouth" in line,
+ timeout=5.0,
+ )
+
+
+def test_enforceable_account_extban_not_queued_on_dynamicbans_channel_without_match(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """An *enforceable* account extban for an account no current member has
+ must NOT be set proactively on a `+dynamicbans` channel. The condition
+ `extban_is_unenforceable("a:nooneactual")` returns false (because acc
+ flag matches), so it falls back to the standard dynamic-bans rule:
+ only set when a matching member triggers it.
+ """
+ drive_registration(
+ mock_ircd,
+ isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"],
+ )
+ chan = drive_join_with_names(mock_ircd, "@TestBot alice")
+ wait_for_isupport(tcl_bridge, "ACCOUNTEXTBAN", "a")
+ assert tcl_bridge.eval_ok(f'channel get "{chan}" dynamicbans') == "1"
+
+ tcl_bridge.eval_ok(f'newchanban "{chan}" a:nooneactual testuser comment')
+ tcl_bridge.eval_ok(f'flushmode "{chan}"')
+
+ # No MODE +b should reach the IRCd within a reasonable window.
+ # Use a short drain that *requires* a +b ... a:nooneactual to assert non-presence:
+ # if the predicate never matches and we get a MockIrcdError on timeout, we win.
+ from support.mock_ircd import MockIrcdError
+
+ with pytest.raises(MockIrcdError):
+ mock_ircd.drain_until(
+ lambda line: line.startswith(f"MODE {chan} ") and "a:nooneactual" in line,
+ timeout=2.0,
+ )
+
+ # And the userfile record exists regardless.
+ assert tcl_bridge.eval_ok(f'isban a:nooneactual "{chan}"') == "1"
+ # And it was not auto-stickified.
+ assert tcl_bridge.eval_ok(f'isbansticky a:nooneactual "{chan}"') == "0"
+
+
+def test_enforcebans_account_extban_kicks_after_account_change(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """+enforcebans + an account-extban + a member who was on the channel
+ *before* the matching account was set: when the server sends ACCOUNT
+ (account-notify capability), the bot must add +b for the extban and
+ KICK the now-matching user.
+
+ Path under test:
+ ACCOUNT msg → got_account (chan.c:2877) → setaccount (chan.c:179) →
+ banmask_list_matches_member finds the userfile ban → refresh_ban_kick
+ → do_mask sets +b a:badname and kick_all sends KICK.
+
+ `+enforcebans` is set explicitly so the test pins the "kick on
+ account-match" intent independent of dynamic-bans defaults. Before
+ the ACCOUNT message arrives, alice's m->account is empty, so
+ `banmask_matches_member` correctly returns false and nothing fires.
+ """
+ drive_registration(
+ mock_ircd,
+ isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"],
+ )
+ chan = drive_join_with_names(mock_ircd, "@TestBot alice")
+ wait_for_isupport(tcl_bridge, "ACCOUNTEXTBAN", "a")
+
+ # Preconditions for the kick path: bot is op, alice is on chan,
+ # alice has no account yet, +enforcebans is on.
+ assert tcl_bridge.eval_ok(f'isop TestBot "{chan}"') == "1"
+ assert tcl_bridge.eval_ok(f'onchan alice "{chan}"') == "1"
+ tcl_bridge.eval_ok(f'channel set "{chan}" +enforcebans')
+ assert tcl_bridge.eval_ok(f'channel get "{chan}" enforcebans') == "1"
+
+ # Add the account-extban targeting an account alice doesn't yet have.
+ # check_this_ban iterates members; alice's m->account is "" so
+ # banmask_matches_member returns false. Nothing about a:badname goes
+ # out — verified below by the negative drain.
+ tcl_bridge.eval_ok(f'newchanban "{chan}" a:badname testuser comment')
+ tcl_bridge.eval_ok(f'flushmode "{chan}"')
+
+ from support.mock_ircd import MockIrcdError
+
+ with pytest.raises(MockIrcdError):
+ mock_ircd.drain_until(
+ lambda line: "a:badname" in line or (
+ line.startswith(f"KICK {chan} alice")
+ ),
+ timeout=2.0,
+ )
+
+ # Alice logs in to the matching account.
+ mock_ircd.send(":alice!u@h.example.com ACCOUNT badname")
+
+ # Bot must (a) set +b a:badname on the channel and (b) KICK alice.
+ # Modes get flushed by add_mode/flush_mode in the refresh_ban_kick
+ # path; KICK goes via DP_SERVER directly. Both should arrive within
+ # the IDLE flush cycle.
+ seen = mock_ircd.drain_until(
+ lambda line: line.startswith(f"KICK {chan} alice"),
+ timeout=5.0,
+ )
+ assert any(
+ line.startswith(f"MODE {chan} ") and "a:badname" in line
+ for line in seen
+ ), f"expected MODE +b a:badname before the KICK; got {seen}"
+
+
+# ---------- userfile loading: extbans loaded regardless of EXTBAN advertised ----------
+
+
+def test_extbans_load_from_userfile_before_connect(
+ eggdrop_config,
+ request: pytest.FixtureRequest,
+) -> None:
+ """Extbans stored in the userfile must be loaded into memory at startup,
+ independent of any EXTBAN/ACCOUNTEXTBAN ISUPPORT data — that data isn't
+ available until after the bot connects.
+
+ The load path goes through `restore_chanban → addmask_fully` (users.c),
+ which doesn't call `isupport_get` or any of the new extban helpers.
+ Verified by spawning without driving registration. The bans are
+ rendered into the userfile via the `userfile_bans` /
+ `userfile_chan_bans` template variables (see templates/userfile.j2).
+ """
+ eggdrop_config.render(
+ userfile_ban_lines=[
+ format_userfile_ban(
+ mask="a:storedacct", perm=True, sticky=False, expire=0,
+ added=1700000000, lastactive=0, creator="owner",
+ desc="loaded from userfile",
+ ),
+ format_userfile_ban(
+ mask="q:storedmute", perm=True, sticky=False, expire=0,
+ added=1700000000, lastactive=0, creator="owner",
+ desc="loaded from userfile",
+ ),
+ format_userfile_ban(
+ mask="U:strangers!*@*", perm=True, sticky=False, expire=0,
+ added=1700000000, lastactive=0, creator="owner",
+ desc="loaded from userfile",
+ ),
+ ],
+ userfile_chan_ban_lines={
+ "#test": [
+ format_userfile_ban(
+ mask="~a:chanonlyacct", perm=True, sticky=False, expire=0,
+ added=1700000000, lastactive=0, creator="owner",
+ desc="loaded from userfile",
+ ),
+ ],
+ },
+ )
+ request.getfixturevalue("eggdrop_proc")
+ bridge: BridgeClient = request.getfixturevalue("tcl_bridge")
+
+ # Bot has not yet connected — confirm ISUPPORT is empty for both keys
+ # the new helpers consult. Then bans must still be present in memory.
+ assert bridge.eval_ok("set ::account-extban") == ""
+
+ # Global extbans loaded.
+ assert bridge.eval_ok("isban a:storedacct") == "1"
+ assert bridge.eval_ok("isban q:storedmute") == "1"
+ assert bridge.eval_ok("isban U:strangers!*@*") == "1"
+
+ # Per-channel extban loaded.
+ assert bridge.eval_ok("isban ~a:chanonlyacct #test") == "1"
+
+ # And — critically — none of them got auto-stickified by the new
+ # u_addban code path, because the load path bypasses u_addban entirely.
+ assert bridge.eval_ok("isbansticky a:storedacct") == "0"
+ assert bridge.eval_ok("isbansticky q:storedmute") == "0"
+ assert bridge.eval_ok("isbansticky ~a:chanonlyacct #test") == "0"
+
+
+def test_extban_perm_sticky_flags_survive_userfile_roundtrip(
+ eggdrop_config,
+ request: pytest.FixtureRequest,
+) -> None:
+ """A perm + sticky extban *that was already in the userfile* loads with
+ those flags intact. Verifies that the `+`/`*` flag characters in the
+ record format aren't confused by the hex-escaped `:` in the mask.
+ """
+ eggdrop_config.render(
+ userfile_ban_lines=[
+ format_userfile_ban(
+ mask="a:permsticky",
+ perm=True,
+ sticky=True,
+ expire=0,
+ added=1700000000,
+ lastactive=0,
+ creator="owner",
+ desc="perm-sticky from userfile",
+ ),
+ ],
+ )
+ request.getfixturevalue("eggdrop_proc")
+ bridge: BridgeClient = request.getfixturevalue("tcl_bridge")
+
+ assert bridge.eval_ok("isban a:permsticky") == "1"
+ assert bridge.eval_ok("isbansticky a:permsticky") == "1"
+ assert bridge.eval_ok("ispermban a:permsticky") == "1"
+
+
+# ---------- partyline .+ban / .+extban across connection states ----------
+
+
+@pytest.mark.partyline
+def test_partyline_pls_ban_extban_works_when_connected(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """`.+ban a:foo` (extban via the generic +ban command, not +extban) is
+ accepted while connected. The mask is stored verbatim — no prefix
+ construction (that's +extban's job).
+ """
+ drive_registration(
+ mock_ircd,
+ isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"],
+ )
+ drive_join_with_names(mock_ircd, "@TestBot")
+ wait_for_isupport(tcl_bridge, "EXTBAN", "~,acrjmU")
+
+ eggdrop_proc.send_partyline(".+ban a:connectedacct #test why")
+
+ wait_for(
+ lambda: tcl_bridge.eval_ok("isban a:connectedacct #test") == "1",
+ timeout=5.0,
+ description="partyline .+ban (extban form) to register on #test",
+ )
+ # No auto-sticky.
+ assert tcl_bridge.eval_ok("isbansticky a:connectedacct #test") == "0"
+
+
+@pytest.mark.partyline
+def test_partyline_pls_ban_extban_works_when_not_yet_connected(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """`.+ban a:foo` works even before 005 has been received — `.+ban`
+ has no connection gate (only `.+extban` does, since only `+extban`
+ needs the prefix). The "extban not enabled" warning fires (because
+ EXTBAN ISUPPORT is unknown) but the ban is still stored.
+ """
+ # Deliberately: NO drive_registration() — bot is pre-welcome.
+ snapshot = len(eggdrop_proc.stdout_text())
+ eggdrop_proc.send_partyline(".+ban a:disconnectedacct why")
+
+ wait_for(
+ lambda: tcl_bridge.eval_ok("isban a:disconnectedacct") == "1",
+ timeout=5.0,
+ description="partyline .+ban (extban form) to register while disconnected",
+ )
+
+ # Storage: not auto-stickified (regression for option-1 swap).
+ assert tcl_bridge.eval_ok("isbansticky a:disconnectedacct") == "0"
+
+ # And the user got the EXTBAN-not-enabled feedback. The message text
+ # comes from EXTBAN_NOT_ENABLED1/2/3 in the language file; we look for
+ # a stable substring rather than the full template.
+ new_output = eggdrop_proc.stdout_text()[snapshot:]
+ assert "extban is not enabled on this server" in new_output, new_output
+
+
+@pytest.mark.partyline
+def test_partyline_pls_extban_works_when_connected_already_covered(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """Sanity counterpart to the `.+extban` disconnected test above:
+ same command, but with 005 received first, succeeds and stores the
+ prefixed mask. (Mostly redundant with `_constructs_prefixed_mask`
+ above — included so the connected/disconnected pair reads cleanly
+ next to each other.)
+ """
+ drive_registration(mock_ircd, isupport_tokens=["EXTBAN=~,acrjmU"])
+ drive_join_with_names(mock_ircd, "@TestBot")
+ wait_for_isupport(tcl_bridge, "EXTBAN", "~,acrjmU")
+
+ eggdrop_proc.send_partyline(".+extban a Bar #test")
+
+ wait_for(
+ lambda: tcl_bridge.eval_ok("isban ~a:Bar #test") == "1",
+ timeout=5.0,
+ description="connected .+extban to register ~a:Bar on #test",
+ )
+
+
+# ---------- partyline behaviour without server.mod loaded ----------
+
+
+@pytest.mark.partyline
+def test_partyline_pls_ban_extban_works_without_server_mod(
+ eggdrop_config,
+ request: pytest.FixtureRequest,
+) -> None:
+ """`.+ban a:foo` stores the extban even when server.mod isn't loaded
+ at all — the storage path doesn't need ISUPPORT, and the
+ `servermod_isupport_get` thunk returns NULL safely when
+ `module_find("server")` finds nothing.
+
+ Without server.mod: irc.mod and ctcp.mod also can't load (they
+ `module_depend` on server). channels.mod loads cleanly because the
+ cross-module API bypass was fixed (commit 8cfcd51e). The bot has no
+ outbound IRC connection at all in this configuration.
+
+ Driven via the `modules` template variable — see
+ tests/templates/eggdrop.conf.j2 and the `EggdropConfig.context()`
+ default in conftest.py. Render must happen *before* the proc fixture
+ evaluates, so the proc/bridge are pulled in lazily via getfixturevalue.
+ """
+ eggdrop_config.render(modules=["pbkdf2", "channels", "console", "notes"])
+ proc: EggdropProc = request.getfixturevalue("eggdrop_proc")
+ bridge: BridgeClient = request.getfixturevalue("tcl_bridge")
+
+ # Sanity: server.mod is genuinely not loaded.
+ assert bridge.eval_ok("expr {[catch {set ::server-online}] != 0}") == "1"
+
+ # `.+ban` with an extban mask: stored, no errors.
+ proc.send_partyline(".+ban a:noservermod why")
+ wait_for(
+ lambda: bridge.eval_ok("isban a:noservermod") == "1",
+ timeout=5.0,
+ description=".+ban (extban form) to register without server.mod",
+ )
+ assert bridge.eval_ok("isbansticky a:noservermod") == "0"
+
+ # `.+extban`: refused. The thunk returns NULL → no EXTBAN known →
+ # gated by the same check that fires when disconnected.
+ snapshot = len(proc.stdout_text())
+ proc.send_partyline(".+extban a foo")
+ wait_for(
+ lambda: "must be connected to a server with EXTBAN support"
+ in proc.stdout_text()[snapshot:],
+ timeout=5.0,
+ description=".+extban refusal text without server.mod loaded",
+ )
+ # Nothing stored from the +extban attempt.
+ assert bridge.eval_ok("isban a:foo") == "0"
+ assert bridge.eval_ok("isban ~a:foo") == "0"
diff --git a/tests/tests/test_framing.py b/tests/tests/test_framing.py
new file mode 100644
index 0000000000..29ee627490
--- /dev/null
+++ b/tests/tests/test_framing.py
@@ -0,0 +1,107 @@
+"""Unit tests for the line-delimited framing helpers."""
+
+from __future__ import annotations
+
+import pytest
+
+from support.framing import (
+ ProtocolError,
+ encode_request,
+ encode_response,
+ escape,
+ parse_response,
+ unescape,
+)
+
+
+@pytest.mark.parametrize(
+ ("raw", "encoded"),
+ [
+ ("", ""),
+ ("hello", "hello"),
+ ("foo bar", "foo bar"),
+ ("a\\b", "a\\\\b"),
+ ("a\nb", "a\\nb"),
+ ("a\rb", "a\\rb"),
+ ("\\", "\\\\"),
+ ("\n", "\\n"),
+ ("\r", "\\r"),
+ ("\\n", "\\\\n"), # literal backslash + n, NOT a newline
+ ("a\\\nb", "a\\\\\\nb"), # backslash then newline
+ ("héllo", "héllo"), # unicode passes through
+ ],
+)
+def test_escape_unescape_roundtrip(raw: str, encoded: str) -> None:
+ assert escape(raw) == encoded
+ assert unescape(encoded) == raw
+
+
+def test_unescape_dangling_backslash_raises() -> None:
+ with pytest.raises(ProtocolError, match="dangling backslash"):
+ unescape("foo\\")
+
+
+def test_unescape_unknown_escape_raises() -> None:
+ with pytest.raises(ProtocolError, match="unknown escape"):
+ unescape("foo\\x")
+
+
+def test_encode_request_appends_newline() -> None:
+ assert encode_request("set foo 42") == b"set foo 42\n"
+
+
+def test_encode_request_escapes_newline() -> None:
+ assert encode_request("a\nb") == b"a\\nb\n"
+
+
+def test_encode_response_format() -> None:
+ assert encode_response("OK", "42") == b"OK 42\n"
+ assert encode_response("ERR", "bad") == b"ERR bad\n"
+
+
+def test_encode_response_escapes_payload() -> None:
+ assert encode_response("OK", "line1\nline2") == b"OK line1\\nline2\n"
+
+
+def test_parse_response_ok() -> None:
+ assert parse_response("OK 42\n") == ("OK", "42")
+
+
+def test_parse_response_err() -> None:
+ assert parse_response("ERR bad command\n") == ("ERR", "bad command")
+
+
+def test_parse_response_empty_payload() -> None:
+ assert parse_response("OK\n") == ("OK", "")
+ assert parse_response("OK \n") == ("OK", "")
+
+
+def test_parse_response_unescapes() -> None:
+ assert parse_response("OK line1\\nline2\n") == ("OK", "line1\nline2")
+ assert parse_response("OK a\\\\b\n") == ("OK", "a\\b")
+
+
+def test_parse_response_strips_crlf() -> None:
+ assert parse_response("OK 42\r\n") == ("OK", "42")
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ "",
+ "simple",
+ "with spaces and stuff",
+ "newlines\nare\nfine",
+ "carriage\rreturn",
+ "back\\slash",
+ "all\\of\nthe\rabove",
+ "unicode: héllo, 日本語",
+ "a" * 4096,
+ ],
+)
+def test_response_roundtrip(payload: str) -> None:
+ framed = encode_response("OK", payload)
+ assert framed.endswith(b"\n")
+ assert framed.count(b"\n") == 1, "frame must be exactly one line"
+ line = framed.decode("utf-8")
+ assert parse_response(line) == ("OK", payload)
diff --git a/tests/tests/test_isupport_modes.py b/tests/tests/test_isupport_modes.py
new file mode 100644
index 0000000000..a0c36a6720
--- /dev/null
+++ b/tests/tests/test_isupport_modes.py
@@ -0,0 +1,381 @@
+"""Tests for arbitrary chan-modes (isupport PREFIX + CHANMODES) handling.
+
+Each test is self-contained — the realistic isupport strings used (drawn
+from the top-3 most-popular advertised values at
+https://stats.ircdocs.horse/isupport/) appear inline so the test reads as
+a complete story.
+
+Coverage:
+- 005 parsing of PREFIX and CHANMODES → debug log + isupport state
+- `.status all` on the partyline reflects what was parsed
+- Raw 324 (RPL_CHANNELMODEIS) with mixed known/unknown modes after join
+- Inbound MODE messages mixing prefix modes, hardcoded modes, and unknown
+ modes — Eggdrop must skip what it doesn't know but still apply known
+ ones in the right slots (op grant, key after junk modes).
+"""
+
+from __future__ import annotations
+
+import re
+
+import pytest
+
+from support.bridge_client import BridgeClient
+from support.eggdrop_proc import EggdropProc
+from support.irc_helpers import (
+ drive_join_with_names,
+ drive_registration,
+ wait_for_isupport,
+)
+from support.mock_ircd import MockIrcd
+from support.waiters import wait_for
+
+# ---------- 005 parsing: debug log + isupport state ----------
+
+
+def test_005_parses_prefix_qaohv_and_chanmodes_top1(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """Most-popular PREFIX (qaohv, 123 networks) + most-popular CHANMODES.
+
+ Each prefix mode and each LIST/KEY chanmode produces a corresponding
+ "Learned mode type: ..." debug line, and isupport state matches.
+ """
+ prefix = "(qaohv)~&@%+"
+ chanmodes = "beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc"
+
+ drive_registration(
+ mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"]
+ )
+ wait_for_isupport(tcl_bridge, "PREFIX", prefix)
+ wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes)
+
+ log = eggdrop_proc.log_path.read_text()
+ # Each prefix letter → symbol pair must appear in the debug log.
+ for letter, symbol in [("q", "~"), ("a", "&"), ("o", "@"), ("h", "%"), ("v", "+")]:
+ pat = (
+ rf"Learned mode type: \+{letter} type Prefix, "
+ rf"prefixchar {re.escape(symbol)}"
+ )
+ assert re.search(pat, log), f"missing prefix debug line for +{letter} {symbol}"
+ # CHANMODES sections: LIST=beI, KEY=kLf.
+ for letter in "beI":
+ assert re.search(rf"Learned mode type: \+{letter} type List(?!.)", log), (
+ f"missing List debug line for +{letter}"
+ )
+ for letter in "kLf":
+ assert re.search(rf"Learned mode type: \+{letter} type Key(?!.)", log), (
+ f"missing Key debug line for +{letter}"
+ )
+
+
+def test_005_parses_prefix_ohv_and_chanmodes_top2(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """Second-most-popular PREFIX (47 networks) + second-most-popular CHANMODES."""
+ prefix = "(ohv)@%+"
+ chanmodes = "beI,kfL,lj,psmntirRcOAQKVCuzNSMTG"
+
+ drive_registration(
+ mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"]
+ )
+ wait_for_isupport(tcl_bridge, "PREFIX", prefix)
+ wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes)
+
+ log = eggdrop_proc.log_path.read_text()
+ for letter, symbol in [("o", "@"), ("h", "%"), ("v", "+")]:
+ pat = (
+ rf"Learned mode type: \+{letter} type Prefix, "
+ rf"prefixchar {re.escape(symbol)}"
+ )
+ assert re.search(pat, log), f"missing prefix debug line for +{letter} {symbol}"
+ # CHANMODES sections: LIST=beI, KEY=kfL.
+ for letter in "beI":
+ assert re.search(rf"Learned mode type: \+{letter} type List(?!.)", log)
+ for letter in "kfL":
+ assert re.search(rf"Learned mode type: \+{letter} type Key(?!.)", log)
+
+
+def test_005_parses_prefix_ov_and_chanmodes_top3(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """Basic PREFIX (op + voice only) + CHANMODES with q/a as LIST modes
+ instead of prefix modes."""
+ prefix = "(ov)@+"
+ chanmodes = "beIqa,kLf,l,psmntirzMQNRTOVKDdGPZS"
+
+ drive_registration(
+ mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"]
+ )
+ wait_for_isupport(tcl_bridge, "PREFIX", prefix)
+ wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes)
+
+ log = eggdrop_proc.log_path.read_text()
+ for letter, symbol in [("o", "@"), ("v", "+")]:
+ pat = (
+ rf"Learned mode type: \+{letter} type Prefix, "
+ rf"prefixchar {re.escape(symbol)}"
+ )
+ assert re.search(pat, log), f"missing prefix debug line for +{letter} {symbol}"
+ # CHANMODES sections: LIST=beIqa (note 'q' and 'a' are LIST here, not prefix), KEY=kLf.
+ for letter in "beIqa":
+ assert re.search(rf"Learned mode type: \+{letter} type List(?!.)", log)
+ for letter in "kLf":
+ assert re.search(rf"Learned mode type: \+{letter} type Key(?!.)", log)
+
+
+# ---------- use-exempts / use-invites derive from CHANMODES ----------
+
+
+def test_use_exempts_and_invites_set_when_e_and_I_in_list(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """`use-exempts` / `use-invites` are turned on when 'e' and 'I' are in the
+ LIST section of CHANMODES (the typical case for major IRCds)."""
+ chanmodes = "beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc" # 'e' and 'I' both in LIST
+ drive_registration(mock_ircd, isupport_tokens=[f"CHANMODES={chanmodes}"])
+ wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes)
+
+ assert tcl_bridge.eval_ok("set ::use-exempts") == "1"
+ assert tcl_bridge.eval_ok("set ::use-invites") == "1"
+
+
+def test_use_exempts_off_when_e_not_in_list(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """If 'e' is missing from the LIST section, use-exempts goes to 0
+ (use-invites stays on because 'I' is still in LIST)."""
+ chanmodes = "bI,k,l,imnpst" # no 'e' in list section
+ drive_registration(mock_ircd, isupport_tokens=[f"CHANMODES={chanmodes}"])
+ wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes)
+
+ assert tcl_bridge.eval_ok("set ::use-exempts") == "0"
+ assert tcl_bridge.eval_ok("set ::use-invites") == "1"
+
+
+# ---------- .status all on the partyline ----------
+
+
+@pytest.mark.partyline
+def test_status_all_reports_parsed_isupport(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """`.status all` partyline output contains an `isupport:` line including
+ the PREFIX and CHANMODES we sent in 005."""
+ prefix = "(qaohv)~&@%+"
+ chanmodes = "beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc"
+
+ drive_registration(
+ mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"]
+ )
+ wait_for_isupport(tcl_bridge, "PREFIX", prefix)
+
+ # Mark a snapshot so we only scan output produced after sending the cmd.
+ snapshot_offset = len(eggdrop_proc.stdout_text())
+ eggdrop_proc.send_partyline(".status all")
+
+ def has_isupport_line() -> bool:
+ new_output = eggdrop_proc.stdout_text()[snapshot_offset:]
+ return any(
+ "isupport:" in line and "PREFIX=" in line and "CHANMODES=" in line
+ for line in new_output.splitlines()
+ )
+
+ wait_for(
+ has_isupport_line,
+ timeout=5.0,
+ description="`.status all` to print the isupport: line",
+ )
+
+ new_output = eggdrop_proc.stdout_text()[snapshot_offset:]
+ assert f"PREFIX={prefix}" in new_output
+ assert f"CHANMODES={chanmodes}" in new_output
+
+
+# ---------- raw 324 with mixed modes ----------
+
+
+def test_got324_skips_unknown_mode_but_applies_key(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """324 `+fk test_f test_k` — `f` (forward, common on Charybdis) is unknown
+ to Eggdrop but isupport says it has a parameter; `f` is skipped, the key
+ `test_k` still applies."""
+ prefix = "(ohv)@%+"
+ # 'f' lives in the KEY section here (kfL), so isupport says it takes an arg.
+ chanmodes = "beI,kfL,lj,psmntirRcOAQKVCuzNSMTG"
+
+ drive_registration(
+ mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"]
+ )
+ chan = drive_join_with_names(mock_ircd, "@TestBot")
+ wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes)
+
+ mock_ircd.send(f":mock.test 324 TestBot {chan} +fk test_f test_k")
+
+ wait_for(
+ lambda: "test_k" in tcl_bridge.eval_ok(f'getchanmode "{chan}"'),
+ timeout=5.0,
+ description="324 to apply key=test_k",
+ )
+
+ chanmode_str = tcl_bridge.eval_ok(f'getchanmode "{chan}"')
+ # Format is "+k " — verify 'k' is set, key is correct,
+ # and 'f' (unknown) does not appear in the flags.
+ flags, _, key = chanmode_str.partition(" ")
+ assert "k" in flags, chanmode_str
+ assert "f" not in flags, chanmode_str
+ assert key.strip() == "test_k", chanmode_str
+
+
+def test_got324_conflict_eggdrop_says_noargs_isupport_says_args(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """If isupport puts a mode Eggdrop hardcodes as no-arg into a section
+ with args, got324 logs a warning and skips that mode (consuming its
+ arg). The next mode in line still gets parsed correctly.
+
+ Constructed CHANMODES: 'q' (Eggdrop hardcodes it as no-arg / quiet) is
+ placed in the LIST section (with-arg). Sending `324 +qk arg_q test_k`
+ must skip the `q arg_q` pair entirely and still apply `+k test_k`.
+ """
+ chanmodes = "beIq,k,l,imnpst" # 'q' in LIST → isupport says it takes arg
+
+ drive_registration(mock_ircd, isupport_tokens=[f"CHANMODES={chanmodes}"])
+ chan = drive_join_with_names(mock_ircd, "@TestBot")
+ wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes)
+
+ mock_ircd.send(f":mock.test 324 TestBot {chan} +qk arg_q test_k")
+
+ wait_for(
+ lambda: "test_k" in tcl_bridge.eval_ok(f'getchanmode "{chan}"'),
+ timeout=5.0,
+ description="324 to apply key=test_k after skipped +q",
+ )
+
+ log = eggdrop_proc.log_path.read_text()
+ assert re.search(
+ r"Eggdrop assumes mode change \+q has no parameter but isupport says yes",
+ log,
+ ), "expected the +q conflict warning in the log"
+
+
+# ---------- inbound MODE while joined ----------
+
+
+def test_gotmode_op_via_prefix_after_unknown_mode_with_arg(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """MODE `+ofk alice test_f test_k` on a joined channel:
+ - alice gets opped (prefix mode +o consumes its arg correctly),
+ - 'f' is consumed-and-discarded (unknown to Eggdrop but isupport says
+ it takes an arg),
+ - 'k' still applies with `test_k`.
+ """
+ prefix = "(ohv)@%+"
+ chanmodes = "beI,kfL,lj,psmntirRcOAQKVCuzNSMTG" # 'f' is KEY-type here
+
+ drive_registration(
+ mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"]
+ )
+ chan = drive_join_with_names(mock_ircd, "@TestBot alice")
+ wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes)
+ wait_for(
+ lambda: tcl_bridge.eval_ok(f'onchan alice "{chan}"') == "1",
+ timeout=5.0,
+ description="alice to appear in chanlist",
+ )
+
+ mock_ircd.send(f":someop!u@h MODE {chan} +ofk alice test_f test_k")
+
+ wait_for(
+ lambda: tcl_bridge.eval_ok(f'isop alice "{chan}"') == "1",
+ timeout=5.0,
+ description="alice to be opped via prefix +o",
+ )
+ chanmode_str = tcl_bridge.eval_ok(f'getchanmode "{chan}"')
+ flags, _, key = chanmode_str.partition(" ")
+ assert key.strip() == "test_k", chanmode_str
+ assert "f" not in flags, chanmode_str
+
+
+def test_gotmode_extended_prefix_modes_qa_consume_args_correctly(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """With PREFIX=(qaohv)~&@%+, MODE `+qav nick1 nick2 nick3` consumes args
+ for each prefix mode (owner/admin/voice).
+
+ Eggdrop only tracks op/halfop/voice flags by default, but the parser
+ must still consume all three args correctly so the next mode in line
+ isn't shifted. Verified by checking voice on the third nick.
+ """
+ prefix = "(qaohv)~&@%+"
+ chanmodes = "beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc"
+
+ drive_registration(
+ mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"]
+ )
+ chan = drive_join_with_names(mock_ircd, "@TestBot owner_user admin_user voice_user")
+ wait_for_isupport(tcl_bridge, "PREFIX", prefix)
+ wait_for(
+ lambda: tcl_bridge.eval_ok(f'onchan voice_user "{chan}"') == "1",
+ timeout=5.0,
+ description="voice_user to appear in chanlist",
+ )
+
+ mock_ircd.send(f":someop!u@h MODE {chan} +qav owner_user admin_user voice_user")
+
+ wait_for(
+ lambda: tcl_bridge.eval_ok(f'isvoice voice_user "{chan}"') == "1",
+ timeout=5.0,
+ description="voice_user to be voiced after +qav consumed all 3 args",
+ )
+
+
+def test_names_with_extended_prefix_grants_op_when_opchars_includes_it(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """NAMES line with `~` (owner) prefix grants op when `opchars` contains it.
+
+ Eggdrop's op-recognition uses the `opchars` set; for networks with
+ extended prefixes admins typically configure `opchars "~&@"`. With that
+ config, a `~user` in NAMES (and the corresponding WHO 352 with `H~` in
+ flags) lands as op.
+ """
+ prefix = "(qaohv)~&@%+"
+
+ drive_registration(mock_ircd, isupport_tokens=[f"PREFIX={prefix}"])
+ # Set opchars to include owner/admin symbols. Must happen before the JOIN
+ # is driven because that's when NAMES + WHO 352 entries are processed.
+ tcl_bridge.eval_ok('set opchars "~&@"')
+
+ chan = drive_join_with_names(mock_ircd, "@TestBot ~bigboss +regular")
+ wait_for(
+ lambda: tcl_bridge.eval_ok(f'onchan bigboss "{chan}"') == "1",
+ timeout=5.0,
+ description="bigboss to appear in chanlist",
+ )
+ assert tcl_bridge.eval_ok(f'isop bigboss "{chan}"') == "1"
+ assert tcl_bridge.eval_ok(f'isvoice regular "{chan}"') == "1"
diff --git a/tests/tests/test_partyline_chan.py b/tests/tests/test_partyline_chan.py
new file mode 100644
index 0000000000..da65370dea
--- /dev/null
+++ b/tests/tests/test_partyline_chan.py
@@ -0,0 +1,39 @@
+"""Partyline integration test: drive HQ partyline via stdin, verify via bridge."""
+
+from __future__ import annotations
+
+import pytest
+
+from support.bridge_client import BridgeClient
+from support.eggdrop_proc import EggdropProc
+from support.irc_helpers import drive_join_with_names, drive_registration
+from support.mock_ircd import MockIrcd
+from support.waiters import wait_for
+
+
+@pytest.mark.partyline
+def test_partyline_add_channel(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """Add a channel via the HQ partyline `.+chan` command, verify via bridge."""
+ drive_registration(mock_ircd)
+ drive_join_with_names(mock_ircd, "@TestBot")
+
+ # Sanity: only the templated #test is configured.
+ assert tcl_bridge.eval_ok("llength [channels]") == "1"
+
+ # Drive the HQ partyline. The HQ user is `-HQ` with full owner perms in
+ # -nt mode, so no auth handshake is needed.
+ eggdrop_proc.send_partyline(".+chan #pytest")
+
+ # The command runs asynchronously inside Eggdrop's event loop. Poll the
+ # bridge until the new channel is visible (or the wait_for times out).
+ wait_for(
+ lambda: tcl_bridge.eval_ok(
+ 'expr {[lsearch [channels] "#pytest"] >= 0}'
+ ) == "1",
+ timeout=5.0,
+ description="partyline .+chan #pytest to register",
+ )
diff --git a/tests/tests/test_smoke_connect.py b/tests/tests/test_smoke_connect.py
new file mode 100644
index 0000000000..d8841608f1
--- /dev/null
+++ b/tests/tests/test_smoke_connect.py
@@ -0,0 +1,34 @@
+"""End-to-end smoke test: spawn Eggdrop, register, join, introspect via bridge."""
+
+from __future__ import annotations
+
+from support.bridge_client import BridgeClient
+from support.eggdrop_proc import EggdropProc
+from support.irc_helpers import drive_join_with_names, drive_registration
+from support.mock_ircd import MockIrcd
+
+
+def test_bridge_alive(tcl_bridge: BridgeClient) -> None:
+ """Eggdrop boots, the bridge listens, Tcl evaluates."""
+ assert tcl_bridge.eval_ok("expr {2 + 2}") == "4"
+ assert tcl_bridge.eval_ok("info patchlevel") # non-empty
+ assert tcl_bridge.eval_ok("set ::nick") == "TestBot"
+
+
+def test_connect_register_join_and_introspect(
+ eggdrop_proc: EggdropProc,
+ mock_ircd: MockIrcd,
+ tcl_bridge: BridgeClient,
+) -> None:
+ """Full happy path: register, join, introspect."""
+ drive_registration(mock_ircd)
+ chan = drive_join_with_names(mock_ircd, "@TestBot")
+ assert chan == "#test"
+
+ # Use lsearch in Tcl so list quoting (curly-brace wrapping of names that
+ # start with '#') doesn't trip us up.
+ assert tcl_bridge.eval_ok('expr {[lsearch [channels] "#test"] >= 0}') == "1"
+
+ # Owner host from the rendered userfile is *!*@127.0.0.1.
+ hosts = tcl_bridge.eval_ok("getuser owner HOSTS")
+ assert "127.0.0.1" in hosts
diff --git a/tests/tests/test_tcl_addbot.py b/tests/tests/test_tcl_addbot.py
new file mode 100644
index 0000000000..aaee5ab858
--- /dev/null
+++ b/tests/tests/test_tcl_addbot.py
@@ -0,0 +1,175 @@
+"""Converted from eggdrop-tests/eggdrop_tcl_addbot.bats.
+
+Tests for the `addbot` Tcl command — adding a bot record with various
+address/port formats. Verifies the result is the expected `botaddr`
+triple `host port port` (or `host botport userport`).
+
+Skipped from the original bats: the two cases that required a second
+non-IPv6 eggdrop instance to verify rejection (port < 1, port > 65535).
+Those need a multi-bot harness which is out of scope for this framework.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from support.bridge_client import BridgeClient
+
+DEFAULT_PORT = "3333" # default-port set in the templated config
+BOT = "testbot"
+
+
+@pytest.fixture
+def ipv6_required(tcl_bridge: BridgeClient) -> None:
+ """Skip the test if Eggdrop wasn't compiled with IPv6 support."""
+ # `info procs` won't work; check for IPv6 by trying an IPv6 add and seeing
+ # if it succeeds. A simpler approach: peek at `set prefer-ipv6` (only set
+ # when the build has IPv6). Use the actual `addbot` smoke test as the
+ # truthy probe — addbot returns "0" if IPv6 isn't supported.
+ tcl_bridge.eval_ok("deluser ipv6probe")
+ if tcl_bridge.eval_ok("addbot ipv6probe ::1") != "1":
+ pytest.skip("Eggdrop built without IPv6 support")
+ tcl_bridge.eval_ok("deluser ipv6probe")
+
+
+# ---------- legacy 'addbot handle address ?botport ?userport??' format ----------
+
+
+def test_addbot_handle_ipv4_uses_default_port(tcl_bridge: BridgeClient) -> None:
+ assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1") == "1"
+ assert (
+ tcl_bridge.eval_ok(f"getuser {BOT} botaddr")
+ == f"1.1.1.1 {DEFAULT_PORT} {DEFAULT_PORT}"
+ )
+
+
+def test_addbot_handle_ipv6_uses_default_port(
+ tcl_bridge: BridgeClient, ipv6_required: None
+) -> None:
+ assert tcl_bridge.eval_ok(f"addbot {BOT} fe80::69ec:cfe4:81de:4fe5") == "1"
+ assert (
+ tcl_bridge.eval_ok(f"getuser {BOT} botaddr")
+ == f"fe80::69ec:cfe4:81de:4fe5 {DEFAULT_PORT} {DEFAULT_PORT}"
+ )
+
+
+def test_addbot_handle_ipv4_with_botport(tcl_bridge: BridgeClient) -> None:
+ assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1 5555") == "1"
+ assert tcl_bridge.eval_ok(f"getuser {BOT} botaddr") == "1.1.1.1 5555 5555"
+
+
+def test_addbot_handle_ipv6_with_botport(
+ tcl_bridge: BridgeClient, ipv6_required: None
+) -> None:
+ assert tcl_bridge.eval_ok(f"addbot {BOT} fe80::69ec:cfe4:81de:4fe5 6666") == "1"
+ assert (
+ tcl_bridge.eval_ok(f"getuser {BOT} botaddr")
+ == "fe80::69ec:cfe4:81de:4fe5 6666 6666"
+ )
+
+
+def test_addbot_handle_ipv4_with_botport_and_userport(
+ tcl_bridge: BridgeClient,
+) -> None:
+ assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1 5555 6666") == "1"
+ assert tcl_bridge.eval_ok(f"getuser {BOT} botaddr") == "1.1.1.1 5555 6666"
+
+
+def test_addbot_handle_ipv6_with_botport_and_userport(
+ tcl_bridge: BridgeClient, ipv6_required: None
+) -> None:
+ assert (
+ tcl_bridge.eval_ok(f"addbot {BOT} fe80::69ec:cfe4:81de:4fe5 6666 7777")
+ == "1"
+ )
+ assert (
+ tcl_bridge.eval_ok(f"getuser {BOT} botaddr")
+ == "fe80::69ec:cfe4:81de:4fe5 6666 7777"
+ )
+
+
+# ---------- ipv4:port packed format ----------
+
+
+def test_addbot_handle_ipv4_colon_port(tcl_bridge: BridgeClient) -> None:
+ assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1:4444") == "1"
+ assert tcl_bridge.eval_ok(f"getuser {BOT} botaddr") == "1.1.1.1 4444 4444"
+
+
+def test_addbot_with_packed_address_ignores_extra_args(
+ tcl_bridge: BridgeClient,
+) -> None:
+ """When the address arg uses `:`/`/` separators, extra positional args
+ are ignored (the packed form is canonical)."""
+ assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1:4444/5555 6666") == "1"
+ assert tcl_bridge.eval_ok(f"getuser {BOT} botaddr") == "1.1.1.1 4444 5555"
+
+
+# ---------- bracketed IPv6 ----------
+
+
+def test_addbot_bracketed_ipv6_with_colon_botport(
+ tcl_bridge: BridgeClient, ipv6_required: None
+) -> None:
+ assert (
+ tcl_bridge.eval_ok(f"addbot {BOT} \\[fe80::69ec:cfe4:81de:4fe5\\]:4444")
+ == "1"
+ )
+ assert (
+ tcl_bridge.eval_ok(f"getuser {BOT} botaddr")
+ == "fe80::69ec:cfe4:81de:4fe5 4444 4444"
+ )
+
+
+def test_addbot_bracketed_ipv6_with_colon_botport_slash_userport(
+ tcl_bridge: BridgeClient, ipv6_required: None
+) -> None:
+ assert (
+ tcl_bridge.eval_ok(
+ f"addbot {BOT} \\[fe80::69ec:cfe4:81de:4fe5\\]:4444/5555"
+ )
+ == "1"
+ )
+ assert (
+ tcl_bridge.eval_ok(f"getuser {BOT} botaddr")
+ == "fe80::69ec:cfe4:81de:4fe5 4444 5555"
+ )
+
+
+def test_addbot_bracketed_ipv6_with_separate_botport(
+ tcl_bridge: BridgeClient, ipv6_required: None
+) -> None:
+ assert (
+ tcl_bridge.eval_ok(f"addbot {BOT} \\[fe80::69ec:cfe4:81de:4fe5\\] 4444")
+ == "1"
+ )
+ assert (
+ tcl_bridge.eval_ok(f"getuser {BOT} botaddr")
+ == "fe80::69ec:cfe4:81de:4fe5 4444 4444"
+ )
+
+
+def test_addbot_bracketed_ipv6_with_separate_botport_userport(
+ tcl_bridge: BridgeClient, ipv6_required: None
+) -> None:
+ assert (
+ tcl_bridge.eval_ok(
+ f"addbot {BOT} \\[fe80::69ec:cfe4:81de:4fe5\\] 4444 5555"
+ )
+ == "1"
+ )
+ assert (
+ tcl_bridge.eval_ok(f"getuser {BOT} botaddr")
+ == "fe80::69ec:cfe4:81de:4fe5 4444 5555"
+ )
+
+
+def test_addbot_ipv6_with_trailing_slash_uses_default_ports(
+ tcl_bridge: BridgeClient, ipv6_required: None
+) -> None:
+ """A trailing `/` after IPv6 means "no port given", default-port applies."""
+ assert tcl_bridge.eval_ok(f"addbot {BOT} fe80::69ec:cfe4:81de:4fe5/") == "1"
+ assert (
+ tcl_bridge.eval_ok(f"getuser {BOT} botaddr")
+ == f"fe80::69ec:cfe4:81de:4fe5 {DEFAULT_PORT} {DEFAULT_PORT}"
+ )
diff --git a/tests/tests/test_tcl_iscmds.py b/tests/tests/test_tcl_iscmds.py
new file mode 100644
index 0000000000..59f864e54b
--- /dev/null
+++ b/tests/tests/test_tcl_iscmds.py
@@ -0,0 +1,197 @@
+"""Converted from eggdrop-tests/eggdrop_tcl_iscmds.bats.
+
+Tests for `isban`, `isbansticky`, `isexempt`, `isinvite` — the 4 lookup
+commands for global vs channel ban/exempt/invite lists.
+
+The original bats test had a single shared eggdrop and mutated state
+between cases; here each test gets a fresh spawn and sets up its own
+state inline, which makes the intent of each case obvious in isolation.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from support.bridge_client import BridgeClient
+
+CHAN = "#foober"
+HOST = "*!test@foo.com"
+
+
+@pytest.fixture
+def chan(tcl_bridge: BridgeClient) -> str:
+ """Add #foober and return its name."""
+ tcl_bridge.eval_ok(f"channel add {CHAN}")
+ return CHAN
+
+
+# ---------- isban ----------
+
+
+def test_isban_returns_0_when_no_global_or_channel_ban(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"isban {HOST}") == "0"
+
+
+def test_isban_returns_1_when_only_global_ban_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newban {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isban {HOST}") == "1"
+
+
+def test_isban_with_channel_returns_1_when_only_channel_ban_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isban {HOST} {chan}") == "1"
+
+
+def test_isban_returns_1_when_both_global_and_channel_ban_exist(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newban {HOST} testuser comment")
+ tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isban {HOST} {chan}") == "1"
+
+
+def test_isban_with_channel_returns_1_for_global_when_only_channel_ban_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ """isban without a channel hits the global list — global ban present → 1."""
+ tcl_bridge.eval_ok(f"newban {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isban {HOST}") == "1"
+
+
+def test_isban_with_channel_returns_0_when_only_global_ban_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ """isban WITH a channel checks both — but if neither global+channel match,
+ returns 0. Here only global is set, so isban for chan-scope returns 1
+ (eggdrop combines global + chan)."""
+ tcl_bridge.eval_ok(f"newban {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isban {HOST} {chan}") == "1"
+
+
+def test_isban_returns_0_when_only_channel_ban_exists_no_chan_arg(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ """Without a channel arg, isban only looks at the global list."""
+ tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isban {HOST}") == "0"
+
+
+# ---------- isbansticky ----------
+
+
+def test_isbansticky_returns_0_when_no_global_or_channel_sticky_ban(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"isbansticky {HOST}") == "0"
+
+
+def test_isbansticky_returns_1_when_only_global_sticky_ban_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newban {HOST} testuser comment 60 sticky")
+ assert tcl_bridge.eval_ok(f"isbansticky {HOST}") == "1"
+
+
+def test_isbansticky_with_channel_returns_1_when_only_channel_sticky_ban_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment 60 sticky")
+ assert tcl_bridge.eval_ok(f"isbansticky {HOST} {chan}") == "1"
+
+
+def test_isbansticky_returns_1_when_both_sticky_bans_exist(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newban {HOST} testuser comment 60 sticky")
+ tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment 60 sticky")
+ assert tcl_bridge.eval_ok(f"isbansticky {HOST} {chan}") == "1"
+
+
+def test_isbansticky_returns_0_when_only_channel_sticky_ban_no_chan_arg(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment 60 sticky")
+ # Original bats test asserts isban (not isbansticky) returns 0 here. Match it.
+ assert tcl_bridge.eval_ok(f"isban {HOST}") == "0"
+
+
+# ---------- isexempt ----------
+
+
+def test_isexempt_returns_0_when_no_global_or_channel_exempt(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"isexempt {HOST}") == "0"
+
+
+def test_isexempt_returns_1_when_only_global_exempt_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newexempt {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isexempt {HOST}") == "1"
+
+
+def test_isexempt_with_channel_returns_1_when_only_channel_exempt_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newchanexempt {chan} {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isexempt {HOST} {chan}") == "1"
+
+
+def test_isexempt_returns_1_when_both_global_and_channel_exempt_exist(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newexempt {HOST} testuser comment")
+ tcl_bridge.eval_ok(f"newchanexempt {chan} {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isexempt {HOST} {chan}") == "1"
+
+
+def test_isexempt_returns_0_when_only_channel_exempt_no_chan_arg(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newchanexempt {chan} {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isexempt {HOST}") == "0"
+
+
+# ---------- isinvite ----------
+
+
+def test_isinvite_returns_0_when_no_global_or_channel_invite(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"isinvite {HOST}") == "0"
+
+
+def test_isinvite_returns_1_when_only_global_invite_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newinvite {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isinvite {HOST}") == "1"
+
+
+def test_isinvite_with_channel_returns_1_when_only_channel_invite_exists(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newchaninvite {chan} {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isinvite {HOST} {chan}") == "1"
+
+
+def test_isinvite_returns_1_when_both_global_and_channel_invite_exist(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newinvite {HOST} testuser comment")
+ tcl_bridge.eval_ok(f"newchaninvite {chan} {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isinvite {HOST} {chan}") == "1"
+
+
+def test_isinvite_returns_0_when_only_channel_invite_no_chan_arg(
+ tcl_bridge: BridgeClient, chan: str
+) -> None:
+ tcl_bridge.eval_ok(f"newchaninvite {chan} {HOST} testuser comment")
+ assert tcl_bridge.eval_ok(f"isinvite {HOST}") == "0"
diff --git a/tests/tests/test_tcl_matchattr.py b/tests/tests/test_tcl_matchattr.py
new file mode 100644
index 0000000000..230a7a8b10
--- /dev/null
+++ b/tests/tests/test_tcl_matchattr.py
@@ -0,0 +1,207 @@
+"""Converted from eggdrop-tests/eggdrop_tcl_matchattr.bats.
+
+Tests for `matchattr` — Tcl flag-matching against a user record. Covers
+global flags, channel flags, the `&` (all-of) operator, and rejection of
+unknown flags.
+
+Original setup:
+ adduser foo
+ channel add #foober
+ chattr foo +jlmoptx|+lov #foober
+
+Result: foo has global +jlmoptx and channel +lov on #foober.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from support.bridge_client import BridgeClient
+
+CHAN = "#foober"
+
+
+@pytest.fixture
+def matchattr_user(tcl_bridge: BridgeClient) -> str:
+ """Per-test setup: adduser foo with global +jlmoptx and channel +lov on #foober."""
+ tcl_bridge.eval_ok(f"channel add {CHAN}")
+ tcl_bridge.eval_ok("adduser foo")
+ tcl_bridge.eval_ok(f"chattr foo +jlmoptx|+lov {CHAN}")
+ return "foo"
+
+
+# ---------- + (any-of) on global flags ----------
+
+
+def test_matchattr_single_global_plus_flag_matches_when_user_has_it(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok("matchattr foo +o") == "1"
+
+
+def test_matchattr_single_global_plus_flag_does_not_match_when_user_lacks_it(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok("matchattr foo +g") == "0"
+
+
+def test_matchattr_two_global_plus_flags_matches_if_user_has_one(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ """+ is any-of by default: +on matches if user has o OR n."""
+ assert tcl_bridge.eval_ok("matchattr foo +on") == "1"
+
+
+def test_matchattr_two_global_plus_flags_does_not_match_if_user_has_neither(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok("matchattr foo +gn") == "0"
+
+
+# ---------- & (all-of) operator on global flags ----------
+
+
+def test_matchattr_two_global_plus_flags_with_amp_matches_if_user_has_both(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ """& makes it all-of: +mo& matches if user has BOTH global m AND o."""
+ assert tcl_bridge.eval_ok(f"matchattr foo +mo& {CHAN}") == "1"
+
+
+def test_matchattr_two_global_plus_flags_with_amp_does_not_match_if_user_has_one(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo +mn& {CHAN}") == "0"
+
+
+# ---------- - (none-of) on global flags ----------
+
+
+def test_matchattr_single_global_minus_flag_matches_when_user_lacks_it(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ """-n matches if user does NOT have n."""
+ assert tcl_bridge.eval_ok("matchattr foo -n") == "1"
+
+
+def test_matchattr_single_global_minus_flag_does_not_match_when_user_has_it(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok("matchattr foo -m") == "0"
+
+
+def test_matchattr_two_global_minus_flags_matches_if_user_lacks_one(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok("matchattr foo -mn") == "1"
+
+
+def test_matchattr_two_global_minus_flags_matches_if_user_lacks_both(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok("matchattr foo -gn") == "1"
+
+
+def test_matchattr_two_global_minus_flags_does_not_match_if_user_has_both(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok("matchattr foo -om") == "0"
+
+
+# ---------- channel flags (| separator) ----------
+
+
+def test_matchattr_single_channel_plus_flag_matches_when_user_has_it(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |+o {CHAN}") == "1"
+
+
+def test_matchattr_single_channel_plus_flag_does_not_match_when_user_lacks_it(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |+g {CHAN}") == "0"
+
+
+def test_matchattr_two_channel_plus_flags_matches_if_user_has_one(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |+on {CHAN}") == "1"
+
+
+def test_matchattr_two_channel_plus_flags_does_not_match_if_user_has_neither(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |+gn {CHAN}") == "0"
+
+
+def test_matchattr_two_channel_plus_flags_with_amp_matches_if_user_has_both(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo &+lo {CHAN}") == "1"
+
+
+def test_matchattr_two_channel_plus_flags_with_amp_does_not_match_if_user_has_one(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo &+om {CHAN}") == "0"
+
+
+def test_matchattr_single_channel_minus_flag_matches_when_user_lacks_it(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |-n {CHAN}") == "1"
+
+
+def test_matchattr_single_channel_minus_flag_does_not_match_when_user_has_it(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |-o {CHAN}") == "0"
+
+
+def test_matchattr_two_channel_minus_flags_matches_if_user_lacks_one(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |-on {CHAN}") == "1"
+
+
+def test_matchattr_two_channel_minus_flags_matches_if_user_lacks_both(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |-gn {CHAN}") == "1"
+
+
+def test_matchattr_two_channel_minus_flags_does_not_match_if_user_has_both(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |-ov {CHAN}") == "0"
+
+
+# ---------- error paths (behavior changed since the original bats tests) ----------
+#
+# The original bats suite asserted that matchattr returned a Tcl error
+# `Unknown flag specified for matching` for unknown global/channel/bot flags.
+# The current implementation in src/tcluser.c (tcl_matchattr) calls
+# break_down_flags() which silently ignores unknown characters, then if the
+# resulting flag set is empty returns "1" (the "no flags matches anyone"
+# branch). The "rejection" is gone — these tests now document the new
+# silent-accept behavior instead.
+
+
+def test_matchattr_silently_accepts_unknown_global_flag(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ """`+s` is unknown globally; ignored, leaving an empty plus set → matches anyone."""
+ assert tcl_bridge.eval_ok("matchattr foo +s") == "1"
+
+
+def test_matchattr_silently_accepts_unknown_channel_flag(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok(f"matchattr foo |+j {CHAN}") == "1"
+
+
+def test_matchattr_silently_accepts_unknown_bot_flag(
+ tcl_bridge: BridgeClient, matchattr_user: str
+) -> None:
+ assert tcl_bridge.eval_ok("matchattr foo ||+f") == "1"
diff --git a/tests/tests/test_tcl_passwdok.py b/tests/tests/test_tcl_passwdok.py
new file mode 100644
index 0000000000..cd2b19b63a
--- /dev/null
+++ b/tests/tests/test_tcl_passwdok.py
@@ -0,0 +1,54 @@
+"""Converted from eggdrop-tests/eggdrop_tcl_passwdok.bats.
+
+Tests for the `passwdok` Tcl command — verifies user passwords match
+their stored value, with correct handling of the empty / dash sentinels.
+"""
+
+from __future__ import annotations
+
+from support.bridge_client import BridgeClient
+
+
+def test_passwdok_returns_1_when_password_matches(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("adduser foo")
+ tcl_bridge.eval_ok("setuser foo PASS asdf")
+ assert tcl_bridge.eval_ok("passwdok foo asdf") == "1"
+
+
+def test_passwdok_returns_0_when_password_differs(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("adduser foo")
+ tcl_bridge.eval_ok("setuser foo PASS asdf")
+ assert tcl_bridge.eval_ok("passwdok foo notasdf") == "0"
+
+
+def test_passwdok_returns_1_for_dash_when_user_has_no_password(
+ tcl_bridge: BridgeClient,
+) -> None:
+ """A `-` password literal matches a user that has no password set."""
+ tcl_bridge.eval_ok("adduser foo")
+ tcl_bridge.eval_ok("setuser foo PASS {}") # clear password
+ assert tcl_bridge.eval_ok("passwdok foo -") == "1"
+
+
+def test_passwdok_returns_0_for_dash_when_user_has_a_password(
+ tcl_bridge: BridgeClient,
+) -> None:
+ tcl_bridge.eval_ok("adduser foo")
+ tcl_bridge.eval_ok("setuser foo PASS asdf")
+ assert tcl_bridge.eval_ok("passwdok foo -") == "0"
+
+
+def test_passwdok_returns_0_for_empty_when_user_has_no_password(
+ tcl_bridge: BridgeClient,
+) -> None:
+ tcl_bridge.eval_ok("adduser foo")
+ tcl_bridge.eval_ok("setuser foo PASS {}")
+ assert tcl_bridge.eval_ok('passwdok foo ""') == "0"
+
+
+def test_passwdok_returns_0_for_empty_when_user_has_a_password(
+ tcl_bridge: BridgeClient,
+) -> None:
+ tcl_bridge.eval_ok("adduser foo")
+ tcl_bridge.eval_ok("setuser foo PASS asdf")
+ assert tcl_bridge.eval_ok('passwdok foo ""') == "0"
diff --git a/tests/tests/test_tcl_server.py b/tests/tests/test_tcl_server.py
new file mode 100644
index 0000000000..661b0d92a8
--- /dev/null
+++ b/tests/tests/test_tcl_server.py
@@ -0,0 +1,139 @@
+"""Converted from eggdrop-tests/eggdrop_tcl_server.bats.
+
+Tests for the `server` Tcl command — adding, removing, and listing the
+in-memory server list.
+
+API note: the original bats tests used the old `addserver`/`delserver`/
+`set servers` interface, which no longer exists. The current command is
+`server add HOST ?PORT? ?PASS?` / `server remove HOST ?PORT?` /
+`server list` (returns a list of `{host port pass}` triples).
+"""
+
+from __future__ import annotations
+
+from support.bridge_client import BridgeClient
+
+
+def _server_entries(tcl_bridge: BridgeClient) -> list[list[str]]:
+ """Return server list as a list of [host, port, pass] entries.
+
+ `server list` formats each entry as a Tcl list `{host port pass}`. We
+ parse it via Tcl `lmap` to keep things simple on the Python side.
+ """
+ raw = tcl_bridge.eval_ok(
+ 'lmap entry [server list] {format "%s|%s|%s" '
+ "[lindex $entry 0] [lindex $entry 1] [lindex $entry 2]}"
+ )
+ return [e.split("|") for e in raw.split() if e]
+
+
+def _server_in_list(
+ tcl_bridge: BridgeClient, host: str, port: str | None = None
+) -> bool:
+ for entry in _server_entries(tcl_bridge):
+ if entry[0] == host and (port is None or entry[1] == port):
+ return True
+ return False
+
+
+# ---------- server add ----------
+
+
+def test_server_add_just_host_uses_default_port(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add irc.foo.com")
+ # Entry exists; port may be the configured default-port or empty.
+ assert _server_in_list(tcl_bridge, "irc.foo.com")
+
+
+def test_server_add_with_explicit_port(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add irc.ferg.com 8877")
+ assert _server_in_list(tcl_bridge, "irc.ferg.com", "8877")
+
+
+def test_server_add_with_port_and_password(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add irc.moo.com 4455 mypass")
+ for entry in _server_entries(tcl_bridge):
+ if entry[0] == "irc.moo.com" and entry[1] == "4455":
+ assert entry[2] == "mypass"
+ return
+ raise AssertionError("irc.moo.com:4455 not in server list")
+
+
+def test_server_add_ssl_port_keeps_plus_prefix(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add irc.snell.com +7000")
+ assert _server_in_list(tcl_bridge, "irc.snell.com", "+7000")
+
+
+def test_server_add_ipv6_address(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add 2344:2344:2344::5433:5433 5555")
+ assert _server_in_list(tcl_bridge, "2344:2344:2344::5433:5433", "5555")
+
+
+def test_server_add_ipv4_address(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add 1.2.3.4 4444")
+ assert _server_in_list(tcl_bridge, "1.2.3.4", "4444")
+
+
+# ---------- server remove ----------
+
+
+def test_server_remove_first_element(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add irc.first.com 1111")
+ tcl_bridge.eval_ok("server add irc.second.com 2222")
+ tcl_bridge.eval_ok("server remove irc.first.com 1111")
+ assert not _server_in_list(tcl_bridge, "irc.first.com", "1111")
+ assert _server_in_list(tcl_bridge, "irc.second.com", "2222")
+
+
+def test_server_remove_middle_element(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add a.com 1111")
+ tcl_bridge.eval_ok("server add b.com 2222")
+ tcl_bridge.eval_ok("server add c.com 3333")
+ tcl_bridge.eval_ok("server remove b.com 2222")
+ assert _server_in_list(tcl_bridge, "a.com", "1111")
+ assert not _server_in_list(tcl_bridge, "b.com", "2222")
+ assert _server_in_list(tcl_bridge, "c.com", "3333")
+
+
+def test_server_remove_ipv6(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add 2344:2344:2344::5433:5433 5555")
+ tcl_bridge.eval_ok("server remove 2344:2344:2344::5433:5433 5555")
+ assert not _server_in_list(tcl_bridge, "2344:2344:2344::5433:5433")
+
+
+def test_server_remove_ipv4(tcl_bridge: BridgeClient) -> None:
+ tcl_bridge.eval_ok("server add 1.2.3.4 4444")
+ tcl_bridge.eval_ok("server remove 1.2.3.4 4444")
+ assert not _server_in_list(tcl_bridge, "1.2.3.4", "4444")
+
+
+def test_server_remove_no_port_removes_all_matching_entries(
+ tcl_bridge: BridgeClient,
+) -> None:
+ """Behavior change since the original bats: `server remove host` with no
+ port iterates the list and removes EVERY entry matching `host`. The bats
+ test assumed only the first match was removed."""
+ tcl_bridge.eval_ok("server add irc.firstmatch.com 1111")
+ tcl_bridge.eval_ok("server add irc.firstmatch.com 2222")
+ tcl_bridge.eval_ok("server remove irc.firstmatch.com")
+ assert not _server_in_list(tcl_bridge, "irc.firstmatch.com")
+
+
+def test_server_remove_with_port_only_removes_matching_host_port(
+ tcl_bridge: BridgeClient,
+) -> None:
+ tcl_bridge.eval_ok("server add irc.firstmatch.com 1111")
+ tcl_bridge.eval_ok("server add irc.firstmatch.com 2222")
+ tcl_bridge.eval_ok("server remove irc.firstmatch.com 2222")
+ assert _server_in_list(tcl_bridge, "irc.firstmatch.com", "1111")
+ assert not _server_in_list(tcl_bridge, "irc.firstmatch.com", "2222")
+
+
+# ---------- error paths ----------
+
+
+def test_server_add_rejects_colon_port_in_address(tcl_bridge: BridgeClient) -> None:
+ """`host:port` syntax is forbidden — port must be a separate argument."""
+ ok, result = tcl_bridge.eval("server add irc.port.com:1111")
+ assert not ok
+ assert "Make sure the port is" in result
diff --git a/tests/uv.lock b/tests/uv.lock
new file mode 100644
index 0000000000..64701ccd08
--- /dev/null
+++ b/tests/uv.lock
@@ -0,0 +1,183 @@
+version = 1
+revision = 1
+requires-python = ">=3.11"
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "eggdrop-tests"
+version = "0.0.0"
+source = { virtual = "." }
+
+[package.dev-dependencies]
+dev = [
+ { name = "jinja2" },
+ { name = "pytest" },
+ { name = "pytest-timeout" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "jinja2", specifier = ">=3.1" },
+ { name = "pytest", specifier = ">=8" },
+ { name = "pytest-timeout", specifier = ">=2.3" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 },
+]
+
+[[package]]
+name = "pytest-timeout"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 },
+]