diff --git a/.editorconfig b/.editorconfig index c25c645..2fec281 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,60 +1,27 @@ -# Space or Tabs? -# https://stackoverflow.com/questions/35649847/objective-reasons-for-using-spaces-instead-of-tabs-for-indentation -# https://stackoverflow.com/questions/12093748/how-to-use-tabs-instead-of-spaces-in-a-shell-script -# https://github.com/editorconfig/editorconfig-defaults/blob/master/editorconfig-defaults.json -# -# 1. What happens when I press the Tab key in my text editor? -# 2. What happens when I request my editor to indent one or more lines? -# 3. What happens when I view a file containing U+0009 HORIZONTAL TAB characters? -# -# Answers: -# -# 1. Pressing the Tab key should indent the current line (or selected lines) one additional level. -# 2. As a secondary alternative, I can also tolerate an editor that, -# like Emacs, uses this key for a context-sensitive fix-my-indentation command. -# 3. Indenting one or more lines should follow the reigning convention, if consensus is sufficiently strong; otherwise, -# I greatly prefer 2-space indentation at each level. U+0009 characters should shift subsequent characters to the next tab stop. -# -# Note: VIM users should use alternate marks [[[ and ]]] as the original ones can confuse nested substitutions, e.g.: ${${${VAR}}} -# -# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- -# vim: ft=zsh sw=2 ts=2 et - root = true [*] charset = utf-8 -indent_style = space -indent_size = 2 +end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true - -[*.sln] -indent_style = tab +indent_style = space +indent_size = 2 [*.{md,mdx,rst}] trim_trailing_whitespace = false -[*.{cmd,bat}] -end_of_line = crlf - -[*za-*] -end_of_line = lf - -[*.{sh,bash,zsh,fish}] -end_of_line = lf - -[Makefile] +[Makefile*] indent_style = tab indent_size = 4 -[*.{py,rb}] +[*.py] indent_size = 4 -[*.{go,java,scala,groovy,kotlin}] +[*.go] indent_style = tab indent_size = 4 -[*.{cs,csx,cake,vb,vbx}] -# Default Severity for all .NET Code Style rules below -dotnet_analyzer_diagnostic.severity = warning +[*.java] +indent_style = tab +indent_size = 4 diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 0000000..e69de29 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 1ac8ec4..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,106 +0,0 @@ -# Copilot Instructions — z-shell/zd - -## Project overview - -`zd` is a **Zi Docker environment** — an Alpine Linux–based Docker image that provides a ready-to-use Zsh + [Zi](https://github.com/z-shell/zi) plugin-manager environment. The repository contains: - -- The `Dockerfile` and supporting shell scripts that build the image. -- ZUnit integration tests that exercise Zi plugin/snippet/package installation inside a running container. -- A `run.sh` helper that launches the container with sensible defaults. - -## Repository layout - -``` -docker/ - Dockerfile # Alpine-based image definition - entrypoint.sh # POSIX sh setup script run at image-build time (root) - init.zsh # Optional user-supplied init script sourced on startup - utils.zsh # Zsh helper functions (prepare_system, initiate_system, zi::*) - zshenv # Zsh env bootstrap (ZI config, PATH additions) - zshrc # Zsh startup config — sources utils.zsh and calls prepare_system/initiate_system - build.sh # Bash helper to docker-build the image with appropriate ARGs - run.sh # Bash helper to docker-run the image - zunit.sh # Bash wrapper that invokes `zunit run --verbose` - docker-compose.yml # Compose file for interactive use - tests/ - setup.zsh # ZUnit @setup: exports DATA_DIR, PLUGINS_DIR, SNIPPETS_DIR, ZPFX - teardown.zsh # ZUnit @teardown: removes DATA_DIR - plugins.zunit # ZUnit tests: fzf, direnv, diff-so-fancy plugin installs - annexes.zunit # ZUnit tests: Zi annex loading - ice.zunit # ZUnit tests: Zi ice-modifier syntax - packages.zunit # ZUnit tests: Zi pack installs - snippets.zunit # ZUnit tests: Zi snippet loading -.github/ - workflows/ - docker.yml # CI: multi-arch Docker build matrix (versioned Zsh + latest) - zunit.yml # CI: ZUnit test matrix (one job per *.zunit file) - codeql.yml # CodeQL security scanning - labeler.yml # Auto-labelling PRs - pr-labels.yml # PR label sync - stale.yml # Stale issue/PR management - lock.yml # Lock closed issues/PRs - rebase.yml # Auto-rebase - sync-labels.yml # Sync labels from config - zsh-n.yml # Zsh -n (syntax check) workflow - ISSUE_TEMPLATE/ # GitHub issue templates - PULL_REQUEST_TEMPLATE.md - CODEOWNERS # @ss-o owns all files -``` - -## Key conventions - -### Shell scripts -- **`entrypoint.sh`** is POSIX `sh` (shebang `#!/usr/bin/env sh`). It runs as root inside the Alpine build context. - - Use `sed -i -r` (BusyBox `sed` extended-regex flag) — **not** `-E`, which is unsupported by BusyBox. - - Never use Bashisms (`[[ ]]`, arrays, `local` with assignment, etc.) in this file. -- **`run.sh`**, **`build.sh`**, **`zunit.sh`** are Bash (shebang `#!/usr/bin/env bash`). 2-space indentation, `# vim: ft=bash sw=2 ts=2 et` modeline. -- **Zsh files** (`utils.zsh`, `zshrc`, `zshenv`, `*.zunit`) use 2-space indentation and the modeline `# vim: ft=zsh sw=2 ts=2 et`. -- All text files: UTF-8, LF line endings. Default indent is 2 spaces, except: - - `Makefile*`: tab indentation with `indent_size=4`. - - `*.py`, `*.rb`: 4-space indentation. - - `*.go`, `*.java`, `*.scala`, `*.groovy`, `*.kotlin`: tab indentation with `indent_size=4`. -- Trailing whitespace is trimmed; files end with a newline (enforced by `.editorconfig`). - -### Dockerfile -- Base image: `alpine:$VERSION` (defaults to `edge`). No GNU coreutils unless explicitly `apk add`ed. -- `SHELL` instructions in a Dockerfile only configure the default shell for subsequent `RUN`/`CMD`/`ENTRYPOINT` — they **do not execute** their arguments. Never use a `SHELL` instruction expecting it to run a command. -- Do **not** invoke interactive shell functions (e.g., Zi's `@zi-scheduler`) in `RUN` steps — they only exist inside a live Zsh session loaded via `.zshrc`. -- Go is installed from `https://go.dev/dl/` (not from `apk`) to get a current release. Bump `ARG GO_VERSION` when a new Go release is available; SHA256 is verified via the `https://go.dev/dl/?mode=json&include=all` API. -- `LABEL` values must be plain strings — no template syntax like `<%= ... =>`. - -### ZUnit tests -- Each `*.zunit` file begins with `@setup { load setup; setup }` and `@teardown { load teardown; teardown }`. -- Every assertion for a binary artifact must include both `assert "$artifact" is_file` **and** `assert "$artifact" is_executable`. -- Use `local artifact=...` for the first artifact in a test; reassign with `artifact=...` (no `local`) for subsequent ones in the same test body. -- Tests run against the published container image via `run.sh --wrap --debug --zunit`. - -### `run.sh` helper -- Use `printf '%s\n' "$*"` (not a custom `say` function) when writing content to a temp file or printing a file path. -- `create_init_config_file` writes `$*` to a `mktemp` file and prints the path on stdout. - -## CI / workflows - -| Workflow | Trigger | What it does | -|---|---|---| -| `docker.yml` | push/PR to `main` touching `docker/**`, scheduled Wed 03:00 UTC | Builds multi-arch image (`linux/amd64`, `linux/arm64`) for Zsh 5.5.1–5.9 matrix + `latest` tag | -| `zunit.yml` | push to `main` touching `*.zunit`, scheduled Mon/Wed/Fri/Sun 12:00 UTC, `workflow_dispatch` | Runs each `*.zunit` file as a separate matrix job | -| `zsh-n.yml` | Zsh `-n` syntax check | Checks all Zsh files for syntax errors | - -### Common build failure causes -1. **`exit 127` in `RUN`**: A command not found — check that it exists in Alpine at build time. -2. **`sed: unrecognized option '-E'`**: Use `-r` instead (BusyBox `sed`). -3. **Zi/Zsh functions not found**: Functions like `@zi-scheduler`, `zi`, `autoload` only exist in a live interactive Zsh session, never at Docker build time. -4. **Go SHA256 mismatch**: The `GO_VERSION` ARG may need updating to a version that exists on `go.dev/dl/`. - -## Development workflow - -1. Edit files in `docker/`. -2. Build locally: `cd docker && ./build.sh` (or `docker compose build`). -3. Run ZUnit tests: `./docker/zunit.sh` (requires a built image). -4. Submit a PR — CI will run both `docker.yml` and `zunit.yml`. - -## Security notes - -- Never commit secrets or credentials. -- The Go tarball SHA256 is verified against the official `go.dev` JSON API before extraction. -- `sudoers` for the container user is scoped to `NOPASSWD: ALL` intentionally for the dev environment. diff --git a/.github/label-commenter-config.yml b/.github/label-commenter-config.yml deleted file mode 100644 index 9b0af06..0000000 --- a/.github/label-commenter-config.yml +++ /dev/null @@ -1,70 +0,0 @@ -comment: - header: Hi, there. - footer: "\n\n > This is an automated comment. Responding to the bot or mentioning it won't have any effect.\n\n" - -labels: - - name: invalid ⚠️ - labeled: - issue: - body: Please follow the issue templates. - action: close - pr: - body: Thank you @{{ pull_request.zunit.login }} for suggesting this. Please follow the pull request templates. - action: close - unlabeled: - issue: - body: Thank you for following the template. The repository owner will reply. - action: open - - name: Q&A ✍️ - labeled: - issue: - body: | - Please ask questions at the Github discussions. - https://github.com/z-shell/zi/discussions/categories/q-a - action: close - - name: priority-low 🔖 - labeled: - issue: - body: "This issue currently can't be resolved, but we appreciate your contribution." - action: close - unlabeled: - issue: - body: This issue may be useful and has become active again. - action: open - - name: beginner-friendly 💕 - labeled: - issue: - body: This issue is easy for contributing. Good for people wanting to contribute to this project. - - name: feature-request 💡 - labeled: - issue: - body: Thank you @{{ issue.zunit.login }} for suggesting this. - - name: locked ‼️ - labeled: - issue: - body: | - This issue has been **LOCKED** because of spam! - - Please do not spam messages and/or issues on the issue tracker. You may get blocked from this repository for doing so. - action: close - locking: lock - lock_reason: spam - pr: - body: | - This pull-request has been **LOCKED** because of spam! - - Please do not spam messages and/or pull-requests on this project. You may get blocked from this repository for doing so. - action: close - locking: lock - lock_reason: spam - - name: resolved ☑️ - labeled: - issue: - body: | - This issue has been **LOCKED** because of it being resolved! - - The issue has been fixed and is therefore considered resolved. - If you still encounter this or it has changed, open a new issue instead of responding to solved ones. - action: close - locking: lock - lock_reason: resolved diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index 8567e32..0000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,4 +0,0 @@ -documentation 📝: - - any: ["docs/**/*.md", "docs/*.md"] -ci 🤖: - - any: [".github/workflows/*.yml"] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aacc6fd..3706dde 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,11 +13,11 @@ name: "CodeQL Advanced" on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] schedule: - - cron: '17 7 * * 2' + - cron: "17 7 * * 2" jobs: analyze: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4640608..51f53ea 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -25,24 +25,22 @@ jobs: strategy: fail-fast: false matrix: - zsh_version: - - 5.5.1 - - 5.6.2 - - 5.7.1 - - 5.8 - - 5.8.1 - - 5.9 + include: + - { zsh_version: "5.5.1" } + - { zsh_version: "5.6.2" } + - { zsh_version: "5.7.1" } + - { zsh_version: "5.8" } + - { zsh_version: "5.8.1" } + - { zsh_version: "5.9" } steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} - - run: git clone --depth 1 -- https://github.com/z-shell/zi.git zi - uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v4 with: - install: true use: true - uses: docker/login-action@v4 with: @@ -56,8 +54,8 @@ jobs: with: push: ${{ github.event.number == 0 }} file: ./docker/Dockerfile - context: ./docker - build-args: ZI_ZSH_VERSION=${{ matrix.zsh_version }} + context: . + build-args: ZSH_VERSION=${{ matrix.zsh_version }} tags: ghcr.io/${{ github.repository }}:zsh-${{ matrix.zsh_version }} platforms: linux/amd64,linux/arm64 cache-from: type=gha @@ -66,16 +64,14 @@ jobs: build-latest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} - - run: git clone --depth 1 -- https://github.com/z-shell/zi.git zi - uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v4 with: - install: true use: true - uses: docker/login-action@v4 with: @@ -89,7 +85,7 @@ jobs: with: push: true file: ./docker/Dockerfile - context: ./docker + context: . tags: ghcr.io/${{ github.repository }}:latest platforms: linux/amd64,linux/arm64 cache-from: type=gha diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index b5cf199..0000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: 🔖 Pull Request Labeler -on: - pull_request_target: - -permissions: - contents: read - pull-requests: write - -jobs: - triage: - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@v5 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - sync-labels: true diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml deleted file mode 100644 index aa08b75..0000000 --- a/.github/workflows/lock.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: 🔒 Lock closed issues and PRs - -on: - schedule: - - cron: "30 2 * * *" - -permissions: - issues: write - pull-requests: write - -concurrency: - group: lock - -jobs: - lock: - name: 🔐 Lock closed issues and PRs - runs-on: ubuntu-latest - steps: - - uses: dessant/lock-threads@v5 - with: - github-token: ${{ github.token }} - issue-inactive-days: "30" - issue-lock-reason: "" - issue-comment: > - Issue closed and locked due to lack of activity. - - If you encounter this same issue, please open a new issue and refer - to this closed one. - - pr-inactive-days: "7" - pr-lock-reason: "" - pr-comment: > - Pull Request closed and locked due to lack of activity. - - If you'd like to build on this closed PR, you can clone it using - this method: https://stackoverflow.com/a/14969986 - - Then open a new PR, referencing this closed PR in your message. diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml deleted file mode 100644 index dd34aec..0000000 --- a/.github/workflows/pr-labels.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: 🏷️ Verify PR Labels - -on: - workflow_dispatch: - pull_request_target: - types: ["opened", "labeled", "unlabeled", "synchronize"] - -jobs: - pr_labels: - name: 🏭 Verify PR Labels - runs-on: ubuntu-latest - steps: - - name: 🏷 Verify PR has a valid label - uses: jesusvasquez333/verify-pr-label-action@v1.4.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - pull-request-number: "${{ github.event.pull_request.number }}" - valid-labels: > - "breaking-change 💥, bug 🐞, ci 🤖, documentation 📝, enhancement ✨, - security 🛡️, refactor ♻️, performance 🚀, new-feature 🎉, triage 📑, - maintenance 📈, in-progress ⚡, dependencies 📦, submodules ⚙️" - disable-reviews: true diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml deleted file mode 100644 index cf96a84..0000000 --- a/.github/workflows/rebase.yml +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: "🔁 Rebase" -on: - issue_comment: - types: [created] - -jobs: - rebase: - runs-on: ubuntu-latest - name: 🔁 Rebase - if: >- - github.event.issue.pull_request != '' && - ( - contains(github.event.comment.body, '/rebase') || - contains(github.event.comment.body, '/autosquash') - ) - steps: - - name: Checkout the latest code - uses: actions/checkout@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 # otherwise, you will fail to push refs to dest repo - - name: 🔁 Rebase - uses: z-shell/.github/actions/rebase@main - with: - autosquash: ${{ contains(github.event.comment.body, '/autosquash') || contains(github.event.comment.body, '/rebase-autosquash') }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 6ac0024..0000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: 👻 Stale - -on: - schedule: - - cron: "0 8 * * *" - workflow_dispatch: - -jobs: - stale: - name: 🧹 Clean up stale issues and PRs - runs-on: ubuntu-latest - steps: - - name: 🚀 Run stale - uses: actions/stale@v9 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-stale: 30 - days-before-close: 7 - remove-stale-when-updated: true - stale-issue-label: "stale 👻" - exempt-issue-labels: "no-stale 🔒,help-wanted 👥" - stale-issue-message: > - There hasn't been any activity on this issue recently, and in order - to prioritize active issues, it will be marked as stale. - - Please make sure to update to the latest version and - check if that solves the issue. Let us know if that works for you - by leaving a 👍 - - Because this issue is marked as stale, it will be closed and locked - in 7 days if no further activity occurs. - - Thank you for your contributions! - stale-pr-label: "stale 👻" - exempt-pr-labels: "no-stale 🔒" - stale-pr-message: > - There hasn't been any activity on this pull request recently, and in - order to prioritize active work, it has been marked as stale. - - This PR will be closed and locked in 7 days if no further activity - occurs. - - Thank you for your contributions! diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml deleted file mode 100644 index c988167..0000000 --- a/.github/workflows/sync-labels.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: "♻️ Sync Labels" -on: - schedule: - - cron: "22 2 * * 2" - workflow_dispatch: -jobs: - labels: - name: "♻️ Sync labels" - uses: z-shell/.github/.github/workflows/sync-labels.yml@main diff --git a/.github/workflows/test-matrix.yml b/.github/workflows/test-matrix.yml new file mode 100644 index 0000000..7c0cff8 --- /dev/null +++ b/.github/workflows/test-matrix.yml @@ -0,0 +1,43 @@ +name: "ZUnit (Zsh matrix)" + +on: + schedule: + - cron: "0 3 * * 3" + workflow_dispatch: + +permissions: + contents: read + +jobs: + zunit-matrix: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + zsh_version: ["5.5.1", "5.6.2", "5.7.1", "5.8", "5.8.1", "5.9"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: "Build image for Zsh ${{ matrix.zsh_version }}" + uses: docker/build-push-action@v7 + with: + context: . + file: docker/Dockerfile + load: true + build-args: ZSH_VERSION=${{ matrix.zsh_version }} + tags: zd:${{ matrix.zsh_version }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: "Run all tests in Zsh ${{ matrix.zsh_version }} container" + run: | + mkdir -p "${RUNNER_TEMP}/zunit" + docker run --rm \ + --env TERM=xterm \ + --env ZI_DATA=/data \ + --volume "${RUNNER_TEMP}/zunit:/data" \ + "zd:${{ matrix.zsh_version }}" \ + zsh -c 'cd /src/tests && for f in *.zunit; do zunit --tap --verbose "$f" || exit $?; done' diff --git a/.github/workflows/test-native.yml b/.github/workflows/test-native.yml new file mode 100644 index 0000000..526ff80 --- /dev/null +++ b/.github/workflows/test-native.yml @@ -0,0 +1,79 @@ +name: "ZUnit (native)" + +permissions: + contents: read + +on: + push: + branches: [main] + paths: + - "tests/**" + - "utils.zsh" + pull_request: + branches: [main] + schedule: + - cron: "0 12 * * 1" + workflow_dispatch: + inputs: + zi_repo: + description: "GitHub repo for zi (owner/name). Leave empty to use the default install script." + required: false + default: "" + zi_ref: + description: "Branch, tag, or SHA of zi to test." + required: false + default: "main" + workflow_call: + inputs: + zi_repo: + description: "GitHub repo for zi (owner/name). Leave empty to use the default install script." + type: string + required: false + default: "" + zi_ref: + description: "Branch, tag, or SHA of zi to test." + type: string + required: false + default: "main" + +jobs: + zunit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + file: [annexes, ice, packages, plugins, snippets] + steps: + - uses: actions/checkout@v4 + + - name: Install zsh + run: sudo apt-get update && sudo apt-get install -yq zsh + + - name: Install zunit + run: | + mkdir -p bin + curl -fsSL 'https://raw.githubusercontent.com/zdharma/revolver/v0.2.4/revolver' > bin/revolver + curl -fsSL 'https://raw.githubusercontent.com/zdharma/color/d8f91ab5fcfceb623ae45d3333ad0e543775549c/color.zsh' > bin/color + git clone --depth 1 --branch v0.8.2 https://github.com/zdharma/zunit.git zunit.git + cd zunit.git && ./build.zsh && cd .. + mv zunit.git/zunit bin/ + chmod u+x bin/{color,revolver,zunit} + + - name: Install zi + env: + ZI_REPO: ${{ inputs.zi_repo || 'z-shell/zi' }} + ZI_REF: ${{ inputs.zi_ref || 'main' }} + run: | + zi_home="${XDG_DATA_HOME:-${HOME}/.local/share}/zi" + git clone --depth 1 --branch "${ZI_REF}" \ + "https://github.com/${ZI_REPO}.git" "${zi_home}/bin" + mkdir -p "${zi_home}"/{cache,completions,plugins,snippets} + [[ -f "${zi_home}/bin/zi.zsh" ]] || { echo "zi.zsh not found after install"; exit 1; } + + - name: "ZUnit: ${{ matrix.file }}" + run: | + export PATH="$PWD/bin:$PATH" + export TERM=xterm + export ZI_BIN="${XDG_DATA_HOME:-${HOME}/.local/share}/zi/bin" + export ZI_DATA="${RUNNER_TEMP}/zunit" + zunit --tap --verbose "tests/${{ matrix.file }}.zunit" diff --git a/.github/workflows/zsh-n.yml b/.github/workflows/zsh-n.yml index 40721b1..bcb33b2 100644 --- a/.github/workflows/zsh-n.yml +++ b/.github/workflows/zsh-n.yml @@ -3,11 +3,14 @@ name: "✅ Zsh Check" on: push: - tags: ["v*.*.*"] branches: [main, next] - paths: [./zi/**] + paths: + - "docker/**" + - "tests/**" pull_request: - paths: [./zi/**] + paths: + - "docker/**" + - "tests/**" workflow_dispatch: {} jobs: @@ -17,7 +20,7 @@ jobs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: ⤵️ Check out code from GitHub - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: submodules: recursive - name: "✨ Set matrix output" @@ -35,7 +38,7 @@ jobs: matrix: ${{ fromJSON(needs.zsh-matrix.outputs.matrix) }} steps: - name: ⤵️ Check out code from GitHub - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: submodules: recursive - name: "⚡ Install dependencies" diff --git a/.github/workflows/zunit.yml b/.github/workflows/zunit.yml deleted file mode 100644 index 95fc78a..0000000 --- a/.github/workflows/zunit.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: "♾️ ZUnit" -on: - workflow_call: - push: - branches: [main] - paths: - - "**/*.zunit" - schedule: - - cron: "0 12 * * 1/2" - workflow_dispatch: - -env: - zi_branch: main - -jobs: - zunit-matrix: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - steps: - - uses: actions/checkout@v6 - - name: "Set matrix output" - id: set-matrix - run: | - builtin cd docker/tests - MATRIX="$(ls -1 *.zunit | sed 's/.zunit$//' | jq -ncR '{"include": [{"file": inputs}]}')" - echo "MATRIX=${MATRIX}" >&2 - echo "matrix=${MATRIX}" >> $GITHUB_OUTPUT - zunit: - runs-on: ubuntu-latest - needs: zunit-matrix - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.zunit-matrix.outputs.matrix) }} - steps: - - name: Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/checkout@v6 - - run: command git clone --branch ${{ env.zi_branch }} --depth 1 -- https://github.com/z-shell/zi.git zi - - name: "⚡ Install dependencies" - run: | - sudo apt-get update && sudo apt-get install -yq zsh - mkdir bin - curl -fsSL https://raw.githubusercontent.com/zdharma/revolver/v0.2.4/revolver > bin/revolver - curl -fsSL https://raw.githubusercontent.com/zdharma/color/d8f91ab5fcfceb623ae45d3333ad0e543775549c/color.zsh > bin/color - git clone https://github.com/zdharma/zunit.git zunit.git - cd zunit.git - ./build.zsh - cd .. - mv ./zunit.git/zunit bin - chmod u+x bin/{color,revolver,zunit} - - name: "⚡ ZUnit: ${{ matrix.file }}" - env: - ZUNIT_TEST: ${{ matrix.file }} - run: | - echo "⚡ $ZUNIT_TEST" >&2 - export PATH="$PWD/bin:$PATH" - export TERM=xterm - zunit --tap --verbose "docker/tests/${ZUNIT_TEST}.zunit" diff --git a/.gitignore b/.gitignore index a840ea6..0a2f0f5 100644 --- a/.gitignore +++ b/.gitignore @@ -129,132 +129,7 @@ CVS NEWS.md Untitled-1.sh -TEMP.md -# VSCODE workspace -.vscode/* -.vscode/settings.json -.vscode/tasks.json -.vscode/launch.json -.vscode/extensions.json -*.code-workspace - -# Exclude for security reasons -.history/ -.dccache -.env - -# Zsh compiled script + zrecompile backup -*.zwc -*.zwc.old - -# Zsh completion-optimization dumpfile -*zcompdump* - -# Zsh zcalc history -.zcalc_history - -# A popular plugin manager's files -._zi -._zinit -._zplugin -.zi_lastupd -.zinit_lastupd -.zplugin_lstupd - -# z-shell/zshelldoc tool's files -zsdoc/data -docs/zsdoc/data - -# ohmyzsh/ohmyzsh/plugins/per-directory-history plugin's files -# (when set-up to store the history in the local directory) -.directory_history - -# MichaelAquilina/zsh-autoswitch-virtualenv plugin's files -# (for Zsh plugins using Python) -.venv - -# Zunit tests' output -/tests/_output/* -!/tests/_output/.gitkeep - -### C -# Prerequisites -*.d - -# Object files -*.o -*.ko -*.obj -*.elf - -# Linker output -*.ilk -*.map -*.exp - -# Precompiled Headers -*.gch -*.pch - -# Libraries -*.lib -*.a -*.la -*.lo - -# Shared objects (inc. Windows DLLs) -*.dll -*.so -*.so.* -*.dylib - -# Executables -*.exe -*.out -*.app -*.i*86 -*.x86_64 -*.hex - -# Debug files -*.dSYM/ -*.su -*.idb -*.pdb - -# Kernel Module Compile Results -*.mod* -*.cmd -.tmp_versions/ -modules.order -Module.symvers -Mkfile.old -dkms.conf - -# Repository specific files -test/ -txt/ -*.txt -*.zwc -*.zini -*lib/zsh/*zwc -.ycm_extra_conf.py -*deploy*key* -*.bundle -site*/ -other -TODO* -tags -TAGS -*.o -*.o.c -*.orig -*.a -*.so -*.dll -*~ -.*.sw? -\#* - -CVS -.#* +AGENTS.md +CLAUDE.md +GEMINI.md +.github/copilot-instructions.md diff --git a/.trunk/.gitignore b/.trunk/.gitignore index 1e24652..15966d0 100644 --- a/.trunk/.gitignore +++ b/.trunk/.gitignore @@ -6,3 +6,4 @@ plugins user_trunk.yaml user.yaml +tmp diff --git a/.trunk/configs/.hadolint.yaml b/.trunk/configs/.hadolint.yaml index b2c6fa4..d58bf17 100644 --- a/.trunk/configs/.hadolint.yaml +++ b/.trunk/configs/.hadolint.yaml @@ -2,4 +2,5 @@ ignored: - SC1090 - SC1091 + - DL3008 - DL3018 diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 18e419d..dc23686 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -1,10 +1,10 @@ version: 0.1 cli: - version: 1.17.2 + version: 1.25.0 plugins: sources: - id: trunk - ref: v1.3.0 + ref: v1.10.0 uri: https://github.com/trunk-io/plugins lint: disabled: @@ -12,21 +12,21 @@ lint: - terrascan - yamllint - trivy + - trufflehog enabled: - - trufflehog@3.63.2-rc0 - - actionlint@1.6.26 + - actionlint@1.7.12 - git-diff-check - - gitleaks@8.18.1 - - hadolint@2.12.0 - - markdownlint@0.37.0 - - prettier@3.1.0 - - shellcheck@0.9.0 + - gitleaks@8.30.1 + - hadolint@2.14.0 + - markdownlint@0.48.0 + - prettier@3.8.3 + - shellcheck@0.11.0 - shfmt@3.6.0 runtimes: enabled: - go@1.21.0 - - node@20.10.0 - - python@3.10.9 + - node@22.16.0 + - python@3.14.4 actions: enabled: - trunk-announce diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b98a331 --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +# -*- mode: makefile; -*- + +SHELL := bash +ZI_BIN ?= $(HOME)/.local/share/zi/bin +ZI_DATA ?= /tmp/zunit-local +IMAGE ?= ghcr.io/z-shell/zd +TAG ?= latest +TERM ?= xterm-256color +TEST_FILES = annexes ice packages plugins snippets + +ifdef FILE +_SUITES := tests/$(FILE).zunit +else +_SUITES := $(patsubst %,tests/%.zunit,$(TEST_FILES)) +endif + +.PHONY: test run shell build help + +## test [FILE=] — run ZUnit natively (all suites, or one) +test: bin/zunit + @for f in $(_SUITES); do \ + echo "==> $$f"; \ + PATH="$(CURDIR)/bin:$$PATH" \ + ZI_BIN="$(ZI_BIN)" \ + ZI_DATA="$(ZI_DATA)" \ + TERM=$(TERM) \ + bin/zunit --tap --verbose "$$f" || exit $$?; \ + done + +## run CMD="" — run a zi command in Docker +run: +ifndef CMD + $(error Usage: make run CMD="zi light fzf") +endif + docker run --rm \ + --env TERM=$(TERM) \ + --env ZI_DATA=/tmp/zd-run \ + $(IMAGE):$(TAG) \ + zsh -ilc "$(CMD)" + +## shell — interactive Docker shell with zi loaded +shell: + docker run --rm -it \ + --env TERM=$(TERM) \ + --env ZI_DATA=/tmp/zd-shell \ + $(IMAGE):$(TAG) \ + zsh -il + +## build [TAG=] [ZSH_VERSION=] — build Docker image locally +build: + CONTAINER_TAG=$(TAG) bash scripts/build.sh \ + $(if $(ZSH_VERSION),--zsh-version $(ZSH_VERSION)) \ + --image $(IMAGE) + +## help — list available targets +help: + @grep -E '^## ' Makefile | sed 's/^## / /' + +# Install zunit + helpers into bin/ — mirrors what test-native.yml does in CI. +bin/zunit: + @echo "Installing zunit into bin/ ..." + @mkdir -p bin + @curl -fsSL 'https://raw.githubusercontent.com/zdharma/revolver/v0.2.4/revolver' \ + > bin/revolver + @curl -fsSL 'https://raw.githubusercontent.com/zdharma/color/d8f91ab5fcfceb623ae45d3333ad0e543775549c/color.zsh' \ + > bin/color + @rm -rf /tmp/zunit.git + @git clone --depth 1 https://github.com/zdharma/zunit.git /tmp/zunit.git + @cd /tmp/zunit.git && ./build.zsh + @mv /tmp/zunit.git/zunit bin/zunit + @chmod u+x bin/color bin/revolver bin/zunit + @rm -rf /tmp/zunit.git + @echo "Done." diff --git a/README.md b/README.md new file mode 100644 index 0000000..950d45f --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# zd — Zi Docker Testing Environment + +`zd` is the official test harness for the [Zi](https://github.com/z-shell/zi) plugin manager. It provides a ZUnit test suite that verifies Zi commands work correctly — plugin installation, snippet loading, ice modifiers, annexes, and packages. The suite runs natively on CI for every pull request and inside Docker containers for Zsh version compatibility testing. It also works as a reusable workflow so other repos in the Z-Shell ecosystem can test against a specific Zi commit. + +The image is based on **Debian trixie-slim** (`debian:trixie-slim`), providing full glibc compatibility and the breadth of the `apt` ecosystem — making it straightforward to add test dependencies or use the container as an interactive development environment. + +## Architecture + +```text + ┌─────────────────────────────────┐ + │ test-native.yml │ + push / PR ───────▶│ ubuntu-latest · zsh from apt │ + schedule │ one job per .zunit file (fast) │ + workflow_call ───▶│ supports zi_repo + zi_ref input │ + └─────────────────────────────────┘ + + ┌─────────────────────────────────┐ + weekly ──────────▶│ test-matrix.yml │ + workflow_dispatch │ Docker · Zsh 5.5.1 – 5.9 │ + │ one job per Zsh version │ + └─────────────────────────────────┘ + + ┌─────────────────────────────────┐ + local ───────────▶│ Makefile │ + │ make test / run / shell / build │ + └─────────────────────────────────┘ +``` + +Native CI catches regressions on every merge. The Docker matrix verifies Zsh version compatibility on a weekly cadence without blocking pull requests. The Makefile gives contributors a local workflow identical to CI. + +## Quick Start + +```sh +# Run the full ZUnit suite natively (installs zunit on first run) +make test + +# Run a single ad-hoc zi command in Docker +make run CMD="zi light z-shell/z-a-bin-gem-node" + +# Open an interactive shell with zi already loaded +make shell +``` + +Pull the prebuilt image directly: + +```sh +docker run --rm -it ghcr.io/z-shell/zd:latest +docker run --rm -it ghcr.io/z-shell/zd:zsh-5.9 +``` + +## Documentation + +| Topic | File | +| ---------------------------------------------------------- | ---------------------------------------------- | +| Local testing — Makefile targets, env vars, Docker | [docs/local-testing.md](docs/local-testing.md) | +| CI workflows — triggers, inputs, caching | [docs/ci-workflows.md](docs/ci-workflows.md) | +| Cross-repo integration — test your zi PR from another repo | [docs/cross-repo.md](docs/cross-repo.md) | +| Writing tests — zi_test, assertions, adding suites | [docs/writing-tests.md](docs/writing-tests.md) | + +## Available Image Tags + +| Tag | Zsh version | +| ----------- | -------------------- | +| `latest` | Alpine's default Zsh | +| `zsh-5.5.1` | 5.5.1 | +| `zsh-5.6.2` | 5.6.2 | +| `zsh-5.7.1` | 5.7.1 | +| `zsh-5.8` | 5.8 | +| `zsh-5.8.1` | 5.8.1 | +| `zsh-5.9` | 5.9 | + +Images are published to `ghcr.io/z-shell/zd` on every push to `main` and on a weekly schedule. + +## Contributing + +1. Fork the repo and create a branch. +2. Add or edit test files in `tests/`. +3. Run `make test` to verify locally. +4. Open a pull request — CI runs the full suite automatically. + +See [docs/writing-tests.md](docs/writing-tests.md) for the test authoring guide. diff --git a/bin/color b/bin/color new file mode 100644 index 0000000..86f3852 --- /dev/null +++ b/bin/color @@ -0,0 +1,29 @@ +#!/usr/bin/env zsh + +function color() { + local color=$1 style=$2 b=0 + + shift + + case $style in + bold|b) b=1; shift ;; + italic|i) b=2; shift ;; + underline|u) b=4; shift ;; + inverse|in) b=7; shift ;; + strikethrough|s) b=9; shift ;; + esac + + case $color in + black|b) echo "\033[${b};30m${@}\033[0;m" ;; + red|r) echo "\033[${b};31m${@}\033[0;m" ;; + green|g) echo "\033[${b};32m${@}\033[0;m" ;; + yellow|y) echo "\033[${b};33m${@}\033[0;m" ;; + blue|bl) echo "\033[${b};34m${@}\033[0;m" ;; + magenta|m) echo "\033[${b};35m${@}\033[0;m" ;; + cyan|c) echo "\033[${b};36m${@}\033[0;m" ;; + white|w) echo "\033[${b};37m${@}\033[0;m" ;; + *) echo "\033[${b};38;5;$(( ${color} ))m${@}\033[0;m" ;; + esac +} + +color "$@" diff --git a/bin/revolver b/bin/revolver new file mode 100644 index 0000000..51c3dba --- /dev/null +++ b/bin/revolver @@ -0,0 +1,318 @@ +#!/usr/bin/env zsh + +local -A _revolver_spinners +_revolver_spinners=( + 'dots' '0.08 ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏' + 'dots2' '0.08 ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷' + 'dots3' '0.08 ⠋ ⠙ ⠚ ⠞ ⠖ ⠦ ⠴ ⠲ ⠳ ⠓' + 'dots4' '0.08 ⠄ ⠆ ⠇ ⠋ ⠙ ⠸ ⠰ ⠠ ⠰ ⠸ ⠙ ⠋ ⠇ ⠆' + 'dots5' '0.08 ⠋ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋' + 'dots6' '0.08 ⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠴ ⠲ ⠒ ⠂ ⠂ ⠒ ⠚ ⠙ ⠉ ⠁' + 'dots7' '0.08 ⠈ ⠉ ⠋ ⠓ ⠒ ⠐ ⠐ ⠒ ⠖ ⠦ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈' + 'dots8' '0.08 ⠁ ⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈ ⠈' + 'dots9' '0.08 ⢹ ⢺ ⢼ ⣸ ⣇ ⡧ ⡗ ⡏' + 'dots10' '0.08 ⢄ ⢂ ⢁ ⡁ ⡈ ⡐ ⡠' + 'dots11' '0.1 ⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈' + 'dots12' '0.08 "⢀⠀" "⡀⠀" "⠄⠀" "⢂⠀" "⡂⠀" "⠅⠀" "⢃⠀" "⡃⠀" "⠍⠀" "⢋⠀" "⡋⠀" "⠍⠁" "⢋⠁" "⡋⠁" "⠍⠉" "⠋⠉" "⠋⠉" "⠉⠙" "⠉⠙" "⠉⠩" "⠈⢙" "⠈⡙" "⢈⠩" "⡀⢙" "⠄⡙" "⢂⠩" "⡂⢘" "⠅⡘" "⢃⠨" "⡃⢐" "⠍⡐" "⢋⠠" "⡋⢀" "⠍⡁" "⢋⠁" "⡋⠁" "⠍⠉" "⠋⠉" "⠋⠉" "⠉⠙" "⠉⠙" "⠉⠩" "⠈⢙" "⠈⡙" "⠈⠩" "⠀⢙" "⠀⡙" "⠀⠩" "⠀⢘" "⠀⡘" "⠀⠨" "⠀⢐" "⠀⡐" "⠀⠠" "⠀⢀" "⠀⡀"' + 'line' '0.13 - \\ | /' + 'line2' '0.1 ⠂ - – — – -' + 'pipe' '0.1 ┤ ┘ ┴ └ ├ ┌ ┬ ┐' + 'simpleDots' '0.4 ". " ".. " "..." " "' + 'simpleDotsScrolling' '0.2 ". " ".. " "..." " .." " ." " "' + 'star' '0.07 ✶ ✸ ✹ ✺ ✹ ✷' + 'star2' '0.08 + x *' + 'flip' "0.07 _ _ _ - \` \` ' ´ - _ _ _" + 'hamburger' '0.1 ☱ ☲ ☴' + 'growVertical' '0.12 ▁ ▃ ▄ ▅ ▆ ▇ ▆ ▅ ▄ ▃' + 'growHorizontal' '0.12 ▏ ▎ ▍ ▌ ▋ ▊ ▉ ▊ ▋ ▌ ▍ ▎' + 'balloon' '0.14 " " "." "o" "O" "@" "*" " "' + 'balloon2' '0.12 . o O ° O o .' + 'noise' '▓ ▒ ░' + 'bounce' '0.1 ⠁ ⠂ ⠄ ⠂' + 'boxBounce' '0.12 ▖ ▘ ▝ ▗' + 'boxBounce2' '0.1 ▌ ▀ ▐ ▄' + 'triangle' '0.05 ◢ ◣ ◤ ◥' + 'arc' '0.1 ◜ ◠ ◝ ◞ ◡ ◟' + 'circle' '0.12 ◡ ⊙ ◠' + 'squareCorners' '0.18 ◰ ◳ ◲ ◱' + 'circleQuarters' '0.12 ◴ ◷ ◶ ◵' + 'circleHalves' '0.05 ◐ ◓ ◑ ◒' + 'squish' '0.1 ╫ ╪' + 'toggle' '0.25 ⊶ ⊷' + 'toggle2' '0.08 ▫ ▪' + 'toggle3' '0.12 □ ■' + 'toggle4' '0.1 ■ □ ▪ ▫' + 'toggle5' '0.1 ▮ ▯' + 'toggle6' '0.3 ဝ ၀' + 'toggle7' '0.08 ⦾ ⦿' + 'toggle8' '0.1 ◍ ◌' + 'toggle9' '0.1 ◉ ◎' + 'toggle10' '0.1 ㊂ ㊀ ㊁' + 'toggle11' '0.05 ⧇ ⧆' + 'toggle12' '0.12 ☗ ☖' + 'toggle13' '0.08 = * -' + 'arrow' '0.1 ← ↖ ↑ ↗ → ↘ ↓ ↙' + 'arrow2' '0.12 ▹▹▹▹▹ ▸▹▹▹▹ ▹▸▹▹▹ ▹▹▸▹▹ ▹▹▹▸▹ ▹▹▹▹▸' + 'bouncingBar' '0.08 "[ ]" "[ =]" "[ ==]" "[ ===]" "[====]" "[=== ]" "[== ]" "[= ]"' + 'bouncingBall' '0.08 "( ● )" "( ● )" "( ● )" "( ● )" "( ●)" "( ● )" "( ● )" "( ● )" "( ● )" "(● )"' + 'pong' '0.08 "▐⠂ ▌" "▐⠈ ▌" "▐ ⠂ ▌" "▐ ⠠ ▌" "▐ ⡀ ▌" "▐ ⠠ ▌" "▐ ⠂ ▌" "▐ ⠈ ▌" "▐ ⠂ ▌" "▐ ⠠ ▌" "▐ ⡀ ▌" "▐ ⠠ ▌" "▐ ⠂ ▌" "▐ ⠈ ▌" "▐ ⠂▌" "▐ ⠠▌" "▐ ⡀▌" "▐ ⠠ ▌" "▐ ⠂ ▌" "▐ ⠈ ▌" "▐ ⠂ ▌" "▐ ⠠ ▌" "▐ ⡀ ▌" "▐ ⠠ ▌" "▐ ⠂ ▌" "▐ ⠈ ▌" "▐ ⠂ ▌" "▐ ⠠ ▌" "▐ ⡀ ▌" "▐⠠ ▌"' + 'shark' '0.12 "▐|\\____________▌" "▐_|\\___________▌" "▐__|\\__________▌" "▐___|\\_________▌" "▐____|\\________▌" "▐_____|\\_______▌" "▐______|\\______▌" "▐_______|\\_____▌" "▐________|\\____▌" "▐_________|\\___▌" "▐__________|\\__▌" "▐___________|\\_▌" "▐____________|\\▌" "▐____________/|▌" "▐___________/|_▌" "▐__________/|__▌" "▐_________/|___▌" "▐________/|____▌" "▐_______/|_____▌" "▐______/|______▌" "▐_____/|_______▌" "▐____/|________▌" "▐___/|_________▌" "▐__/|__________▌" "▐_/|___________▌" "▐/|____________▌"' +) + +### +# Output usage information and exit +### +function _revolver_usage() { + echo "\033[0;33mUsage:\033[0;m" + echo " revolver [options] " + echo + echo "\033[0;33mOptions:\033[0;m" + echo " -h, --help Output help text and exit" + echo " -v, --version Output version information and exit" + echo " -s, --style Set the spinner style" + echo + echo "\033[0;33mCommands:\033[0;m" + echo " start Start the spinner" + echo " update Update the message" + echo " stop Stop the spinner" + echo " demo Display an demo of each style" +} + +### +# The main revolver process, which contains the loop +### +function _revolver_process() { + local dir statefile state msg pid="$1" spinner_index=0 + + # Find the directory and load the statefile + dir=${REVOLVER_DIR:-"${ZDOTDIR:-$HOME}/.revolver"} + statefile="$dir/$pid" + + # The frames that, when animated, will make up + # our spinning indicator + frames=(${(@z)_revolver_spinners[$style]}) + interval=${(@z)frames[1]} + shift frames + + # Create a never-ending loop + while [[ 1 -eq 1 ]]; do + # If the statefile has been removed, exit the script + # to prevent it from being orphaned + if [[ ! -f $statefile ]]; then + exit 1 + fi + + # Check for the existence of the parent process + $(kill -s 0 $pid 2&>/dev/null) + + # If process doesn't exist, exit the script + # to prevent it from being orphaned + if [[ $? -ne 0 ]]; then + exit 1 + fi + + # Load the current state, and parse it to get + # the message to be displayed + state=($(cat $statefile)) + + msg="${(@)state:1}" + + # Output the current spinner frame, and add a + # slight delay before the next one + _revolver_spin + sleep ${interval:-"0.1"} + done +} + +### +# Output the spinner itself, along with a message +### +function _revolver_spin() { + local dir statefile state pid frame + + # ZSH arrays start at 1, so we need to bump the index if it's 0 + if [[ $spinner_index -eq 0 ]]; then + spinner_index+=1 + fi + + # Calculate the screen width + lim=$(tput cols) + + # Clear the line and move the cursor to the start + printf ' %.0s' {1..$lim} + echo -n "\r" + + # Echo the current frame and message, and overwrite + # the rest of the line with white space + msg="\033[0;38;5;242m${msg}\033[0;m" + frame="${${(@z)frames}[$spinner_index]//\"}" + printf '%*.*b' ${#msg} $lim "$frame $msg$(printf '%0.1s' " "{1..$lim})" + + # Return to the beginning of the line + echo -n "\r" + + # Set the spinner index to the next frame + spinner_index=$(( $(( $spinner_index + 1 )) % $(( ${#frames} + 1 )) )) +} + +### +# Stop the current spinner process +### +function _revolver_stop() { + local dir statefile state pid + + # Find the directory and load the statefile + dir=${REVOLVER_DIR:-"${ZDOTDIR:-$HOME}/.revolver"} + statefile="$dir/$PPID" + + # If the statefile does not exist, raise an error. + # The spinner process itself performs the same check + # and kills itself, so it should never be orphaned + if [[ ! -f $statefile ]]; then + echo '\033[0;31mRevolver process could not be found\033[0;m' + exit 1 + fi + + # Get the current state, and parse it to find the PID + # of the spinner process + state=($(cat $statefile)) + pid="$state[1]" + + # Clear the line and move the cursor to the start + printf ' %.0s' {1..$(tput cols)} + echo -n "\r" + + # If a PID has been found, kill the process + [[ ! -z $pid ]] && kill "$pid" > /dev/null + unset pid + + # Remove the statefile + rm $statefile +} + +### +# Update the message being displayed +function _revolver_update() { + local dir statefile state pid msg="$1" + + # Find the directory and load the statefile + dir=${REVOLVER_DIR:-"${ZDOTDIR:-$HOME}/.revolver"} + statefile="$dir/$PPID" + + # If the statefile does not exist, raise an error. + # The spinner process itself performs the same check + # and kills itself, so it should never be orphaned + if [[ ! -f $statefile ]]; then + echo '\033[0;31mRevolver process could not be found\033[0;m' + exit 1 + fi + + # Get the current state, and parse it to find the PID + # of the spinner process + state=($(cat $statefile)) + pid="$state[1]" + + # Clear the line and move the cursor to the start + printf ' %.0s' {1..$(tput cols)} + echo -n "\r" + + # Echo the new message to the statefile, to be + # picked up by the spinner process + echo "$pid $msg" >! $statefile +} + +### +# Create a new spinner with the specified message +### +function _revolver_start() { + local dir statefile msg="$1" + + # Find the directory and create it if it doesn't exist + dir=${REVOLVER_DIR:-"${ZDOTDIR:-$HOME}/.revolver"} + if [[ ! -d $dir ]]; then + mkdir -p $dir + fi + + # Create the filename for the statefile + statefile="$dir/$PPID" + + touch $statefile + if [[ ! -f $statefile ]]; then + echo '\033[0;31mRevolver process could not create state file\033[0;m' + echo "Check that the directory $dir is writable" + exit 1 + fi + + # Start the spinner process in the background + _revolver_process $PPID &! + + # Save the current state to the statefile + echo "$! $msg" >! $statefile +} + +### +# Demonstrate each of the included spinner styles +### +function _revolver_demo() { + for style in "${(@k)_revolver_spinners[@]}"; do + revolver --style $style start $style + sleep 2 + revolver stop + done +} + +### +# Handle command input +### +function _revolver() { + # Get the context from the first parameter + local help version style ctx="$1" + + # Parse CLI options + zparseopts -D \ + h=help -help=help \ + v=version -version=version \ + s:=style -style:=style + + # Output usage information and exit + if [[ -n $help ]]; then + _revolver_usage + exit 0 + fi + + # Output version information and exit + if [[ -n $version ]]; then + echo '0.2.0' + exit 0 + fi + + if [[ -z $style ]]; then + style='dots' + fi + + if [[ -n $style ]]; then + shift style + ctx="$1" + fi + + if [[ -z $_revolver_spinners[$style] ]]; then + echo $(color red "Spinner '$style' is not recognised") + exit 1 + fi + + case $ctx in + start|update|stop|demo) + # Check if a valid command is passed, + # and if so, run it + _revolver_${ctx} "${(@)@:2}" + ;; + *) + # If the context is not recognised, + # throw an error and exit + echo "Command $ctx is not recognised" + exit 1 + ;; + esac +} + +_revolver "$@" diff --git a/docker/Dockerfile b/docker/Dockerfile index a5ff48b..0db6ade 100755 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,73 +1,109 @@ -ARG VERSION=edge -FROM alpine:$VERSION +FROM debian:trixie-slim AS base + LABEL maintainer="Z-Shell Community" LABEL email="team@zshell.dev" -ARG DIR -ARG PUID -ARG PGID -ARG TERM -ARG ZUSER -ARG ZHOST -# Bump this ARG when a new Go release is available -ARG GO_VERSION=1.26.1 +ARG TERM=xterm +ENV TERM=${TERM} +ENV DEBIAN_FRONTEND=noninteractive -ENV DIR=${DIR:-/static} -ENV PUID=${PUID:-1000} -ENV PGID=${PGID:-1000} -ENV TERM=${TERM:-xterm} -ENV ZUSER=${ZUSER:-user} -ENV HOST=$ZHOST -ENV PATH="/usr/local/go/bin:$PATH" +RUN set -ex \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libncurses-dev \ + build-essential \ + coreutils \ + libpcre2-dev \ + zlib1g-dev \ + autoconf \ + automake \ + rsync \ + bash \ + curl \ + sudo \ + golang-go \ + git \ + vim \ + jq \ + && rm -rf /var/lib/apt/lists/* -RUN set -ex && apk --no-cache add \ - alpine-zsh-config \ - ncurses-dev \ - build-base \ - coreutils \ - pcre-dev \ - zlib-dev \ - autoconf \ - libuser \ - rsync \ - bash \ - curl \ - sudo \ - zsh \ - git \ - vim \ - jq +# When ZSH_VERSION is set, compile that exact release from the GitHub mirror. +# Tags are formatted as zsh-X.Y.Z (e.g. zsh-5.9). +# Otherwise install the distro-packaged zsh (Debian trixie ships 5.9). +ARG ZSH_VERSION +# hadolint ignore=DL3003 +RUN set -ex \ + && if [ -n "${ZSH_VERSION}" ]; then \ + git clone --depth 1 --branch "zsh-${ZSH_VERSION}" \ + https://github.com/zsh-users/zsh.git /tmp/zsh-src \ + && cd /tmp/zsh-src \ + && [ -f configure ] || autoreconf --install --force \ + && sed -i -E 's/\b(bool|num|str)(codes|names|fnames)\b/zsh_\1\2/g' Src/Modules/termcap.c \ + && ./configure --prefix=/usr/local --enable-pcre \ + && make -j"$(nproc)" \ + && make install.bin install.fns install.modules \ + && rm -rf /tmp/zsh-src ; \ + else \ + apt-get update \ + && apt-get install -y --no-install-recommends zsh \ + && rm -rf /var/lib/apt/lists/* ; \ + fi -# Install Go from upstream — avoids the outdated apk package +# Install zunit and its helpers into /usr/local/bin at build time. +RUN git clone --depth 1 --branch v0.8.2 https://github.com/zdharma/zunit.git /tmp/zunit.git +WORKDIR /tmp/zunit.git RUN set -ex \ - && ARCH="$(uname -m)" \ - && case "$ARCH" in \ - x86_64) GOARCH="amd64" ;; \ - aarch64) GOARCH="arm64" ;; \ - armv6l|armv7l) GOARCH="armv6l" ;; \ - *) echo "Unsupported arch: $ARCH" && exit 1 ;; \ - esac \ - && GO_TARBALL="go${GO_VERSION}.linux-${GOARCH}.tar.gz" \ - && rm -rf /usr/local/go \ - && curl -fsSL "https://go.dev/dl/${GO_TARBALL}" -o "/tmp/${GO_TARBALL}" \ - && EXPECTED_SHA256="$(curl -fsSL 'https://go.dev/dl/?mode=json&include=all' \ - | jq -r --arg f "${GO_TARBALL}" '.[].files[] | select(.filename==$f) | .sha256')" \ - && [ -n "${EXPECTED_SHA256}" ] || { echo "Failed to retrieve SHA256 for ${GO_TARBALL}" && exit 1; } \ - && echo "${EXPECTED_SHA256} /tmp/${GO_TARBALL}" | sha256sum -c - \ - && tar -C /usr/local -xzf "/tmp/${GO_TARBALL}" \ - && rm -f "/tmp/${GO_TARBALL}" \ - && go version + && ./build.zsh \ + && mv /tmp/zunit.git/zunit /usr/local/bin/zunit \ + && curl -fsSL 'https://raw.githubusercontent.com/z-shell/src/9335c8c14e0aaa845277f882969c146755e1241e/lib/zsh/snippets/revolver' \ + > /usr/local/bin/revolver \ + && curl -fsSL 'https://raw.githubusercontent.com/z-shell/src/9335c8c14e0aaa845277f882969c146755e1241e/lib/zsh/snippets/color.zsh' \ + > /usr/local/bin/color \ + && chmod a+x /usr/local/bin/color /usr/local/bin/revolver /usr/local/bin/zunit \ + && rm -rf /tmp/zunit.git + +WORKDIR / + +FROM base AS test + +ARG ZUSER=user +ARG PUID=1000 +ARG PGID=1000 +ARG ZHOST=zi-docker + +ENV PUID=${PUID} +ENV PGID=${PGID} +ENV ZUSER=${ZUSER} +ENV HOST=${ZHOST} + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh && /entrypoint.sh -WORKDIR $DIR -COPY . . -RUN chmod +x entrypoint.sh && ./entrypoint.sh +# Install zi as $ZUSER at build time — no network calls at test time. +USER ${ZUSER} +ARG ZI_BRANCH=main +RUN git clone --depth 1 --branch "${ZI_BRANCH}" \ + https://github.com/z-shell/zi.git \ + "${XDG_DATA_HOME:-${HOME}/.local/share}/zi/bin" \ + && mkdir -p \ + "${XDG_DATA_HOME:-${HOME}/.local/share}/zi/cache" \ + "${XDG_DATA_HOME:-${HOME}/.local/share}/zi/completions" \ + "${XDG_DATA_HOME:-${HOME}/.local/share}/zi/plugins" \ + "${XDG_DATA_HOME:-${HOME}/.local/share}/zi/snippets" -VOLUME ["/src", "/data"] -COPY --chown=$ZUSER . /src +# Switch back to root for COPY operations. +USER root +COPY docker/zshenv /home/${ZUSER}/.zshenv +COPY docker/zshrc /home/${ZUSER}/.zshrc +COPY docker/utils.zsh /src/utils.zsh +COPY tests/ /src/tests/ +RUN chown -R ${PUID}:${PGID} /home/${ZUSER}/.zshenv /home/${ZUSER}/.zshrc /src -USER $ZUSER -WORKDIR /home/$ZUSER +# VOLUME declared after all COPYs — declaring before COPY silently discards copied files. +VOLUME ["/data"] -RUN /tmp/install.sh -i skip +USER ${ZUSER} +WORKDIR /home/${ZUSER} CMD ["zsh", "-il"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b7e9421..7ef75cb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,16 +1,13 @@ -version: "3.9" - services: zd: build: - context: . - dockerfile: Dockerfile - #image: ghcr.io/z-shell/zd:latest + context: .. + dockerfile: docker/Dockerfile stdin_open: true tty: true container_name: zd environment: - TERM=xterm-256color volumes: - - $PWD:/src + - $PWD/..:/src hostname: zi@docker diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 43ee6e4..98c79a3 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,25 +1,14 @@ #!/usr/bin/env sh HOME="/home/${ZUSER}" - export HOME -command sed -i -r 's#^(root:.+):/bin/ash#\1:/bin/zsh#' /etc/passwd -command adduser -D -s /bin/zsh -u "${PUID}" -h "${HOME}" "${ZUSER}" +# Change root's default shell from bash to zsh. +command sed -i -r 's#^(root:.+):/bin/bash#\1:/bin/zsh#' /etc/passwd + +# Create the unprivileged user with a home directory and zsh as login shell. +command useradd -m -s /bin/zsh -u "${PUID}" "${ZUSER}" -command printf '%s' "${ZUSER} ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/user +command printf '%s\n' "${ZUSER} ALL=(ALL) NOPASSWD: ALL" >/etc/sudoers.d/user command mkdir -p /src /data command chown -R "${PUID}:${PGID}" /src /data - -command wget 'https://raw.githubusercontent.com/z-shell/zi-src/main/lib/sh/install.sh' -qO /tmp/install.sh -command chown "${PUID}:${PGID}" /tmp/install.sh -command chmod u+x /tmp/install.sh - -command ln -sfv /src/zshenv "${HOME}/.zshenv" -command ln -sfv /src/zshrc "${HOME}/.zshrc" - -if [ -f "${HOME}"/init.zsh ]; then - command chmod u+x "${HOME}"/init.zsh - # shellcheck source=/dev/null - . "${HOME}"/init.zsh -fi diff --git a/docker/init.zsh b/docker/init.zsh deleted file mode 100755 index 1ff793f..0000000 --- a/docker/init.zsh +++ /dev/null @@ -1,4 +0,0 @@ -# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- -# vim: ft=zsh sw=2 ts=2 et - -true \ No newline at end of file diff --git a/docker/tests/ice.zunit b/docker/tests/ice.zunit deleted file mode 100755 index 9886741..0000000 --- a/docker/tests/ice.zunit +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env zunit -# -# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- -# vim: ft=zsh sw=2 ts=2 et -# - -@setup { - load setup - setup -} - -@teardown { - load teardown - teardown -} - -@test 'sbin ice' { - run ./docker/run.sh --wrap --debug --zunit \ - zi light z-shell/z-a-bin-gem-node\;\ - zi light-mode as"null" from"gh-r" sbin'fzf' for junegunn/fzf - - assert $state equals 0 - # We can't assert 'Downloading z-shell/z-a-bin-gem-node' - # because of the control chars (colored output) - assert "$output" contains "Downloading" - - local artifact="${PLUGINS_DIR}/z-shell---z-a-bin-gem-node/z-a-bin-gem-node.plugin.zsh" - assert "$artifact" is_file - assert "$artifact" is_readable - - artifact="${ZPFX}/bin/fzf" - assert "$artifact" is_file - assert "$artifact" is_executable -} - -@test 'failing atclone ice' { - local z=$'zi null atclone'\''echo "intentional failure"; return 255'\'' for z-shell/null' - run ./docker/run.sh --wrap --debug --zunit $z - - assert $state not_equal_to 0 - assert $state equals 255 - assert "$output" contains "intentional failure" -} - -@test 'failing atpull ice' { - local z=$'zi id-as'\''atpull-fail'\'' null \ - atpull'\''echo "intentional failure"; return 255'\'' run-atpull \ - for z-shell/null; zi update atpull-fail' - run ./docker/run.sh --wrap --debug --zunit $z - - assert $state equals 255 - assert "$output" contains "intentional failure" -} - -@test 'failing mv ice' { - local z=$'zi as'\''command'\'' from'\''gh-r'\'' bpick'\''*musl*'\'' mv'\''DOES_NOT_EXIST* -> fd'\'' pick'\''fd/fd'\'' for @sharkdp/fd' - run ./docker/run.sh --wrap --debug --zunit $z - - assert $state equals 1 - assert "$output" contains "DOES_NOT_EXIST" - assert "$output" contains "didn't match any file" -} - -@test 'mv ice' { - local z=$'zi as'\''command'\'' from'\''gh-r'\'' bpick'\''*musl*'\'' mv'\''fd* -> fd'\'' pick'\''fd/fd'\'' for @sharkdp/fd' - run ./docker/run.sh --wrap --debug --zunit $z - - assert $state equals 0 - local artifact="${PLUGINS_DIR}/sharkdp---fd/fd/fd" - assert "$artifact" is_file - assert "$artifact" is_readable - assert "$artifact" is_executable -} diff --git a/docker/tests/setup.zsh b/docker/tests/setup.zsh deleted file mode 100755 index 34012bb..0000000 --- a/docker/tests/setup.zsh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env zunit - -setup() { - export DATA_DIR="${TMPDIR:-/tmp}/zunit" - export PLUGINS_DIR="${DATA_DIR}/plugins" - export SNIPPETS_DIR="${DATA_DIR}/snippets" - export ZPFX="${DATA_DIR}/polaris" - - { - color magenta @setup started - color magenta "DATA_DIR=${DATA_DIR}" - color magenta "PLUGINS_DIR=${PLUGINS_DIR}" - color magenta "SNIPPETS_DIR=${SNIPPETS_DIR}" - color magenta "ZPFX=${ZPFX}" - } >&2 - - color red bold "Deleting $DATA_DIR" >&2 - sudo rm -rf "${DATA_DIR}" - mkdir -p "${DATA_DIR}" -} - -# vim: set ft=zsh et ts=2 sw=2 : diff --git a/docker/utils.zsh b/docker/utils.zsh index 1f924c0..d7738ab 100755 --- a/docker/utils.zsh +++ b/docker/utils.zsh @@ -13,12 +13,12 @@ prepare_system() { initiate_system() { typeset -gxU path module_path - path=("${ZPFX:-${HOME}/.zi/polaris}/bin" "${HOME}/go/bin" "/usr/local/go/bin" $path) + path=("${ZPFX:-${ZI_DATA:-/data}/polaris}/bin" "${HOME}/go/bin" "/usr/local/go/bin" $path) module_path+=( /data/zmodules/zpmod/Src ) zmodload zi/zpmod &>/dev/null - source ~/.zi/bin/zi.zsh + source "${ZI_BIN}/zi.zsh" autoload -Uz _zi (( ${+_comps} )) && _comps[zi]=_zi @@ -26,10 +26,10 @@ initiate_system() { reload_system() { local zf1 zf2 - for zf1 in ~/.zi/bin/*.zsh; do + for zf1 in "${ZI_BIN}"/*.zsh; do source "$zf1" done - for zf2 in ~/.zi/bin/lib/zsh/*.zsh; do + for zf2 in "${ZI_BIN}"/lib/zsh/*.zsh; do source "$zf2" done } @@ -57,7 +57,7 @@ zi::setup-annexes+rec() { } zi::setup-annexes+add() { - sudo apk add ruby-dev grep tree + sudo apt-get install -y ruby-dev grep tree zi::install-zsdoc zi light-mode for z-shell/z-a-test } diff --git a/docker/zshenv b/docker/zshenv index fed16b8..cf899b0 100755 --- a/docker/zshenv +++ b/docker/zshenv @@ -3,8 +3,5 @@ export TERM=${TERM:-xterm-256color} export SHELL=${SHELL:-${commands[zsh]}} - -typeset -Ag ZI -export ZI[HOME_DIR]=${ZI_HOME_DIR:-/data} -export ZI[BIN_DIR]=${ZI_BIN_DIR:-$HOME/.zi/bin} - +export ZI_DATA=${ZI_DATA:-/data} +export ZI_BIN=${ZI_BIN:-${XDG_DATA_HOME:-${HOME}/.local/share}/zi/bin} diff --git a/docker/zshrc b/docker/zshrc index d5b1f53..a9a0c17 100755 --- a/docker/zshrc +++ b/docker/zshrc @@ -1,13 +1,22 @@ # -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- # vim: ft=zsh sw=2 ts=2 et -# Prepare and initiate the source tree. -source /src/utils.zsh -prepare_system; initiate_system - -# If the ZI_ZSH_VERSION is set will install the specified version of Zsh with Zi. -if [[ -n "$ZI_ZSH_VERSION" ]]; then - if [[ "$ZI_ZSH_VERSION" != "$ZSH_VERSION" ]]; then - zi::pack-zsh "$ZI_ZSH_VERSION" - fi -fi +# Initialize Zi with full environment setup. +typeset -gxU path module_path +typeset -gA ZI + +# Set up paths for polaris binaries and zmodules. +path=("${ZPFX:-${ZI_DATA:-/data}/polaris}/bin" $path) +module_path+=( /data/zmodules/zpmod/Src ) + +# Load zpmod if available. +zmodload zi/zpmod &>/dev/null + +# Source zi (pre-installed during docker build). +ZI[HOME_DIR]="${ZI_DATA:-/data}" +source "${ZI_BIN}/zi.zsh" +autoload -Uz _zi +(( ${+_comps} )) && _comps[zi]=_zi + +# Load interactive convenience wrappers. +[[ -f /src/utils.zsh ]] && source /src/utils.zsh diff --git a/docker/zunit.sh b/docker/zunit.sh deleted file mode 100755 index 61d0e1a..0000000 --- a/docker/zunit.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# -*- mode: bash; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- -# vim: ft=bash sw=2 ts=2 et - -run_tests() { - command cd -P -- "$(dirname -- "$(command -v -- "$0" || true)")" && pwd -P || exit 9 - zunit run --verbose "$@" -} - -if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then - run_tests "$@" -fi diff --git a/docs/ci-workflows.md b/docs/ci-workflows.md new file mode 100644 index 0000000..c7c3afc --- /dev/null +++ b/docs/ci-workflows.md @@ -0,0 +1,115 @@ +# CI Workflows + +`zd` uses a two-tier CI model. Native tests run on every push and pull request to catch regressions quickly. The Docker matrix runs weekly to verify compatibility across Zsh versions without blocking merges. + +## Overview + +| Workflow | File | Trigger | Purpose | +| ------------------ | ----------------- | --------------------------------------------- | ----------------------------------- | +| ZUnit (native) | `test-native.yml` | push, PR, schedule, dispatch, `workflow_call` | Fast ZUnit suite on ubuntu-latest | +| ZUnit (Zsh matrix) | `test-matrix.yml` | weekly, dispatch | Zsh 5.5.1–5.9 compat via Docker | +| Zi Docker | `docker.yml` | push, tags, schedule | Build and publish multi-arch images | +| Zsh -n | `zsh-n.yml` | push, PR | Syntax check all `.zsh` files | +| CodeQL | `codeql.yml` | push, PR, schedule | Security scanning | + +--- + +## test-native.yml + +The primary CI workflow. Runs on every push to `main` (when `tests/**` or `utils.zsh` change), on all pull requests, on a weekly Monday schedule, and on manual or `workflow_call` dispatch. + +**Matrix:** one parallel job per `.zunit` file — `annexes`, `ice`, `packages`, `plugins`, `snippets`. Jobs run with `fail-fast: false` so a failure in one suite does not cancel the others. + +**Steps per job:** + +1. Checkout the repository +2. Install `zsh` via `apt-get` +3. Install `zunit`, `revolver`, and `color` into `bin/` +4. Install Zi — either via the default install script or a custom repo/ref when inputs are provided (see [Cross-Repo Integration](cross-repo.md)) +5. Run `zunit --tap --verbose tests/.zunit` + +**Environment variables set by the workflow:** + +| Variable | Value | Purpose | +| --------- | --------------------------- | -------------------------------------------- | +| `PATH` | `$PWD/bin:$PATH` | Makes `zunit`, `revolver`, `color` available | +| `ZI_BIN` | `$HOME/.local/share/zi/bin` | Points `zi_test` at the installed Zi binary | +| `ZI_DATA` | `$RUNNER_TEMP/zunit` | Isolated data dir; wiped between tests | +| `TERM` | `xterm` | Required for Zi's output formatting | + +**Manual dispatch inputs** (available in the GitHub Actions UI): + +| Input | Default | Description | +| --------- | --------- | ------------------------------------------------------------------------- | +| `zi_repo` | _(empty)_ | GitHub repo for Zi (`owner/name`). Empty uses the default install script. | +| `zi_ref` | `main` | Branch, tag, or SHA to install. | + +--- + +## test-matrix.yml + +Runs weekly (Wednesday 03:00 UTC) and on manual dispatch. Not triggered by push or pull request — Zsh version compatibility is a periodic concern, not a per-commit one. + +**Matrix:** six parallel jobs, one per Zsh version: + +| Version | Tag suffix | +| ------- | ----------- | +| 5.5.1 | `zsh-5.5.1` | +| 5.6.2 | `zsh-5.6.2` | +| 5.7.1 | `zsh-5.7.1` | +| 5.8 | `zsh-5.8` | +| 5.8.1 | `zsh-5.8.1` | +| 5.9 | `zsh-5.9` | + +**Per job:** + +1. Build the Docker image for that Zsh version using `docker/setup-buildx-action` and `docker/build-push-action`, passing `ZSH_VERSION` as a build arg — the Dockerfile compiles that exact Zsh release from source on the `debian:trixie-slim` base +2. Layer caching via `type=gha` — only changed layers rebuild on subsequent runs +3. Run all test files in a single container invocation: + +```sh +docker run --rm \ + --env TERM=xterm \ + --env ZI_DATA=/data \ + --volume "${RUNNER_TEMP}/zunit:/data" \ + "zd:${{ matrix.zsh_version }}" \ + zsh -c 'for f in /src/tests/*.zunit; do zunit --tap --verbose "$f" || exit $?; done' +``` + +Running all suites in one container (rather than one container per suite) keeps the job count at 6 instead of 30. + +--- + +## docker.yml + +Builds and publishes multi-architecture images (`linux/amd64`, `linux/arm64`) to `ghcr.io/z-shell/zd`. + +**Triggers:** + +- Push to `main` touching `docker/**`, `scripts/**`, `tests/**`, or `lib/**` +- Tag push matching `v*.*.*` +- Weekly schedule (Wednesday 03:00 UTC) +- Manual dispatch + +**Jobs:** + +`build-versioned` — builds one image per Zsh version (5.5.1–5.9) with tag `zsh-`. Images are pushed only when the trigger is not a pull request (`github.event.number == 0`). + +`build-latest` — builds the `latest` tag. Pushed only on `main` branch pushes. + +Layer caching uses `type=gha` for both jobs. + +--- + +## Environment variable reference + +All workflows share a common set of variables. The table below covers every variable used across the three main workflows. + +| Variable | Workflow | Default | Description | +| ------------- | -------------- | --------------------------- | -------------------------------------------- | +| `TERM` | native, matrix | `xterm` | Terminal type required by Zi output | +| `ZI_BIN` | native | `$HOME/.local/share/zi/bin` | Path to Zi binary directory | +| `ZI_DATA` | native, matrix | `$RUNNER_TEMP/zunit` | Plugin/snippet data directory | +| `ZI_REPO` | native (input) | `z-shell/zi` | Zi GitHub repo when using `workflow_call` | +| `ZI_REF` | native (input) | `main` | Zi branch/tag/SHA when using `workflow_call` | +| `ZSH_VERSION` | matrix, docker | _(empty)_ | Zsh version to bake into the Docker image | diff --git a/docs/cross-repo.md b/docs/cross-repo.md new file mode 100644 index 0000000..c652ca7 --- /dev/null +++ b/docs/cross-repo.md @@ -0,0 +1,103 @@ +# Cross-Repo Integration + +`test-native.yml` is published as a reusable GitHub Actions workflow via the `workflow_call` trigger. Any repository in (or outside) the Z-Shell ecosystem can call it to run the full ZUnit suite against a specific Zi commit, branch, or tag — without maintaining a copy of the test infrastructure. + +## How it works + +When called with `zi_repo` and `zi_ref` inputs, `test-native.yml` clones that exact revision of Zi directly instead of using the default install script. The rest of the workflow is identical: the full test matrix runs (`annexes`, `ice`, `packages`, `plugins`, `snippets`), and results appear in the caller's GitHub Actions UI. + +This means a Zi pull request can trigger `zd` tests as part of its own CI pipeline, catching regressions in the test suite before the PR is merged. + +--- + +## End-to-end example + +Add this file to the repository you want to test from (e.g. `z-shell/zi`): + +```yaml +# .github/workflows/zd-integration.yml +name: "zd integration tests" + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + zd: + name: "ZUnit suite" + uses: z-shell/zd/.github/workflows/test-native.yml@main + with: + zi_repo: z-shell/zi # the repo being tested + zi_ref: ${{ github.sha }} # the exact commit under test +``` + +**What each field does:** + +| Field | Purpose | +| --------------------------------------------------------- | ------------------------------------------------------------------ | +| `uses: z-shell/zd/.github/workflows/test-native.yml@main` | Calls the reusable workflow at the `main` ref of `zd` | +| `zi_repo: z-shell/zi` | Tells `zd` to clone this repo instead of using the default install | +| `zi_ref: ${{ github.sha }}` | Pins to the exact commit that triggered the caller's workflow | + +The caller's `GITHUB_TOKEN` is used automatically — no additional secrets are required. Both repositories must be public. + +--- + +## Input reference + +| Input | Type | Required | Default | Description | +| --------- | -------- | -------- | ------- | ------------------------------------------------------------------------------------------ | +| `zi_repo` | `string` | No | `""` | GitHub repo for Zi in `owner/name` format. When empty, the default install script is used. | +| `zi_ref` | `string` | No | `main` | Branch name, tag, or full commit SHA to check out. | + +--- + +## Choosing `zi_ref` + +**`${{ github.sha }}`** — use this in pull request workflows. Tests the exact commit under review. No ambiguity about what is being tested. + +```yaml +zi_ref: ${{ github.sha }} +``` + +**Branch name** — tracks a branch continuously. Useful for nightly runs against `main` without tying to a specific commit. + +```yaml +zi_ref: main +zi_ref: develop +``` + +**Tag** — pins to a release. Use this when you want a stable baseline, not the latest development state. + +```yaml +zi_ref: v1.2.3 +``` + +--- + +## Permissions and tokens + +`workflow_call` inherits the `GITHUB_TOKEN` from the calling workflow. The token is scoped to the caller's repository with read permissions on `contents`. No secrets need to be shared between repositories. + +`zd` only performs outbound network requests (to install `zunit` and clone `zi_repo`) — it does not write back to either repository. + +--- + +## Pinning the zd version + +The `uses:` line can reference `zd` by branch or by tag: + +```yaml +# Always use the latest zd (may include breaking changes) +uses: z-shell/zd/.github/workflows/test-native.yml@main + +# Pin to a specific zd release (stable, auditable) +uses: z-shell/zd/.github/workflows/test-native.yml@v1.0.0 + +# Pin to a specific commit SHA (most stable) +uses: z-shell/zd/.github/workflows/test-native.yml@a1b2c3d +``` + +For production CI in a release-tracked repo, pinning to a tag or SHA is recommended. For development repos following Zi's `main` branch, `@main` is sufficient. diff --git a/docs/local-testing.md b/docs/local-testing.md new file mode 100644 index 0000000..4bebdda --- /dev/null +++ b/docs/local-testing.md @@ -0,0 +1,147 @@ +# Local Testing + +The Makefile provides four targets that cover the full local workflow: running the test suite natively, executing ad-hoc Zi commands in Docker, opening an interactive shell, and building the image locally. + +## Prerequisites + +**For native tests (`make test`):** + +- Zsh installed (`zsh --version`) +- Zi installed — default location `${XDG_DATA_HOME:-$HOME/.local/share}/zi/bin`. Override with `ZI_BIN=` if installed elsewhere. +- Internet access on first run (downloads `zunit` into `bin/`) + +**For Docker targets (`make run`, `make shell`, `make build`):** + +- Docker running locally +- The prebuilt image pulled (`docker pull ghcr.io/z-shell/zd:latest`) — or build it with `make build` + +--- + +## Running the test suite — `make test` + +```sh +make test +``` + +On the first run, `make test` automatically installs `zunit`, `revolver`, and `color` into `bin/`. This mirrors exactly what the CI workflow does. Subsequent runs skip the install step (the `bin/zunit` file already exists). + +```text +Installing zunit into bin/ ... +Done. +==> tests/annexes.zunit +TAP version 13 +ok 1 - z-a-bin-gem-node installation +ok 2 - z-a-meta-plugins installation +... +==> tests/ice.zunit +... +``` + +**Run a single suite:** + +```sh +make test FILE=annexes +make test FILE=ice +make test FILE=packages +make test FILE=plugins +make test FILE=snippets +``` + +**Override where Zi is installed:** + +```sh +make test ZI_BIN=/path/to/zi/bin +``` + +**Use a different data directory:** + +```sh +make test ZI_DATA=/tmp/my-zunit-run +``` + +Each test suite wipes `ZI_DATA` between individual tests (via `tests/setup.zsh`), so isolation is guaranteed regardless of what you set here. + +--- + +## Running an ad-hoc Zi command — `make run` + +```sh +make run CMD="" +``` + +This starts a fresh container from `ghcr.io/z-shell/zd:latest`, sources Zi via `zsh -il`, and runs your command. The container is removed when it exits. + +**Examples:** + +```sh +# Install a plugin +make run CMD="zi light z-shell/z-a-bin-gem-node" + +# Load a snippet +make run CMD="zi snippet OMZL::spectrum.zsh" + +# Install a program from GitHub releases +make run CMD="zi lucid as\"program\" from\"gh-r\" for junegunn/fzf" + +# Use a specific image tag +make run CMD="zi light z-shell/z-a-rust" TAG=zsh-5.9 +``` + +`CMD` is required. Running `make run` without it prints a usage error. + +--- + +## Interactive shell — `make shell` + +```sh +make shell +``` + +Opens an interactive Zsh session inside the container with Zi already sourced. Use this to explore the environment, debug a failing test manually, or prototype a new Zi command before writing a test for it. + +```sh +$ make shell +user@zi-docker ~ $ zi light junegunn/fzf +... +user@zi-docker ~ $ which fzf +/tmp/zd-shell/polaris/bin/fzf +user@zi-docker ~ $ exit +``` + +The container is removed on exit. State does not persist between sessions. + +--- + +## Building the image locally — `make build` + +```sh +# Build with Debian's default Zsh (same as :latest) +make build + +# Build with a specific Zsh version compiled from source +make build ZSH_VERSION=5.9 +make build ZSH_VERSION=5.8.1 + +# Build with a custom image name and tag +make build IMAGE=my-zd TAG=dev +``` + +After building, use the image in other targets: + +```sh +make run CMD="zi light fzf" IMAGE=my-zd TAG=dev +make shell IMAGE=my-zd TAG=dev +``` + +--- + +## Variable reference + +| Variable | Default | Purpose | +| ------------- | ----------------------- | ----------------------------------------------------- | +| `ZI_BIN` | `~/.local/share/zi/bin` | Path to the Zi binary directory (native tests) | +| `ZI_DATA` | `/tmp/zunit-local` | Data directory for plugins/snippets during tests | +| `IMAGE` | `ghcr.io/z-shell/zd` | Docker image name | +| `TAG` | `latest` | Docker image tag | +| `FILE` | _(all suites)_ | Single `.zunit` suite name to run (without extension) | +| `ZSH_VERSION` | _(empty)_ | Zsh version to bake into a local Docker build | diff --git a/docs/writing-tests.md b/docs/writing-tests.md new file mode 100644 index 0000000..10944e3 --- /dev/null +++ b/docs/writing-tests.md @@ -0,0 +1,183 @@ +# Writing Tests + +Tests live in `tests/` as `.zunit` files and are run by [ZUnit](https://github.com/zdharma/zunit). Each file covers a logical group of Zi functionality. The same files run in both the native CI tier and the Docker matrix — no duplication. + +## Test file anatomy + +Every `.zunit` file follows this structure: + +```zsh +#!/usr/bin/env zunit +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et + +@setup { + load setup # resets ZI_DATA between tests + load helpers # provides zi_test() + setup +} + +@teardown { + load teardown + teardown +} + +@test 'descriptive test name' { + # test body +} +``` + +`@setup` and `@teardown` run before and after each `@test` block. `load setup` and `load helpers` source `tests/setup.zsh` and `tests/helpers.zsh` respectively. + +--- + +## The `zi_test` helper + +`zi_test` is the core of every test. It spawns a fresh, isolated Zsh subprocess, sources Zi, runs the snippet you pass, then captures the exit code in `$state` and all output in `$output`. + +**Signature:** + +```zsh +zi_test '' +``` + +**Minimal example:** + +```zsh +@test 'fzf installs as a program' { + zi_test 'zi lucid as"program" from"gh-r" for junegunn/fzf' + + assert $state equals 0 + assert "$output" contains "Unpacking" + assert "$output" contains "Successfully" +} +``` + +**Multi-line commands** — use a single-quoted heredoc style: + +```zsh +@test 'sbin ice creates shim' { + zi_test ' + zi light z-shell/z-a-bin-gem-node + zi light-mode as"null" from"gh-r" sbin"fzf" for junegunn/fzf + ' + + assert $state equals 0 +} +``` + +**Testing failure** — assert non-zero exit codes explicitly: + +```zsh +@test 'bad mv pattern fails with exit 1' { + zi_test ' + zi as"command" from"gh-r" bpick"*musl*" mv"DOES_NOT_EXIST* -> fd" pick"fd/fd" \ + for @sharkdp/fd + ' + + assert $state equals 1 + assert "$output" contains "DOES_NOT_EXIST" +} +``` + +--- + +## Asserting on files + +After `zi_test` runs, the installed plugin or snippet lives under `$ZI_DATA`. Use `$ZI_DATA` in assertions — never hardcode a path. + +**Plugin path pattern:** `${ZI_DATA}/plugins/---/` + +```zsh +local artifact="${ZI_DATA}/plugins/junegunn---fzf/fzf" +assert "$artifact" is_file +assert "$artifact" is_executable +``` + +**Snippet path pattern:** `${ZI_DATA}/snippets//` + +```zsh +local artifact="${ZI_DATA}/snippets/OMZL::spectrum.zsh/OMZL::spectrum.zsh" +assert "$artifact" is_file +assert "$artifact" is_readable +``` + +**Polaris (programs installed via `sbin`):** `${ZI_DATA}/polaris/bin/` + +```zsh +assert "${ZI_DATA}/polaris/bin/fzf" is_executable +``` + +Note how `owner/name` becomes `owner---name` in the filesystem path — Zi replaces `/` with `---`. + +--- + +## Variable interpolation + +`zi_test` receives a string that is embedded into an inner Zsh process. There are two shells involved: the outer ZUnit shell and the inner Zsh started by `zi_test`. + +- **Single quotes** — the string is passed literally; `$VAR` references resolve in the _inner_ shell (after Zi is sourced). This is the default and is what you want for most tests. +- **Double quotes** — the string is interpolated by the _outer_ shell before being passed in. Use this when you want to inject an outer variable's value. + +```zsh +# Correct — expands $my_plugin in the outer (ZUnit) shell +zi_test "zi light ${my_plugin}" + +# Wrong — $my_plugin is undefined in the inner shell, expands to empty +zi_test 'zi light ${my_plugin}' +``` + +In practice, test values are almost always literals, so single quotes are correct in the vast majority of cases. + +--- + +## Test isolation + +Each `zi_test` call is a completely fresh Zsh process. There is no shared Zi state between individual `@test` blocks — no loaded plugins, no cached data, no side-effects from previous tests. + +`tests/setup.zsh` wipes `$ZI_DATA` between every `@test` block: + +```zsh +setup() { + rm -rf "${ZI_DATA:?}"/* + mkdir -p "${ZI_DATA}" +} +``` + +This means tests can be run in any order and do not depend on each other. + +--- + +## Adding a new suite + +1. Create `tests/.zunit` following the anatomy above. +2. Add `` to the matrix in `.github/workflows/test-native.yml`: + + ```yaml + matrix: + file: [annexes, ice, packages, plugins, snippets, ] + ``` + +3. Verify locally before pushing: + + ```sh + make test FILE= + ``` + +The new suite will be picked up automatically by the Docker matrix workflow (`test-matrix.yml`) — it iterates over all `*.zunit` files, so no change is needed there. + +--- + +## Common assertion patterns + +| Assertion | Meaning | +| ---------------------------------- | -------------------------------------------- | +| `assert $state equals 0` | Command exited successfully | +| `assert $state equals 255` | Command exited with a specific non-zero code | +| `assert $state not_equal_to 0` | Command failed (any non-zero code) | +| `assert "$output" contains "text"` | Output includes the substring | +| `assert "$artifact" is_file` | Path exists and is a regular file | +| `assert "$artifact" is_executable` | Path exists and is executable | +| `assert "$artifact" is_readable` | Path exists and is readable | + +Full ZUnit assertion reference: diff --git a/docker/build.sh b/scripts/build.sh similarity index 87% rename from docker/build.sh rename to scripts/build.sh index 3e3a0aa..befe6c1 100755 --- a/docker/build.sh +++ b/scripts/build.sh @@ -2,9 +2,9 @@ # -*- mode: bash; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- # vim: ft=bash sw=2 ts=2 et -col_error="" -col_info="" -col_rst="" +col_error="[31m" +col_info="[32m" +col_rst="[0m" say() { printf '%s\n' "${col_info}${1}${col_rst}" >&2 @@ -24,7 +24,7 @@ build() { local container_hostname="z-shell" shift 3 - local dockerfile="Dockerfile" + local dockerfile="../docker/Dockerfile" if [[ -n ${zsh_version} ]]; then tag="zsh${zsh_version}-${tag}" @@ -33,7 +33,8 @@ build() { say "Building image: ${image_name}" local -a args - [[ -n ${NO_CACHE} ]] && args+=(--no-cache "$@") + [[ -n ${NO_CACHE} ]] && args+=(--no-cache) + args+=("$@") if docker build \ --build-arg "ZUSER=${USER:-$(id -u -n)}" \ @@ -41,10 +42,10 @@ build() { --build-arg "PUID=${UID:-$(id -u)}" \ --build-arg "PGID=${GID:-$(id -g)}" \ --build-arg "TERM=${TERM:-xterm-256color}" \ - --build-arg "ZI_ZSH_VERSION=${zsh_version}" \ + --build-arg "ZSH_VERSION=${zsh_version}" \ --file "${dockerfile}" \ --tag "${image_name}:${tag}" \ - "${args[@]}" "$(realpath ../docker || realpath .. || true)"; then + "${args[@]}" "$(realpath .. || true)"; then { say "To use this image for ZUnit tests run: " say "export CONTAINER_IMAGE=\"${image_name}\" CONTAINER_TAG=\"${tag}\"" diff --git a/docker/run.sh b/scripts/run.sh similarity index 80% rename from docker/run.sh rename to scripts/run.sh index 9f343a8..1a90001 100755 --- a/docker/run.sh +++ b/scripts/run.sh @@ -2,9 +2,9 @@ # -*- mode: bash; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- # vim: ft=bash sw=2 ts=2 et -col_error="" -col_info="" -col_rst="" +col_error="[31m" +col_info="[32m" +col_rst="[0m" say() { printf '%s\n' "${col_info}${1}${col_rst}" >&2 @@ -38,7 +38,6 @@ running_interactively() { fi if ! [[ -t 1 ]]; then - # return false if running non-interactively, unless run with zunit parent_process | grep -q zunit || true fi } @@ -76,7 +75,6 @@ run() { fi fi - # Inherit TERM if [[ -n ${TERM} ]]; then args+=(--env "TERM=${TERM}") fi @@ -119,12 +117,12 @@ if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then CONTAINER_VOLUMES=() DEBUG="${DEBUG-}" ZSH_DEBUG="${ZSH_DEBUG-}" + DEVEL="${DEVEL-}" INIT_CONFIG_VAL="${INIT_CONFIG_VAL-}" WRAP_CMD="${WRAP_CMD-}" while [[ -n $* ]]; do case "$1" in - # Fetch init config from clipboard (Linux only) --xsel | -b) INIT_CONFIG_VAL="$(xsel -b)" shift @@ -165,17 +163,10 @@ if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then CONTAINER_VOLUMES+=("$2") shift 2 ;; - # Whether to wrap the command in zsh -silc -w | --wrap) WRAP_CMD=1 shift ;; - --tests | --zunit | -z) - ZUNIT=1 - shift - ;; - # Whether to enable debug tracing of zd (zsh -x) - # Only applies to wrapped commands (--w|--wrap) --zsh-debug | -x | -Z) ZSH_DEBUG=1 shift @@ -190,32 +181,14 @@ if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then trap 'rm -vf $INIT_CONFIG' EXIT INT fi CONTAINER_ROOT="$( - cd -P -- "$(dirname "$0")" + cd -P -- "$(dirname "$0")/.." pwd -P )" || exit 9 if [[ -n ${DEVEL} ]]; then - # Mount root of the repo to /src CONTAINER_VOLUMES+=( "${CONTAINER_ROOT}:/src" ) fi - if [[ -n ${ZUNIT} ]]; then - ROOT_DIR="$( - cd -P -- "$(dirname "$0")" - pwd -P - )" || exit 9 - # Mount root of the repo to /src - # Mount /tmp/zunit to /data - CONTAINER_VOLUMES+=( - "${CONTAINER_ROOT}:/src" - "${TMPDIR:-/tmp}/zunit:/data" - "${ROOT_DIR}/zshenv:/home/zunit/.zshenv" - "${ROOT_DIR}/zshrc:/home/zunit/.zshrc" - ) - CONTAINER_ENV+=( - "QUIET=1" - ) - fi run "${INIT_CONFIG}" "$@" fi diff --git a/docker/tests/annexes.zunit b/tests/annexes.zunit old mode 100755 new mode 100644 similarity index 59% rename from docker/tests/annexes.zunit rename to tests/annexes.zunit index 5aded7d..65aeb79 --- a/docker/tests/annexes.zunit +++ b/tests/annexes.zunit @@ -6,6 +6,7 @@ @setup { load setup + load helpers setup } @@ -15,106 +16,97 @@ } @test 'z-a-bin-gem-node installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi light z-shell/z-a-bin-gem-node + zi_test 'zi light z-shell/z-a-bin-gem-node' assert $state equals 0 assert "$output" contains "Downloading" assert "$output" contains "Compiling" - local artifact="${PLUGINS_DIR}/z-shell---z-a-bin-gem-node/z-a-bin-gem-node.plugin.zsh" + local artifact="${ZI_DATA}/plugins/z-shell---z-a-bin-gem-node/z-a-bin-gem-node.plugin.zsh" assert "$artifact" is_file assert "$artifact" is_readable } @test 'z-a-meta-plugins installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi light z-shell/z-a-meta-plugins + zi_test 'zi light z-shell/z-a-meta-plugins' assert $state equals 0 assert "$output" contains "Downloading" assert "$output" contains "Compiling" - local artifact="${PLUGINS_DIR}/z-shell---z-a-meta-plugins/z-a-meta-plugins.plugin.zsh" + local artifact="${ZI_DATA}/plugins/z-shell---z-a-meta-plugins/z-a-meta-plugins.plugin.zsh" assert "$artifact" is_file assert "$artifact" is_readable } - @test 'z-a-readurl installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi light z-shell/z-a-readurl + zi_test 'zi light z-shell/z-a-readurl' assert $state equals 0 assert "$output" contains "Downloading" assert "$output" contains "Compiling" - local artifact="${PLUGINS_DIR}/z-shell---z-a-readurl/z-a-readurl.plugin.zsh" + local artifact="${ZI_DATA}/plugins/z-shell---z-a-readurl/z-a-readurl.plugin.zsh" assert "$artifact" is_file assert "$artifact" is_readable } @test 'z-a-rust installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi light z-shell/z-a-rust + zi_test 'zi light z-shell/z-a-rust' assert $state equals 0 assert "$output" contains "Downloading" assert "$output" contains "Compiling" - local artifact="${PLUGINS_DIR}/z-shell---z-a-rust/z-a-rust.plugin.zsh" + local artifact="${ZI_DATA}/plugins/z-shell---z-a-rust/z-a-rust.plugin.zsh" assert "$artifact" is_file assert "$artifact" is_readable } @test 'z-a-eval installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi light z-shell/z-a-eval + zi_test 'zi light z-shell/z-a-eval' assert $state equals 1 assert "$output" contains "Downloading" assert "$output" contains "Compiling" - local artifact="${PLUGINS_DIR}/z-shell---z-a-eval/z-a-eval.plugin.zsh" + local artifact="${ZI_DATA}/plugins/z-shell---z-a-eval/z-a-eval.plugin.zsh" assert "$artifact" is_file assert "$artifact" is_readable } @test 'z-a-linkbin installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi light z-shell/z-a-linkbin + zi_test 'zi light z-shell/z-a-linkbin' assert $state equals 0 assert "$output" contains "Downloading" assert "$output" contains "Compiling" - local artifact="${PLUGINS_DIR}/z-shell---z-a-linkbin/z-a-linkbin.plugin.zsh" + local artifact="${ZI_DATA}/plugins/z-shell---z-a-linkbin/z-a-linkbin.plugin.zsh" assert "$artifact" is_file assert "$artifact" is_readable } @test 'z-a-default-ice installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi light z-shell/z-a-default-ice + zi_test 'zi light z-shell/z-a-default-ice' assert $state equals 1 assert "$output" contains "Downloading" assert "$output" contains "Compiling" - local artifact="${PLUGINS_DIR}/z-shell---z-a-default-ice/z-a-default-ice.plugin.zsh" + local artifact="${ZI_DATA}/plugins/z-shell---z-a-default-ice/z-a-default-ice.plugin.zsh" assert "$artifact" is_file assert "$artifact" is_readable } @test 'z-a-test installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi light z-shell/z-a-test + zi_test 'zi light z-shell/z-a-test' assert $state equals 0 assert "$output" contains "Downloading" assert "$output" contains "Compiling" - local artifact="${PLUGINS_DIR}/z-shell---z-a-test/z-a-test.plugin.zsh" + local artifact="${ZI_DATA}/plugins/z-shell---z-a-test/z-a-test.plugin.zsh" assert "$artifact" is_file assert "$artifact" is_readable } diff --git a/tests/helpers.zsh b/tests/helpers.zsh new file mode 100644 index 0000000..e716769 --- /dev/null +++ b/tests/helpers.zsh @@ -0,0 +1,26 @@ +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et + +# Run a zi snippet in a fresh isolated zsh subprocess. +# $1 — zsh code to execute (unescaped; single-quote at call site to prevent +# expansion before the function receives it). +# +# Variable interpolation note: ${_zi_bin} and ${_zi_data} are expanded by the +# outer shell when the inner command string is assembled. References to $VAR +# inside the script argument resolve in the *inner* shell after zi is sourced. +# To pass an outer variable's value into the script, let it expand in the +# caller: zi_test "zi light ${my_plugin}" +zi_test() { + local script=$1 + local _zi_bin="${ZI_BIN:-${XDG_DATA_HOME:-${HOME}/.local/share}/zi/bin}" + local _zi_data="${ZI_DATA:-${TMPDIR:-/tmp}/zunit}" + run env NO_COLOR=1 TERM=dumb zsh -c " + typeset -gxU path + path=( \${HOME}/go/bin \$path ) + typeset -gA ZI + ZI[HOME_DIR]='${_zi_data}' + source '${_zi_bin}/zi.zsh' + autoload -Uz _zi + ${script} + " +} diff --git a/tests/ice.zunit b/tests/ice.zunit new file mode 100644 index 0000000..88e3ac6 --- /dev/null +++ b/tests/ice.zunit @@ -0,0 +1,78 @@ +#!/usr/bin/env zunit +# +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et +# + +@setup { + load setup + load helpers + setup +} + +@teardown { + load teardown + teardown +} + +@test 'sbin ice' { + zi_test ' + zi light z-shell/z-a-bin-gem-node + zi light-mode as"null" from"gh-r" sbin"fzf" for junegunn/fzf + ' + + assert $state equals 0 + assert "$output" contains "Downloading" + + local artifact="${ZI_DATA}/plugins/z-shell---z-a-bin-gem-node/z-a-bin-gem-node.plugin.zsh" + assert "$artifact" is_file + assert "$artifact" is_readable + + artifact="${ZI_DATA}/polaris/bin/fzf" + assert "$artifact" is_file + assert "$artifact" is_executable +} + +@test 'failing atclone ice' { + zi_test 'zi null atclone"echo intentional failure; return 255" for z-shell/null' + + assert $state not_equal_to 0 + assert $state equals 255 + assert "$output" contains "intentional failure" +} + +@test 'failing atpull ice' { + zi_test ' + zi id-as"atpull-fail" null \ + atpull"echo intentional failure; return 255" run-atpull \ + for z-shell/null + zi update atpull-fail + ' + + assert $state equals 255 + assert "$output" contains "intentional failure" +} + +@test 'failing mv ice' { + zi_test ' + zi as"command" from"gh-r" bpick"*musl*" mv"DOES_NOT_EXIST* -> fd" pick"fd/fd" \ + for @sharkdp/fd + ' + + assert "$output" contains "DOES_NOT_EXIST" + assert "$output" contains "∞zi-mv-hook hook returned with" +} + +@test 'mv ice' { + zi_test ' + zi as"command" from"gh-r" bpick"*musl*" mv"fd* -> fd" pick"fd/fd" \ + for @sharkdp/fd + ' + + assert $state equals 0 + + local artifact="${ZI_DATA}/plugins/sharkdp---fd/fd/fd" + assert "$artifact" is_file + assert "$artifact" is_readable + assert "$artifact" is_executable +} diff --git a/docker/tests/packages.zunit b/tests/packages.zunit old mode 100755 new mode 100644 similarity index 74% rename from docker/tests/packages.zunit rename to tests/packages.zunit index 49ce7c1..1068eb2 --- a/docker/tests/packages.zunit +++ b/tests/packages.zunit @@ -6,6 +6,7 @@ @setup { load setup + load helpers setup } @@ -15,13 +16,12 @@ } @test 'zi package ls_colors' { - run ./docker/run.sh --wrap --debug --zunit \ - zi pack for ls_colors + zi_test 'zi pack for ls_colors' assert $state equals 0 assert "$output" contains "Package" - local artifact="${PLUGINS_DIR}/ls_colors/LS_COLORS" + local artifact="${ZI_DATA}/plugins/ls_colors/LS_COLORS" assert "$artifact" is_file assert "$artifact" is_readable } diff --git a/docker/tests/plugins.zunit b/tests/plugins.zunit old mode 100755 new mode 100644 similarity index 64% rename from docker/tests/plugins.zunit rename to tests/plugins.zunit index 3e80b9e..517ff51 --- a/docker/tests/plugins.zunit +++ b/tests/plugins.zunit @@ -6,6 +6,7 @@ @setup { load setup + load helpers setup } @@ -15,48 +16,49 @@ } @test 'zi fzf installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi lucid as="program" from="gh-r" for junegunn/fzf + zi_test 'zi lucid as"program" from"gh-r" for junegunn/fzf' assert $state equals 0 assert "$output" contains "Unpacking" assert "$output" contains "Successfully" - local artifact="${PLUGINS_DIR}/junegunn---fzf/fzf" + local artifact="${ZI_DATA}/plugins/junegunn---fzf/fzf" assert "$artifact" is_file assert "$artifact" is_executable } @test 'zi direnv installation' { - run ./docker/run.sh --wrap --debug --zunit \ - 'zi light-mode as"program" \ + zi_test ' + zi light-mode as"program" \ atclone"go install github.com/cpuguy83/go-md2man/v2@latest" \ - make for @direnv/direnv' + make for @direnv/direnv + ' assert $state equals 0 assert "$output" contains "Downloading" assert "$output" contains "go: downloading github.com" - local artifact="${PLUGINS_DIR}/direnv---direnv/direnv" + local artifact="${ZI_DATA}/plugins/direnv---direnv/direnv" assert "$artifact" is_file assert "$artifact" is_executable } @test 'zi diff-so-fancy installation' { - run ./docker/run.sh --wrap --debug --zunit \ - 'zi light-mode for \ + zi_test ' + zi light-mode for \ as"program" pick"bin/git-dsf" \ - z-shell/zsh-diff-so-fancy' + z-shell/zsh-diff-so-fancy + ' assert $state equals 0 assert "$output" contains "Downloading" assert "$output" contains "Cloning into" - local artifact="${PLUGINS_DIR}/z-shell---zsh-diff-so-fancy/bin/git-dsf" + local artifact="${ZI_DATA}/plugins/z-shell---zsh-diff-so-fancy/bin/git-dsf" assert "$artifact" is_file assert "$artifact" is_executable - artifact="${PLUGINS_DIR}/z-shell---zsh-diff-so-fancy/bin/diff-so-fancy" + artifact="${ZI_DATA}/plugins/z-shell---zsh-diff-so-fancy/bin/diff-so-fancy" assert "$artifact" is_file assert "$artifact" is_executable } diff --git a/tests/setup.zsh b/tests/setup.zsh new file mode 100644 index 0000000..b2194dc --- /dev/null +++ b/tests/setup.zsh @@ -0,0 +1,16 @@ +#!/usr/bin/env zunit +# -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- +# vim: ft=zsh sw=2 ts=2 et + +setup() { + export ZI_DATA="${TMPDIR:-/tmp}/zunit" + + { + color magenta @setup started + color magenta "ZI_DATA=${ZI_DATA}" + } >&2 + + # Wipe and recreate between tests. Direct rm avoids glob-no-match errors. + rm -rf "${ZI_DATA:?}" + mkdir -p "${ZI_DATA}" +} diff --git a/docker/tests/snippets.zunit b/tests/snippets.zunit old mode 100755 new mode 100644 similarity index 63% rename from docker/tests/snippets.zunit rename to tests/snippets.zunit index 0c82133..75b88aa --- a/docker/tests/snippets.zunit +++ b/tests/snippets.zunit @@ -6,6 +6,7 @@ @setup { load setup + load helpers setup } @@ -15,37 +16,34 @@ } @test 'zi OMZL::spectrum.zsh installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi snippet OMZL::spectrum.zsh + zi_test 'zi snippet OMZL::spectrum.zsh' assert $state equals 0 assert "$output" contains "Downloading" - local artifact="${SNIPPETS_DIR}/OMZL::spectrum.zsh/OMZL::spectrum.zsh" + local artifact="${ZI_DATA}/snippets/OMZL::spectrum.zsh/OMZL::spectrum.zsh" assert "$artifact" is_file assert "$artifact" is_readable } @test 'zi OMZP::git installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi snippet OMZP::git + zi_test 'zi snippet OMZP::git' assert $state equals 0 assert "$output" contains "Downloading" - local artifact="${SNIPPETS_DIR}/OMZP::git/OMZP::git" + local artifact="${ZI_DATA}/snippets/OMZP::git/OMZP::git" assert "$artifact" is_file assert "$artifact" is_readable } @test 'zi PZTM::environment installation' { - run ./docker/run.sh --wrap --debug --zunit \ - zi snippet PZTM::environment + zi_test 'zi snippet PZTM::environment' assert $state equals 0 assert "$output" contains "Downloading" - local artifact="${SNIPPETS_DIR}/PZTM::environment/PZTM::environment" + local artifact="${ZI_DATA}/snippets/PZTM::environment/PZTM::environment" assert "$artifact" is_file assert "$artifact" is_readable } diff --git a/docker/tests/teardown.zsh b/tests/teardown.zsh old mode 100755 new mode 100644 similarity index 51% rename from docker/tests/teardown.zsh rename to tests/teardown.zsh index 7fda356..f7b7455 --- a/docker/tests/teardown.zsh +++ b/tests/teardown.zsh @@ -1,14 +1,8 @@ #!/usr/bin/env zunit -# # -*- mode: zsh; sh-indentation: 2; indent-tabs-mode: nil; sh-basic-offset: 2; -*- # vim: ft=zsh sw=2 ts=2 et -# teardown() { - color cyan @teardown called - - [[ -n "$DATA_DIR" ]] && { - color red bold "Deleting $DATA_DIR" >&2 - sudo rm -rf "$DATA_DIR" - } + color cyan @teardown called >&2 + [[ -n "${ZI_DATA}" ]] && rm -rf "${ZI_DATA:?}"/* }