mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 03:00:45 +00:00
tests and tooling (#4)
* init openspec * clean out src, move mod into lib, remove trash * create tests * pre-commit hook * add tests to CI * update website * README, CONTRIBUTING and Makefile * openspec * archive task * fix ci order * fix integration test * fix validation tests
This commit is contained in:
parent
7dfab68304
commit
3819a85c47
24 changed files with 892 additions and 286 deletions
67
.github/workflows/ci.yml
vendored
67
.github/workflows/ci.yml
vendored
|
|
@ -5,6 +5,8 @@ name: CI
|
|||
# runs-on: ${{ vars.UVA_RUNNER || 'ubuntu-latest' }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
|
@ -14,8 +16,67 @@ permissions:
|
|||
packages: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: site/pnpm-lock.yaml
|
||||
|
||||
- name: Install site dependencies
|
||||
run: pnpm --dir site install --frozen-lockfile
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
|
||||
|
||||
- name: Format check
|
||||
run: make fmt-check
|
||||
|
||||
- name: Clippy
|
||||
run: make lint
|
||||
|
||||
- name: Unit tests
|
||||
run: make test-unit
|
||||
|
||||
- name: Site format check
|
||||
run: make site-format-check
|
||||
|
||||
integration:
|
||||
name: Integration (Xvfb)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev xvfb
|
||||
|
||||
- name: Xvfb integration tests
|
||||
run: make test-integration
|
||||
|
||||
changes:
|
||||
name: Changes
|
||||
needs: [validate, integration]
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
rust: ${{ steps.check.outputs.rust }}
|
||||
|
|
@ -32,9 +93,11 @@ jobs:
|
|||
filters: |
|
||||
rust:
|
||||
- 'src/**'
|
||||
- 'tests/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'docker/**'
|
||||
- 'Makefile'
|
||||
|
||||
- name: Set outputs
|
||||
id: check
|
||||
|
|
@ -69,7 +132,7 @@ jobs:
|
|||
build:
|
||||
name: Build (${{ matrix.target }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
|
|
@ -132,6 +195,7 @@ jobs:
|
|||
update-manifests:
|
||||
name: Update Manifests
|
||||
needs: [changes, build]
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -165,6 +229,7 @@ jobs:
|
|||
release:
|
||||
name: Release
|
||||
needs: [changes, build, update-manifests]
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
|
|||
15
.pre-commit-config.yaml
Normal file
15
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: rustfmt
|
||||
name: rustfmt
|
||||
entry: cargo fmt --
|
||||
language: system
|
||||
files: \.rs$
|
||||
pass_filenames: true
|
||||
- id: site-format-check
|
||||
name: site format check
|
||||
entry: make site-format-check
|
||||
language: system
|
||||
files: ^site/
|
||||
pass_filenames: false
|
||||
70
CONTRIBUTING.md
Normal file
70
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Contributing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust toolchain
|
||||
- `make`
|
||||
- `pre-commit` for commit-time hooks
|
||||
- `pnpm` for site formatting checks
|
||||
- Linux with `xvfb-run` for integration tests
|
||||
|
||||
Install site dependencies before running site checks:
|
||||
|
||||
```bash
|
||||
pnpm --dir site install
|
||||
```
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `src/lib.rs` exposes the library target used by integration tests
|
||||
- `src/main.rs` is the thin CLI binary wrapper
|
||||
- `src/` holds production code and unit tests
|
||||
- `tests/` holds integration tests
|
||||
- `tests/support/` holds shared X11 and daemon helpers for integration coverage
|
||||
|
||||
Keep integration-only helpers out of `src/`.
|
||||
|
||||
## Local Validation
|
||||
|
||||
The repo uses one local validation surface through `make`:
|
||||
|
||||
```bash
|
||||
make fmt-check
|
||||
make lint
|
||||
make test-unit
|
||||
make test-integration
|
||||
make site-format-check
|
||||
make validate
|
||||
```
|
||||
|
||||
`make validate` runs the full Phase 2 validation stack. It requires Linux, `xvfb-run`, and site dependencies to be installed.
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
Install the hook workflow once:
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
Run hooks across the repo on demand:
|
||||
|
||||
```bash
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
The hook config intentionally stays small:
|
||||
|
||||
- Rust files use default `rustfmt`
|
||||
- Site files reuse the existing `site/` Prettier setup
|
||||
- Slower checks stay in CI or `make validate`
|
||||
|
||||
## Integration Tests
|
||||
|
||||
Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is:
|
||||
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
That command runs the top-level X11 integration tests under `xvfb-run` with one test thread so the shared display/session environment stays deterministic.
|
||||
33
Makefile
Normal file
33
Makefile
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
.PHONY: fmt fmt-check lint test-unit test-integration site-format-check validate
|
||||
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
|
||||
fmt-check:
|
||||
cargo fmt --all --check
|
||||
|
||||
lint:
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
test-unit:
|
||||
cargo test --lib
|
||||
|
||||
test-integration:
|
||||
@if [ "$$(uname -s)" != "Linux" ]; then \
|
||||
echo "Integration tests require Linux and xvfb-run."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! command -v xvfb-run >/dev/null 2>&1; then \
|
||||
echo "xvfb-run is required to execute integration tests."; \
|
||||
exit 1; \
|
||||
fi
|
||||
XDG_SESSION_TYPE=x11 xvfb-run -a cargo test --test x11_runtime -- --test-threads=1
|
||||
|
||||
site-format-check:
|
||||
@if ! command -v pnpm >/dev/null 2>&1; then \
|
||||
echo "pnpm is required for site formatting checks."; \
|
||||
exit 1; \
|
||||
fi
|
||||
pnpm --dir site format:check
|
||||
|
||||
validate: fmt-check lint test-unit test-integration site-format-check
|
||||
32
README.md
32
README.md
|
|
@ -59,6 +59,14 @@ deskctl focus "firefox"
|
|||
Client-daemon architecture over Unix sockets (NDJSON wire protocol).
|
||||
The daemon starts automatically on first command and keeps the X11 connection alive for fast repeated calls.
|
||||
|
||||
Source layout:
|
||||
|
||||
- `src/lib.rs` exposes the shared library target
|
||||
- `src/main.rs` is the thin CLI wrapper
|
||||
- `src/` contains production code and unit tests
|
||||
- `tests/` contains Linux/X11 integration tests
|
||||
- `tests/support/` contains shared integration helpers
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
- Linux with X11 session
|
||||
|
|
@ -89,6 +97,30 @@ deskctl doctor
|
|||
|
||||
`deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract.
|
||||
|
||||
## Workflow
|
||||
|
||||
Local validation uses the root `Makefile`:
|
||||
|
||||
```bash
|
||||
make fmt-check
|
||||
make lint
|
||||
make test-unit
|
||||
make test-integration
|
||||
make site-format-check
|
||||
make validate
|
||||
```
|
||||
|
||||
`make validate` is the full repo-quality check and requires Linux with `xvfb-run` plus `pnpm --dir site install`.
|
||||
|
||||
The repository standardizes on `pre-commit` for fast commit-time checks:
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [@barrettruth](github.com/barrettruth) - i stole the website from [vimdoc](https://github.com/barrettruth/vimdoc-language-server)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-25
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
## Context
|
||||
|
||||
Phase 1 stabilized the runtime contract, but the repo still lacks the structure and tooling needed to keep that contract stable as contributors add features. The current state is mixed:
|
||||
|
||||
- Rust checks exist in CI, but there is no explicit `cargo test` lane and no Xvfb integration lane.
|
||||
- The repo has site-local Prettier config under `site/`, but no root-level contributor workflow for formatting or hooks.
|
||||
- Integration-style tests are starting to appear, but the crate is still binary-first and does not yet have a clean top-level `tests/` structure.
|
||||
- An empty `src/tests/` directory exists, which suggests the intended direction is not yet settled.
|
||||
|
||||
This change should establish the repo-quality foundation before Phase 3 agent features and Phase 4 distribution work expand the maintenance surface further.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Define a clean test architecture with a centralized top-level `tests/` directory for integration coverage.
|
||||
- Introduce the crate structure needed for integration tests to import the project cleanly.
|
||||
- Add a real Xvfb-backed CI lane and make CI validate the same commands contributors run locally.
|
||||
- Define one formatting and hook workflow for the repo instead of ad hoc tool usage.
|
||||
- Keep the site formatting story integrated without turning the entire repo into a Node-first project.
|
||||
|
||||
**Non-Goals:**
|
||||
- New runtime capabilities such as `wait-for-window`, `version`, or broader read commands.
|
||||
- npm distribution, crates.io publishing, or release automation changes.
|
||||
- Introducing both Husky and pre-commit, or multiple competing hook systems.
|
||||
- Adding `rustfmt.toml` unless we have a concrete non-default formatting requirement.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Convert the crate to library + binary
|
||||
|
||||
This change will introduce `src/lib.rs` and make `src/main.rs` a thin binary wrapper.
|
||||
|
||||
Rationale:
|
||||
- Top-level Rust integration tests in `/tests` should import the crate cleanly.
|
||||
- Shared test support and internal modules become easier to organize without abusing `src/main.rs`.
|
||||
- This is the standard structure for a Rust project that needs both unit and integration coverage.
|
||||
|
||||
Alternatives considered:
|
||||
- Keep the binary-only layout and continue placing all tests inside `src/`. Rejected because it makes integration coverage awkward and keeps test structure implicit.
|
||||
- Put integration helpers in `src/tests` without a library target. Rejected because it preserves the binary-first coupling and does not create a clean external-test boundary.
|
||||
|
||||
### 2. Standardize on top-level `/tests` for integration coverage
|
||||
|
||||
Integration tests will live under a centralized `/tests` directory, with shared helpers under `tests/support/`.
|
||||
|
||||
Rationale:
|
||||
- The runtime-facing flows are integration problems, not unit problems.
|
||||
- A centralized `/tests` layout makes it clear which tests require Xvfb or daemon orchestration.
|
||||
- It keeps `src/` focused on application code.
|
||||
|
||||
Alternatives considered:
|
||||
- Keep helpers in `src/test_support.rs`. Rejected because it mixes production and integration concerns.
|
||||
|
||||
### 3. Standardize on `pre-commit`, not Husky
|
||||
|
||||
This change will define one hook system: `pre-commit`.
|
||||
|
||||
Rationale:
|
||||
- The repo is Rust-first, not root-Node-managed.
|
||||
- Husky would imply a root `package.json` and a Node-first workflow the repo does not currently have.
|
||||
- `pre-commit` can run Rust and site checks without forcing the whole repo through npm.
|
||||
|
||||
Alternatives considered:
|
||||
- Husky. Rejected because it introduces a root Node workflow for a repo that is not otherwise Node-based.
|
||||
- Both Husky and pre-commit. Rejected because dual hook systems inevitably drift.
|
||||
- No hooks. Rejected because contributor ergonomics and CI parity are explicit goals of this phase.
|
||||
|
||||
### 4. Keep formatting opinionated but minimal
|
||||
|
||||
This phase will use default `rustfmt` behavior and site-local Prettier behavior. A root `rustfmt.toml` will only be added if the implementation reveals a real non-default formatting need.
|
||||
|
||||
Rationale:
|
||||
- A config file with no meaningful opinion is noise.
|
||||
- What matters more is that CI and hooks actually run formatting checks.
|
||||
- The site already has a working Prettier configuration; we should integrate it rather than duplicate it prematurely.
|
||||
|
||||
Alternatives considered:
|
||||
- Add `rustfmt.toml` immediately. Rejected because there is no concrete formatting policy to encode yet.
|
||||
- Add a root Prettier config for the whole repo. Rejected because it would broaden Node tooling scope before there is a clear need.
|
||||
|
||||
### 5. CI should call stable local entrypoints
|
||||
|
||||
This phase should define one local command surface for validation, and CI should call those same commands instead of hand-coded bespoke steps where practical.
|
||||
|
||||
Candidate checks:
|
||||
- format check
|
||||
- clippy
|
||||
- unit tests
|
||||
- Xvfb integration tests
|
||||
- site formatting check
|
||||
|
||||
Rationale:
|
||||
- Local/CI drift is one of the fastest ways to make an open source repo unpleasant to contribute to.
|
||||
- Contributors should be able to run the same validation shape locally before pushing.
|
||||
|
||||
Alternatives considered:
|
||||
- Keep all validation logic encoded only in GitHub Actions. Rejected because local parity matters.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Introducing `src/lib.rs` creates some churn] → Keep `main.rs` thin and preserve module names to minimize callsite disruption.
|
||||
- [Xvfb CI can be flaky if test fixtures are underspecified] → Keep fixture windows simple and deterministic; avoid broad screenshot assertions early.
|
||||
- [Pre-commit adds a Python-based contributor dependency] → Document installation clearly and keep hooks fast so the value exceeds the setup cost.
|
||||
- [Formatting/tooling scope could sprawl into site build work] → Limit this phase to formatting and validation, not full site build architecture.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Introduce `src/lib.rs` and move reusable modules behind the library target.
|
||||
2. Move integration support into top-level `tests/support/` and create real `/tests` coverage for Xvfb-backed flows.
|
||||
3. Add local validation entrypoints for formatting, lint, and tests.
|
||||
4. Add a root hook configuration using `pre-commit`.
|
||||
5. Update CI to run unit tests, Xvfb integration tests, and relevant formatting checks on pull requests and main.
|
||||
6. Update contributor docs so local validation, hooks, and test structure are discoverable.
|
||||
|
||||
Rollback strategy:
|
||||
- This phase is repo-internal and pre-1.0, so rollback is a normal revert rather than a compatibility shim.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Whether the local validation entrypoint should be a `Justfile`, `Makefile`, or another lightweight wrapper.
|
||||
- Whether site validation in this phase should be limited to Prettier checks or also include `astro check`.
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
## Why
|
||||
|
||||
`deskctl` now has a decent Phase 1 runtime contract, but the repo still lacks the test architecture and local tooling discipline needed to keep that contract stable as the project grows. Before moving into broader agent primitives or distribution polish, the repo needs a real integration-test story, CI coverage that exercises it, and one coherent developer workflow for formatting and pre-commit checks.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Introduce a proper repository quality foundation for testing, CI, and local developer tooling.
|
||||
- Establish a centralized top-level integration test layout and the crate structure needed to support it cleanly.
|
||||
- Add an explicit Xvfb-backed CI lane for runtime integration tests.
|
||||
- Define one local formatting and hook workflow for Rust and site content instead of ad hoc tool usage.
|
||||
- Add contributor-facing commands and docs for running the same checks locally that CI will enforce.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `repo-quality`: Repository-level quality guarantees covering test architecture, CI validation, formatting, and local hook workflow.
|
||||
|
||||
### Modified Capabilities
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Rust crate layout in `src/` and likely a new `src/lib.rs`
|
||||
- New top-level `tests/` structure and shared integration test support
|
||||
- GitHub Actions workflow(s) under `.github/workflows/`
|
||||
- Root-level contributor tooling files such as `.pre-commit-config.yaml` and related local task entrypoints
|
||||
- Site formatting integration for files under `site/`
|
||||
- README and contributor documentation describing local validation workflows
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Repository exposes a clean integration test architecture
|
||||
The repository SHALL provide a top-level integration test architecture that allows runtime flows to be tested outside in-module unit tests.
|
||||
|
||||
#### Scenario: Integration tests import the crate cleanly
|
||||
- **WHEN** an integration test under `/tests` needs to exercise repository code
|
||||
- **THEN** it imports the project through a library target rather than depending on binary-only wiring
|
||||
|
||||
#### Scenario: Integration helpers are centralized
|
||||
- **WHEN** multiple integration tests need shared X11 or daemon helpers
|
||||
- **THEN** those helpers live in a shared test-support location under `/tests`
|
||||
- **AND** production code does not need to host integration-only support files
|
||||
|
||||
### Requirement: CI validates real X11 integration behavior
|
||||
The repository SHALL run Xvfb-backed integration coverage in CI for the supported X11 runtime.
|
||||
|
||||
#### Scenario: Pull requests run X11 integration tests
|
||||
- **WHEN** a pull request modifies runtime or test-relevant code
|
||||
- **THEN** CI runs the repository's Xvfb-backed integration test lane
|
||||
- **AND** fails the change if the integration lane does not pass
|
||||
|
||||
#### Scenario: Integration lane covers core runtime flows
|
||||
- **WHEN** the Xvfb integration lane runs
|
||||
- **THEN** it exercises at least runtime diagnostics, window enumeration, and daemon startup/recovery behavior
|
||||
|
||||
### Requirement: Repository defines one local validation workflow
|
||||
The repository SHALL define one coherent local validation workflow that contributors can run before pushing and that CI can mirror.
|
||||
|
||||
#### Scenario: Local formatting and linting entrypoints are documented
|
||||
- **WHEN** a contributor wants to validate a change locally
|
||||
- **THEN** the repository provides documented commands for formatting, linting, unit tests, and integration tests
|
||||
|
||||
#### Scenario: CI and local validation stay aligned
|
||||
- **WHEN** CI validates the repository
|
||||
- **THEN** it uses the same validation categories that contributors are expected to run locally
|
||||
- **AND** avoids introducing a separate undocumented CI-only workflow
|
||||
|
||||
### Requirement: Repository uses a single hook system
|
||||
The repository SHALL standardize on one pre-commit hook workflow for contributor checks.
|
||||
|
||||
#### Scenario: Hook workflow does not require root Node ownership
|
||||
- **WHEN** a contributor installs the hook workflow
|
||||
- **THEN** the repository can run Rust and site checks without requiring a root-level Node package workflow
|
||||
|
||||
#### Scenario: Hook scope stays fast and focused
|
||||
- **WHEN** the pre-commit hook runs
|
||||
- **THEN** it executes only fast checks appropriate for commit-time feedback
|
||||
- **AND** slower validation remains in pre-push or CI lanes
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
## 1. Repository Structure
|
||||
|
||||
- [x] 1.1 Introduce `src/lib.rs`, keep `src/main.rs` as a thin binary wrapper, and preserve existing module boundaries so integration tests can import the crate cleanly
|
||||
- [x] 1.2 Move integration-only helpers out of production modules into `tests/support/` and remove the unused `src/tests` direction so the test layout is unambiguous
|
||||
- [x] 1.3 Add top-level integration tests under `tests/` that exercise at least diagnostics, window enumeration, and daemon startup/recovery flows through the library target
|
||||
|
||||
## 2. Local Validation Tooling
|
||||
|
||||
- [x] 2.1 Add one documented local validation entrypoint for formatting, linting, unit tests, integration tests, and site formatting checks
|
||||
- [x] 2.2 Add a root `.pre-commit-config.yaml` that standardizes on `pre-commit` for fast commit-time checks without introducing a root Node workflow
|
||||
- [x] 2.3 Keep formatting configuration minimal by using default `rustfmt`, reusing the existing site-local Prettier setup, and only adding new config where implementation requires it
|
||||
|
||||
## 3. CI Hardening
|
||||
|
||||
- [x] 3.1 Update GitHub Actions to validate pull requests in addition to `main` pushes and to run the same validation categories contributors run locally
|
||||
- [x] 3.2 Add an explicit Xvfb-backed CI lane that runs the integration tests covering diagnostics, window enumeration, and daemon recovery behavior
|
||||
- [x] 3.3 Ensure CI also runs the repository's formatting, clippy, unit test, and site formatting checks through the shared local entrypoints where practical
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Update contributor-facing docs to explain the new crate/test layout, including where integration tests and shared helpers live
|
||||
- [x] 4.2 Document the local validation workflow and `pre-commit` installation/use so contributors can reproduce CI expectations locally
|
||||
- [x] 4.3 Update the Phase 2 planning/docs references so the repo-quality foundation clearly lands before later packaging and distribution phases
|
||||
49
openspec/specs/repo-quality/spec.md
Normal file
49
openspec/specs/repo-quality/spec.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Repository exposes a clean integration test architecture
|
||||
The repository SHALL provide a top-level integration test architecture that allows runtime flows to be tested outside in-module unit tests.
|
||||
|
||||
#### Scenario: Integration tests import the crate cleanly
|
||||
- **WHEN** an integration test under `/tests` needs to exercise repository code
|
||||
- **THEN** it imports the project through a library target rather than depending on binary-only wiring
|
||||
|
||||
#### Scenario: Integration helpers are centralized
|
||||
- **WHEN** multiple integration tests need shared X11 or daemon helpers
|
||||
- **THEN** those helpers live in a shared test-support location under `/tests`
|
||||
- **AND** production code does not need to host integration-only support files
|
||||
|
||||
### Requirement: CI validates real X11 integration behavior
|
||||
The repository SHALL run Xvfb-backed integration coverage in CI for the supported X11 runtime.
|
||||
|
||||
#### Scenario: Pull requests run X11 integration tests
|
||||
- **WHEN** a pull request modifies runtime or test-relevant code
|
||||
- **THEN** CI runs the repository's Xvfb-backed integration test lane
|
||||
- **AND** fails the change if the integration lane does not pass
|
||||
|
||||
#### Scenario: Integration lane covers core runtime flows
|
||||
- **WHEN** the Xvfb integration lane runs
|
||||
- **THEN** it exercises at least runtime diagnostics, window enumeration, and daemon startup/recovery behavior
|
||||
|
||||
### Requirement: Repository defines one local validation workflow
|
||||
The repository SHALL define one coherent local validation workflow that contributors can run before pushing and that CI can mirror.
|
||||
|
||||
#### Scenario: Local formatting and linting entrypoints are documented
|
||||
- **WHEN** a contributor wants to validate a change locally
|
||||
- **THEN** the repository provides documented commands for formatting, linting, unit tests, and integration tests
|
||||
|
||||
#### Scenario: CI and local validation stay aligned
|
||||
- **WHEN** CI validates the repository
|
||||
- **THEN** it uses the same validation categories that contributors are expected to run locally
|
||||
- **AND** avoids introducing a separate undocumented CI-only workflow
|
||||
|
||||
### Requirement: Repository uses a single hook system
|
||||
The repository SHALL standardize on one pre-commit hook workflow for contributor checks.
|
||||
|
||||
#### Scenario: Hook workflow does not require root Node ownership
|
||||
- **WHEN** a contributor installs the hook workflow
|
||||
- **THEN** the repository can run Rust and site checks without requiring a root-level Node package workflow
|
||||
|
||||
#### Scenario: Hook scope stays fast and focused
|
||||
- **WHEN** the pre-commit hook runs
|
||||
- **THEN** it executes only fast checks appropriate for commit-time feedback
|
||||
- **AND** slower validation remains in pre-push or CI lanes
|
||||
|
|
@ -19,7 +19,7 @@ Requests and responses are newline-delimited JSON (NDJSON) over a Unix socket.
|
|||
**Request:**
|
||||
|
||||
```json
|
||||
{"id": "r123456", "action": "snapshot", "annotate": true}
|
||||
{ "id": "r123456", "action": "snapshot", "annotate": true }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
|
@ -31,7 +31,7 @@ Requests and responses are newline-delimited JSON (NDJSON) over a Unix socket.
|
|||
Error responses include an `error` field:
|
||||
|
||||
```json
|
||||
{"success": false, "error": "window not found: @w99"}
|
||||
{ "success": false, "error": "window not found: @w99" }
|
||||
```
|
||||
|
||||
## Socket location
|
||||
|
|
@ -66,13 +66,13 @@ The trait-based design means adding Wayland support is a single trait implementa
|
|||
|
||||
Window detection uses EWMH properties:
|
||||
|
||||
| Property | Purpose |
|
||||
|----------|---------|
|
||||
| `_NET_CLIENT_LIST_STACKING` | Window stacking order |
|
||||
| `_NET_ACTIVE_WINDOW` | Currently focused window |
|
||||
| `_NET_WM_NAME` | Window title (UTF-8) |
|
||||
| `_NET_WM_STATE_HIDDEN` | Minimized state |
|
||||
| `_NET_CLOSE_WINDOW` | Graceful close |
|
||||
| `WM_CLASS` | Application class/name |
|
||||
| Property | Purpose |
|
||||
| --------------------------- | ------------------------ |
|
||||
| `_NET_CLIENT_LIST_STACKING` | Window stacking order |
|
||||
| `_NET_ACTIVE_WINDOW` | Currently focused window |
|
||||
| `_NET_WM_NAME` | Window title (UTF-8) |
|
||||
| `_NET_WM_STATE_HIDDEN` | Minimized state |
|
||||
| `_NET_CLOSE_WINDOW` | Graceful close |
|
||||
| `WM_CLASS` | Application class/name |
|
||||
|
||||
Falls back to `XQueryTree` if `_NET_CLIENT_LIST_STACKING` is unavailable.
|
||||
|
|
|
|||
|
|
@ -169,8 +169,8 @@ deskctl launch code --args /path/to/project
|
|||
|
||||
## Global options
|
||||
|
||||
| Flag | Env | Description |
|
||||
|------|-----|-------------|
|
||||
| `--json` | | Output as JSON |
|
||||
| `--socket <path>` | `DESKCTL_SOCKET` | Path to daemon Unix socket |
|
||||
| `--session <name>` | | Session name for multiple daemons (default: `default`) |
|
||||
| Flag | Env | Description |
|
||||
| ------------------ | ---------------- | ------------------------------------------------------ |
|
||||
| `--json` | | Output as JSON |
|
||||
| `--socket <path>` | `DESKCTL_SOCKET` | Path to daemon Unix socket |
|
||||
| `--session <name>` | | Session name for multiple daemons (default: `default`) |
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import DocLayout from "../layouts/DocLayout.astro";
|
|||
</header>
|
||||
|
||||
<p>
|
||||
Desktop control CLI for AI agents on Linux X11. Compact JSON output
|
||||
for agent loops. Screenshot, click, type, scroll, drag, and manage
|
||||
windows through a fast client-daemon architecture. 100% native Rust.
|
||||
Desktop control CLI for AI agents on Linux X11. Compact JSON output for
|
||||
agent loops. Screenshot, click, type, scroll, drag, and manage windows
|
||||
through a fast client-daemon architecture. 100% native Rust.
|
||||
</p>
|
||||
|
||||
<h2>Getting started</h2>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ use std::process::{Command, Stdio};
|
|||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::cli::GlobalOpts;
|
||||
use crate::core::doctor::{DoctorReport, run as run_doctor_report};
|
||||
use crate::core::doctor::{run as run_doctor_report, DoctorReport};
|
||||
use crate::core::paths::{pid_path_for_session, socket_dir, socket_path_for_session};
|
||||
use crate::core::protocol::{Request, Response};
|
||||
|
||||
|
|
@ -95,7 +95,8 @@ fn send_request_over_stream(mut stream: UnixStream, request: &Request) -> Result
|
|||
}
|
||||
|
||||
fn ping_daemon(opts: &GlobalOpts) -> Result<()> {
|
||||
let response = send_request_over_stream(connect_socket(&socket_path(opts))?, &Request::new("ping"))?;
|
||||
let response =
|
||||
send_request_over_stream(connect_socket(&socket_path(opts))?, &Request::new("ping"))?;
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
|
|
@ -212,7 +213,9 @@ pub fn daemon_status(opts: &GlobalOpts) -> Result<()> {
|
|||
let path = socket_path(opts);
|
||||
match ping_daemon(opts) {
|
||||
Ok(()) => println!("Daemon running ({})", path.display()),
|
||||
Err(_) if path.exists() => println!("Daemon socket exists but is unhealthy ({})", path.display()),
|
||||
Err(_) if path.exists() => {
|
||||
println!("Daemon socket exists but is unhealthy ({})", path.display())
|
||||
}
|
||||
Err(_) => println!("Daemon not running"),
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -226,7 +229,11 @@ fn print_doctor_report(report: &DoctorReport, json_output: bool) -> Result<()> {
|
|||
|
||||
println!(
|
||||
"deskctl doctor: {}",
|
||||
if report.healthy { "healthy" } else { "issues found" }
|
||||
if report.healthy {
|
||||
"healthy"
|
||||
} else {
|
||||
"issues found"
|
||||
}
|
||||
);
|
||||
for check in &report.checks {
|
||||
let status = if check.ok { "OK" } else { "FAIL" };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
mod connection;
|
||||
pub mod connection;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
|
|
|||
|
|
@ -84,13 +84,16 @@ pub fn run(socket_path: &Path) -> DoctorReport {
|
|||
checks.push(match backend.capture_screenshot() {
|
||||
Ok(image) => check_ok(
|
||||
"screenshot",
|
||||
format!("Captured {}x{} desktop image", image.width(), image.height()),
|
||||
format!(
|
||||
"Captured {}x{} desktop image",
|
||||
image.width(),
|
||||
image.height()
|
||||
),
|
||||
),
|
||||
Err(error) => check_fail(
|
||||
"screenshot",
|
||||
error.to_string(),
|
||||
"Verify the X11 session permits desktop capture on the active display."
|
||||
.to_string(),
|
||||
"Verify the X11 session permits desktop capture on the active display.".to_string(),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
|
|
@ -117,7 +120,10 @@ fn check_socket_dir(socket_path: &Path) -> DoctorCheck {
|
|||
let Some(socket_dir) = socket_path.parent() else {
|
||||
return check_fail(
|
||||
"socket-dir",
|
||||
format!("Socket path {} has no parent directory", socket_path.display()),
|
||||
format!(
|
||||
"Socket path {} has no parent directory",
|
||||
socket_path.display()
|
||||
),
|
||||
"Use a socket path inside a writable directory.".to_string(),
|
||||
);
|
||||
};
|
||||
|
|
@ -203,37 +209,3 @@ fn check_fail(name: &str, details: String, fix: String) -> DoctorCheck {
|
|||
fix: Some(fix),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::run;
|
||||
use crate::test_support::{X11TestEnv, env_lock};
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_healthy_x11_environment_under_xvfb() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let Some(env) = X11TestEnv::new().unwrap() else {
|
||||
eprintln!("Skipping Xvfb-dependent doctor test");
|
||||
return;
|
||||
};
|
||||
env.create_window("deskctl doctor test", "DeskctlDoctor").unwrap();
|
||||
|
||||
let socket_path = std::env::temp_dir().join("deskctl-doctor-test.sock");
|
||||
let report = run(&socket_path);
|
||||
|
||||
assert!(report.checks.iter().any(|check| check.name == "display" && check.ok));
|
||||
assert!(report.checks.iter().any(|check| check.name == "backend" && check.ok));
|
||||
assert!(
|
||||
report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "window-enumeration" && check.ok)
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "screenshot" && check.ok)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,8 +136,12 @@ impl RefMap {
|
|||
|
||||
/// Resolve a selector to the center coordinates of the window.
|
||||
pub fn resolve_to_center(&self, selector: &str) -> Option<(i32, i32)> {
|
||||
self.resolve(selector)
|
||||
.map(|entry| (entry.x + entry.width as i32 / 2, entry.y + entry.height as i32 / 2))
|
||||
self.resolve(selector).map(|entry| {
|
||||
(
|
||||
entry.x + entry.width as i32 / 2,
|
||||
entry.y + entry.height as i32 / 2,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&String, &RefEntry)> {
|
||||
|
|
@ -182,7 +186,10 @@ mod tests {
|
|||
|
||||
assert_eq!(refs.resolve("@w1").unwrap().window_id, window_id);
|
||||
assert_eq!(refs.resolve(&window_id).unwrap().backend_window_id, 42);
|
||||
assert_eq!(refs.resolve(&format!("id={window_id}")).unwrap().title, "Editor");
|
||||
assert_eq!(
|
||||
refs.resolve(&format!("id={window_id}")).unwrap().title,
|
||||
"Editor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -394,14 +394,13 @@ fn capture_snapshot(
|
|||
) -> Result<Snapshot> {
|
||||
let windows = refresh_windows(state)?;
|
||||
let screenshot_path = path.unwrap_or_else(temp_screenshot_path);
|
||||
let screenshot = capture_and_save_screenshot(
|
||||
state,
|
||||
&screenshot_path,
|
||||
annotate,
|
||||
Some(&windows),
|
||||
)?;
|
||||
let screenshot =
|
||||
capture_and_save_screenshot(state, &screenshot_path, annotate, Some(&windows))?;
|
||||
|
||||
Ok(Snapshot { screenshot, windows })
|
||||
Ok(Snapshot {
|
||||
screenshot,
|
||||
windows,
|
||||
})
|
||||
}
|
||||
|
||||
fn capture_and_save_screenshot(
|
||||
|
|
@ -439,55 +438,3 @@ fn parse_coords(value: &str) -> Option<(i32, i32)> {
|
|||
let y = parts[1].trim().parse().ok()?;
|
||||
Some((x, y))
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::handle_request;
|
||||
use crate::core::protocol::Request;
|
||||
use crate::daemon::state::DaemonState;
|
||||
use crate::test_support::{X11TestEnv, deskctl_tmp_screenshot_count, env_lock};
|
||||
|
||||
#[test]
|
||||
fn list_windows_is_side_effect_free_under_xvfb() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let Some(env) = X11TestEnv::new().unwrap() else {
|
||||
eprintln!("Skipping Xvfb-dependent list-windows test");
|
||||
return;
|
||||
};
|
||||
env.create_window("deskctl list-windows test", "DeskctlList").unwrap();
|
||||
|
||||
let before = deskctl_tmp_screenshot_count();
|
||||
let runtime = Builder::new_current_thread().enable_all().build().unwrap();
|
||||
let state = Arc::new(Mutex::new(
|
||||
DaemonState::new(
|
||||
"test".to_string(),
|
||||
std::env::temp_dir().join("deskctl-list-windows.sock"),
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
|
||||
let response = runtime.block_on(handle_request(&Request::new("list-windows"), &state));
|
||||
assert!(response.success);
|
||||
|
||||
let data = response.data.unwrap();
|
||||
let windows = data
|
||||
.get("windows")
|
||||
.and_then(|value| value.as_array())
|
||||
.unwrap();
|
||||
assert!(windows.iter().any(|window| {
|
||||
window
|
||||
.get("title")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|title| title == "deskctl list-windows test")
|
||||
.unwrap_or(false)
|
||||
}));
|
||||
|
||||
let after = deskctl_tmp_screenshot_count();
|
||||
assert_eq!(before, after, "list-windows should not create screenshot artifacts");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/lib.rs
Normal file
11
src/lib.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
pub mod backend;
|
||||
pub mod cli;
|
||||
pub mod core;
|
||||
pub mod daemon;
|
||||
|
||||
pub fn run() -> anyhow::Result<()> {
|
||||
if std::env::var("DESKCTL_DAEMON").is_ok() {
|
||||
return daemon::run();
|
||||
}
|
||||
cli::run()
|
||||
}
|
||||
12
src/main.rs
12
src/main.rs
|
|
@ -1,13 +1,3 @@
|
|||
mod backend;
|
||||
mod cli;
|
||||
mod core;
|
||||
mod daemon;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
if std::env::var("DESKCTL_DAEMON").is_ok() {
|
||||
return daemon::run();
|
||||
}
|
||||
cli::run()
|
||||
deskctl::run()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
#![cfg(all(test, target_os = "linux"))]
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::{
|
||||
AtomEnum, ConnectionExt as XprotoConnectionExt, CreateWindowAux, EventMask, PropMode,
|
||||
WindowClass,
|
||||
};
|
||||
|
||||
pub fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
pub struct X11TestEnv {
|
||||
child: Child,
|
||||
old_display: Option<String>,
|
||||
old_session_type: Option<String>,
|
||||
}
|
||||
|
||||
impl X11TestEnv {
|
||||
pub fn new() -> Result<Option<Self>> {
|
||||
if Command::new("Xvfb")
|
||||
.arg("-help")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_err()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for display_num in 90..110 {
|
||||
let display = format!(":{display_num}");
|
||||
let lock_path = format!("/tmp/.X{display_num}-lock");
|
||||
let unix_socket = format!("/tmp/.X11-unix/X{display_num}");
|
||||
if Path::new(&lock_path).exists() || Path::new(&unix_socket).exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let child = Command::new("Xvfb")
|
||||
.arg(&display)
|
||||
.arg("-screen")
|
||||
.arg("0")
|
||||
.arg("1024x768x24")
|
||||
.arg("-nolisten")
|
||||
.arg("tcp")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to launch Xvfb on {display}"))?;
|
||||
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
|
||||
let old_display = std::env::var("DISPLAY").ok();
|
||||
let old_session_type = std::env::var("XDG_SESSION_TYPE").ok();
|
||||
std::env::set_var("DISPLAY", &display);
|
||||
std::env::set_var("XDG_SESSION_TYPE", "x11");
|
||||
|
||||
return Ok(Some(Self {
|
||||
child,
|
||||
old_display,
|
||||
old_session_type,
|
||||
}));
|
||||
}
|
||||
|
||||
anyhow::bail!("Failed to find a free Xvfb display")
|
||||
}
|
||||
|
||||
pub fn create_window(&self, title: &str, app_class: &str) -> Result<()> {
|
||||
let (conn, screen_num) =
|
||||
x11rb::connect(None).context("Failed to connect to test Xvfb display")?;
|
||||
let screen = &conn.setup().roots[screen_num];
|
||||
let window = conn.generate_id()?;
|
||||
|
||||
conn.create_window(
|
||||
x11rb::COPY_DEPTH_FROM_PARENT,
|
||||
window,
|
||||
screen.root,
|
||||
10,
|
||||
10,
|
||||
320,
|
||||
180,
|
||||
0,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
0,
|
||||
&CreateWindowAux::new()
|
||||
.background_pixel(screen.white_pixel)
|
||||
.event_mask(EventMask::EXPOSURE),
|
||||
)?;
|
||||
conn.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_NAME,
|
||||
AtomEnum::STRING,
|
||||
title.as_bytes(),
|
||||
)?;
|
||||
let class_bytes = format!("{app_class}\0{app_class}\0");
|
||||
conn.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_CLASS,
|
||||
AtomEnum::STRING,
|
||||
class_bytes.as_bytes(),
|
||||
)?;
|
||||
conn.map_window(window)?;
|
||||
conn.flush()?;
|
||||
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for X11TestEnv {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
|
||||
match &self.old_display {
|
||||
Some(value) => std::env::set_var("DISPLAY", value),
|
||||
None => std::env::remove_var("DISPLAY"),
|
||||
}
|
||||
|
||||
match &self.old_session_type {
|
||||
Some(value) => std::env::set_var("XDG_SESSION_TYPE", value),
|
||||
None => std::env::remove_var("XDG_SESSION_TYPE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deskctl_tmp_screenshot_count() -> usize {
|
||||
std::fs::read_dir("/tmp")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|iter| iter.filter_map(Result::ok))
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(|name| name.starts_with("deskctl-") && name.ends_with(".png"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
220
tests/support/mod.rs
Normal file
220
tests/support/mod.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use deskctl::cli::{connection, GlobalOpts};
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::{
|
||||
AtomEnum, ConnectionExt as XprotoConnectionExt, CreateWindowAux, EventMask, PropMode,
|
||||
WindowClass,
|
||||
};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
use x11rb::wrapper::ConnectionExt as X11WrapperConnectionExt;
|
||||
|
||||
pub fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
pub struct SessionEnvGuard {
|
||||
old_session_type: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionEnvGuard {
|
||||
pub fn prepare() -> Option<Self> {
|
||||
let _display = std::env::var("DISPLAY")
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())?;
|
||||
|
||||
let old_session_type = std::env::var("XDG_SESSION_TYPE").ok();
|
||||
std::env::set_var("XDG_SESSION_TYPE", "x11");
|
||||
Some(Self { old_session_type })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.old_session_type {
|
||||
Some(value) => std::env::set_var("XDG_SESSION_TYPE", value),
|
||||
None => std::env::remove_var("XDG_SESSION_TYPE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FixtureWindow {
|
||||
conn: RustConnection,
|
||||
window: u32,
|
||||
}
|
||||
|
||||
impl FixtureWindow {
|
||||
pub fn create(title: &str, app_class: &str) -> Result<Self> {
|
||||
let (conn, screen_num) =
|
||||
x11rb::connect(None).context("Failed to connect to the integration test display")?;
|
||||
let screen = &conn.setup().roots[screen_num];
|
||||
let window = conn.generate_id()?;
|
||||
|
||||
conn.create_window(
|
||||
x11rb::COPY_DEPTH_FROM_PARENT,
|
||||
window,
|
||||
screen.root,
|
||||
10,
|
||||
10,
|
||||
320,
|
||||
180,
|
||||
0,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
0,
|
||||
&CreateWindowAux::new()
|
||||
.background_pixel(screen.white_pixel)
|
||||
.event_mask(EventMask::EXPOSURE),
|
||||
)?;
|
||||
conn.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_NAME,
|
||||
AtomEnum::STRING,
|
||||
title.as_bytes(),
|
||||
)?;
|
||||
let class_bytes = format!("{app_class}\0{app_class}\0");
|
||||
conn.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_CLASS,
|
||||
AtomEnum::STRING,
|
||||
class_bytes.as_bytes(),
|
||||
)?;
|
||||
conn.map_window(window)?;
|
||||
conn.flush()?;
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
Ok(Self { conn, window })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FixtureWindow {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.conn.destroy_window(self.window);
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TestSession {
|
||||
pub opts: GlobalOpts,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl TestSession {
|
||||
pub fn new(label: &str) -> Result<Self> {
|
||||
let suffix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("System clock is before the Unix epoch")?
|
||||
.as_nanos();
|
||||
let root = std::env::temp_dir().join(format!("deskctl-{label}-{suffix}"));
|
||||
std::fs::create_dir_all(&root)
|
||||
.with_context(|| format!("Failed to create {}", root.display()))?;
|
||||
|
||||
Ok(Self {
|
||||
opts: GlobalOpts {
|
||||
socket: Some(root.join("deskctl.sock")),
|
||||
session: format!("{label}-{suffix}"),
|
||||
json: false,
|
||||
},
|
||||
root,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn socket_path(&self) -> &Path {
|
||||
self.opts
|
||||
.socket
|
||||
.as_deref()
|
||||
.expect("TestSession always has an explicit socket path")
|
||||
}
|
||||
|
||||
pub fn create_stale_socket(&self) -> Result<()> {
|
||||
let listener = UnixListener::bind(self.socket_path())
|
||||
.with_context(|| format!("Failed to bind {}", self.socket_path().display()))?;
|
||||
drop(listener);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start_daemon_cli(&self) -> Result<()> {
|
||||
let output = self.run_cli(["daemon", "start"])?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
bail!(
|
||||
"deskctl daemon start failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
pub fn run_cli<I, S>(&self, args: I) -> Result<Output>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let socket = self.socket_path();
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_deskctl"));
|
||||
command
|
||||
.arg("--socket")
|
||||
.arg(socket)
|
||||
.arg("--session")
|
||||
.arg(&self.opts.session);
|
||||
|
||||
for arg in args {
|
||||
command.arg(arg.as_ref());
|
||||
}
|
||||
|
||||
command.output().with_context(|| {
|
||||
format!(
|
||||
"Failed to run {} against {}",
|
||||
env!("CARGO_BIN_EXE_deskctl"),
|
||||
socket.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestSession {
|
||||
fn drop(&mut self) {
|
||||
let _ = connection::stop_daemon(&self.opts);
|
||||
if self.socket_path().exists() {
|
||||
let _ = std::fs::remove_file(self.socket_path());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deskctl_tmp_screenshot_count() -> usize {
|
||||
std::fs::read_dir("/tmp")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|iter| iter.filter_map(Result::ok))
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(|name| name.starts_with("deskctl-") && name.ends_with(".png"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn successful_json_response(output: Output) -> Result<serde_json::Value> {
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"deskctl command failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl")
|
||||
}
|
||||
115
tests/x11_runtime.rs
Normal file
115
tests/x11_runtime.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#![cfg(target_os = "linux")]
|
||||
|
||||
mod support;
|
||||
|
||||
use anyhow::Result;
|
||||
use deskctl::cli::connection::send_command;
|
||||
use deskctl::core::doctor;
|
||||
use deskctl::core::protocol::Request;
|
||||
|
||||
use self::support::{
|
||||
deskctl_tmp_screenshot_count, env_lock, successful_json_response, FixtureWindow,
|
||||
SessionEnvGuard, TestSession,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_healthy_x11_environment() -> Result<()> {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let Some(_env) = SessionEnvGuard::prepare() else {
|
||||
eprintln!("Skipping X11 integration test because DISPLAY is not set");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let _window = FixtureWindow::create("deskctl doctor test", "DeskctlDoctor")?;
|
||||
let session = TestSession::new("doctor")?;
|
||||
let report = doctor::run(session.socket_path());
|
||||
|
||||
assert!(report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "display" && check.ok));
|
||||
assert!(report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "backend" && check.ok));
|
||||
assert!(report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "window-enumeration" && check.ok));
|
||||
assert!(report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "screenshot" && check.ok));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_windows_is_side_effect_free() -> Result<()> {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let Some(_env) = SessionEnvGuard::prepare() else {
|
||||
eprintln!("Skipping X11 integration test because DISPLAY is not set");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let _window = FixtureWindow::create("deskctl list-windows test", "DeskctlList")?;
|
||||
let session = TestSession::new("list-windows")?;
|
||||
session.start_daemon_cli()?;
|
||||
|
||||
let before = deskctl_tmp_screenshot_count();
|
||||
let response = send_command(&session.opts, &Request::new("list-windows"))?;
|
||||
assert!(response.success);
|
||||
|
||||
let windows = response
|
||||
.data
|
||||
.and_then(|data| data.get("windows").cloned())
|
||||
.and_then(|windows| windows.as_array().cloned())
|
||||
.expect("list-windows response must include a windows array");
|
||||
assert!(windows.iter().any(|window| {
|
||||
window
|
||||
.get("title")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|title| title == "deskctl list-windows test")
|
||||
.unwrap_or(false)
|
||||
}));
|
||||
|
||||
let after = deskctl_tmp_screenshot_count();
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"list-windows should not create screenshot artifacts"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_start_recovers_from_stale_socket() -> Result<()> {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let Some(_env) = SessionEnvGuard::prepare() else {
|
||||
eprintln!("Skipping X11 integration test because DISPLAY is not set");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let _window = FixtureWindow::create("deskctl daemon recovery test", "DeskctlDaemon")?;
|
||||
let session = TestSession::new("daemon-recovery")?;
|
||||
session.create_stale_socket()?;
|
||||
|
||||
session.start_daemon_cli()?;
|
||||
let response = successful_json_response(session.run_cli(["--json", "list-windows"])?)
|
||||
.expect("list-windows should return valid JSON");
|
||||
|
||||
let windows = response
|
||||
.get("data")
|
||||
.and_then(|data| data.get("windows"))
|
||||
.and_then(|value| value.as_array())
|
||||
.expect("CLI JSON response must include windows");
|
||||
assert!(windows.iter().any(|window| {
|
||||
window
|
||||
.get("title")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|title| title == "deskctl daemon recovery test")
|
||||
.unwrap_or(false)
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue