mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-06-29 13:45:23 +03:00
Compare commits
65 Commits
docs/gover
...
docs/compa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9528c25e95 | ||
|
|
d12c491470 | ||
|
|
0a1f8071e9 | ||
|
|
14ccb61596 | ||
|
|
af8a60bdb1 | ||
|
|
8b556f2997 | ||
|
|
865c147916 | ||
|
|
113329f91f | ||
|
|
3d47f33ccb | ||
|
|
a8093a8e11 | ||
|
|
23285d3d5a | ||
|
|
22bd68d5b2 | ||
|
|
3ea677f563 | ||
|
|
4c9a2ac951 | ||
|
|
cd1ad67f9a | ||
|
|
8dd4a3229c | ||
|
|
6a3c8319e0 | ||
|
|
0bbb54d1b4 | ||
|
|
7ffd73add1 | ||
|
|
0daceb1e30 | ||
|
|
b9697c2d1e | ||
|
|
e038768daa | ||
|
|
34e9077dd2 | ||
|
|
88f3bd5941 | ||
|
|
d5e1edc11b | ||
|
|
9e561c0c49 | ||
|
|
aa139be763 | ||
|
|
ee7b35ff86 | ||
|
|
549bf4281a | ||
|
|
ce2e5325d3 | ||
|
|
86385848d0 | ||
|
|
fb5189fe45 | ||
|
|
1f5702bc7b | ||
|
|
11ab62afcd | ||
|
|
bebe83d194 | ||
|
|
61245bcc81 | ||
|
|
2ca35610ec | ||
|
|
4d29cf83fa | ||
|
|
af3c31b511 | ||
|
|
b3baa8ad8f | ||
|
|
ade75d748d | ||
|
|
66d390ccec | ||
|
|
5957c8212b | ||
|
|
cb20fde797 | ||
|
|
c76f7e62da | ||
|
|
5ae25247ef | ||
|
|
e13660993b | ||
|
|
7715952c3f | ||
|
|
2f308c868c | ||
|
|
3ed5dfa84c | ||
|
|
5d7fda521f | ||
|
|
04cd879d11 | ||
|
|
9e72ebb3e0 | ||
|
|
3d3653f51d | ||
|
|
7d4b819a2d | ||
|
|
e92ca9895a | ||
|
|
bf9082067a | ||
|
|
c97d9eb64e | ||
|
|
bfc0c0378e | ||
|
|
d5d7081b35 | ||
|
|
46f6dcdb9d | ||
|
|
f8ba761c2e | ||
|
|
47de8bff7d | ||
|
|
28fc6e29a2 | ||
|
|
ff3dd3c64e |
@@ -658,7 +658,7 @@ Bash scripts in this project are located in:
|
||||
- `.claude/hooks/` - Session lifecycle hooks (build tool installation, linting, PR simplification)
|
||||
|
||||
When writing scripts for this project:
|
||||
- Follow the style guide in `docs/styleguides/bash_styleguide.md` (enforced by shellcheck)
|
||||
- Follow the style guide in `STYLEGUIDE.md` (enforced by shellcheck)
|
||||
- Use existing modular scripts in `scripts/` as patterns for build logic
|
||||
- Reference `build.sh` for architecture detection and package orchestration patterns
|
||||
- Test scripts work on both amd64 and arm64 architectures where applicable
|
||||
|
||||
@@ -6,7 +6,7 @@ model: opus
|
||||
|
||||
You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality. Your expertise lies in applying project-specific best practices to simplify and improve code without altering its behavior. You prioritize readable, explicit code over overly compact solutions.
|
||||
|
||||
**Reference**: Follow the [Bash Style Guide](../../docs/styleguides/bash_styleguide.md)
|
||||
**Reference**: Follow the [Bash Style Guide](../../STYLEGUIDE.md)
|
||||
|
||||
You will analyze recently modified code and apply refinements that:
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ Only launch delegates for domains that have changed files in the PR. All domain
|
||||
|
||||
| Changed Files | Agent | What to Ask |
|
||||
|---|---|---|
|
||||
| Shell scripts in `scripts/` | `cdd-code-simplifier` | Review against `docs/styleguides/bash_styleguide.md` and CLAUDE.md conventions. Report issues with suggested fixes. |
|
||||
| Shell scripts in `scripts/` | `cdd-code-simplifier` | Review against STYLEGUIDE.md and CLAUDE.md conventions. Report issues with suggested fixes. |
|
||||
| JS files in `scripts/` | `electron-linux-specialist` | Review for Electron API correctness, error handling, cross-DE robustness (GNOME, KDE, Xfce, Cinnamon). Note: frame-fix-entry.js is generated by build.sh. |
|
||||
| sed patterns in `build.sh` | `patch-engineer` | Check whitespace tolerance, idempotency guards, dynamic extraction error checks, match specificity, `-E` flag usage. Minified names change between releases — must use regex. |
|
||||
| Packaging scripts (`build-*-package.sh`) | `packaging-specialist` | Check format constraints (RPM version hyphens, AppImage --no-sandbox, deb permissions), cross-format consistency, desktop integration. |
|
||||
@@ -202,12 +202,12 @@ claude-desktop-debian/
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── resources/ # Desktop entries, icons
|
||||
├── CLAUDE.md # Project conventions
|
||||
└── docs/styleguides/bash_styleguide.md # Bash style guide (formerly STYLEGUIDE.md at root)
|
||||
└── STYLEGUIDE.md # Bash style guide
|
||||
# Note: frame-fix-entry.js is generated by build.sh, not a standalone file
|
||||
```
|
||||
|
||||
### Key Conventions
|
||||
- Shell: follows `docs/styleguides/bash_styleguide.md` strictly (tabs, 80-char lines, `[[ ]]`, lowercase vars)
|
||||
- Shell: follows STYLEGUIDE.md strictly (tabs, 80-char lines, `[[ ]]`, lowercase vars)
|
||||
- JS in scripts/: standalone files using Electron APIs (not minified)
|
||||
- JS in build.sh: sed patterns against minified source (must use regex)
|
||||
- Attribution: reviews end with `Written by Claude <model> via [Claude Code](...)`
|
||||
@@ -222,7 +222,7 @@ claude-desktop-debian/
|
||||
|
||||
| File Type | Delegate To | Focus Area |
|
||||
|-----------|------------|------------|
|
||||
| Shell scripts (`scripts/*.sh`) | `cdd-code-simplifier` | `docs/styleguides/bash_styleguide.md` compliance, clarity |
|
||||
| Shell scripts (`scripts/*.sh`) | `cdd-code-simplifier` | STYLEGUIDE.md compliance, clarity |
|
||||
| JS files (`scripts/*.js`) | `electron-linux-specialist` | Electron APIs, cross-DE compatibility |
|
||||
| sed patterns in `build.sh` | `patch-engineer` | Regex robustness, idempotency, extraction |
|
||||
| Packaging scripts (`build-*-package.sh`) | `packaging-specialist` | Format constraints, cross-format consistency |
|
||||
|
||||
@@ -24,7 +24,7 @@ You are a senior Electron and Linux desktop integration specialist with deep exp
|
||||
- **Menu Bar Management**: Hiding/showing menu bars on Linux, `autoHideMenuBar`, `setMenuBarVisibility`, and `Menu.setApplicationMenu` interception.
|
||||
|
||||
**Not in scope** (defer to other agents):
|
||||
- Shell script style and `docs/styleguides/bash_styleguide.md` compliance (defer to `cdd-code-simplifier`)
|
||||
- Shell script style and STYLEGUIDE.md compliance (defer to `cdd-code-simplifier`)
|
||||
- PR review orchestration (defer to `code-reviewer`)
|
||||
- CI/CD workflow YAML and release automation
|
||||
- Debian/RPM package metadata and control files
|
||||
@@ -241,7 +241,7 @@ The `code-reviewer` agent delegates JavaScript file reviews (files in `scripts/`
|
||||
|
||||
This agent provides Electron domain expertise; `cdd-code-simplifier` handles shell style:
|
||||
- This agent specifies WHAT Electron flags/env vars/APIs to use
|
||||
- `cdd-code-simplifier` ensures the shell code implementing them follows `docs/styleguides/bash_styleguide.md`
|
||||
- `cdd-code-simplifier` ensures the shell code implementing them follows STYLEGUIDE.md
|
||||
|
||||
### Providing Guidance on Patches
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ claude-desktop-debian/
|
||||
claude-native-stub.js # Native module replacement
|
||||
.github/workflows/ # CI/CD (defer to ci-workflow-architect)
|
||||
CLAUDE.md # Project conventions
|
||||
docs/styleguides/bash_styleguide.md # Bash style guide
|
||||
STYLEGUIDE.md # Bash style guide
|
||||
```
|
||||
|
||||
### Version String Flow
|
||||
|
||||
@@ -102,7 +102,7 @@ claude-desktop-debian/
|
||||
│ ├── frame-fix-entry.js # Generated entry point (by build.sh)
|
||||
│ └── claude-native-stub.js # Native module replacement
|
||||
├── CLAUDE.md # Project conventions
|
||||
└── docs/styleguides/bash_styleguide.md # Bash style guide
|
||||
└── STYLEGUIDE.md # Bash style guide
|
||||
```
|
||||
|
||||
### Key Files
|
||||
@@ -127,11 +127,11 @@ The electron module variable name changes every release. This extraction finds i
|
||||
|
||||
```bash
|
||||
# Primary: find the variable assigned from require("electron")
|
||||
electron_var=$(grep -oP '\b[$\w]+(?=\s*=\s*require\("electron"\))' "$index_js" | head -1)
|
||||
electron_var=$(grep -oP '\b\w+(?=\s*=\s*require\("electron"\))' "$index_js" | head -1)
|
||||
|
||||
# Fallback: find it from Tray usage if require pattern doesn't match
|
||||
if [[ -z $electron_var ]]; then
|
||||
electron_var=$(grep -oP '(?<=new )[$\w]+(?=\.Tray\b)' "$index_js" | head -1)
|
||||
electron_var=$(grep -oP '(?<=new )\w+(?=\.Tray\b)' "$index_js" | head -1)
|
||||
fi
|
||||
|
||||
# Always validate
|
||||
@@ -149,13 +149,13 @@ Three connected extractions, each depending on the previous:
|
||||
|
||||
```bash
|
||||
# Step 1: Find the tray rebuild function name from event handler
|
||||
tray_func=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' "$index_js")
|
||||
tray_func=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
|
||||
# Step 2: Find the tray variable using the function name as anchor
|
||||
tray_var=$(grep -oP "\}\);let \K[\$\w]+(?==null;(?:async )?function ${tray_func})" "$index_js")
|
||||
tray_var=$(grep -oP "\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" "$index_js")
|
||||
|
||||
# Step 3: Find the first const inside the function for insertion point
|
||||
first_const=$(grep -oP "async function ${tray_func}\(\)\{.*?const \K[\$\w]+(?==)" "$index_js" | head -1)
|
||||
first_const=$(grep -oP "async function ${tray_func}\(\)\{.*?const \K\w+(?==)" "$index_js" | head -1)
|
||||
```
|
||||
|
||||
Each uses a stable string literal as anchor and captures the adjacent minified name.
|
||||
@@ -206,7 +206,7 @@ Note: `e.hide()` uses a minified variable name `e`, but this is safe because it'
|
||||
```bash
|
||||
# Find all variables used with .nativeTheme that aren't the correct electron var
|
||||
mapfile -t wrong_refs < <(
|
||||
grep -oP '\b[$\w]+(?=\.nativeTheme)' "$index_js" \
|
||||
grep -oP '\b\w+(?=\.nativeTheme)' "$index_js" \
|
||||
| sort -u \
|
||||
| grep -v "^${electron_var}$" || true
|
||||
)
|
||||
@@ -288,7 +288,7 @@ When writing a new patch or modifying an existing one:
|
||||
|
||||
## SHELL STYLE NOTES
|
||||
|
||||
Follow the project's [Bash Style Guide](../../docs/styleguides/bash_styleguide.md) for all shell code:
|
||||
Follow the project's [Bash Style Guide](../../STYLEGUIDE.md) for all shell code:
|
||||
|
||||
- Tabs for indentation
|
||||
- Lines under 80 characters (exception: long regex patterns and URLs)
|
||||
|
||||
@@ -14,7 +14,7 @@ You are NOT a code quality reviewer. You do not evaluate:
|
||||
- Performance characteristics
|
||||
- Best practices or design patterns
|
||||
- Test coverage or test quality
|
||||
- Shell script conventions (`docs/styleguides/bash_styleguide.md` compliance)
|
||||
- Shell script conventions (STYLEGUIDE.md compliance)
|
||||
- Minified JS regex pattern quality
|
||||
|
||||
Those concerns belong to the `code-reviewer` agent, which runs in parallel with you.
|
||||
@@ -235,7 +235,7 @@ Written by Claude <model-name> via [Claude Code](https://claude.ai/code)
|
||||
|
||||
Leave these concerns to the `code-reviewer` agent:
|
||||
- Code quality, style, and formatting
|
||||
- Shell script `docs/styleguides/bash_styleguide.md` compliance
|
||||
- Shell script STYLEGUIDE.md compliance
|
||||
- Regex pattern quality in sed commands
|
||||
- Performance implications
|
||||
- Security vulnerabilities
|
||||
|
||||
@@ -759,7 +759,7 @@ IMPORTANT SCOPE CONSTRAINT: This is for issue #$ISSUE_NUMBER. Only simplify code
|
||||
|
||||
If no relevant files were modified as part of this issue's implementation, make no changes and report 'No changes to simplify'.
|
||||
|
||||
Simplify code for clarity and consistency without changing functionality. Follow docs/styleguides/bash_styleguide.md conventions for shell scripts.
|
||||
Simplify code for clarity and consistency without changing functionality. Follow STYLEGUIDE.md conventions for shell scripts.
|
||||
Output a summary of changes made."
|
||||
|
||||
local simplify_result
|
||||
|
||||
@@ -107,7 +107,7 @@ Before writing the agent, gather domain knowledge and project context:
|
||||
Glob: "scripts/*.sh"
|
||||
Glob: ".github/workflows/*.yml"
|
||||
Grep: "function.*\(\)" # in shell scripts
|
||||
Read: "CLAUDE.md", "README.md", "docs/styleguides/bash_styleguide.md"
|
||||
Read: "CLAUDE.md", "README.md", "STYLEGUIDE.md"
|
||||
|
||||
# Find existing agent patterns
|
||||
Glob: ".claude/agents/*.md"
|
||||
|
||||
@@ -2,8 +2,5 @@
|
||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||
skip = .git*,.codespellrc
|
||||
check-hidden = true
|
||||
# ignore-regex =
|
||||
# openIn — substring of `openInEditor` IPC channel name (upstream).
|
||||
# YHe — minified function identifier in build-reference anchor.
|
||||
# hel — three-char literal in QE-13 example ("hel (3) submits").
|
||||
ignore-words-list = openIn,YHe,hel
|
||||
# ignore-regex =
|
||||
# ignore-words-list =
|
||||
|
||||
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@@ -62,12 +62,7 @@
|
||||
# ---- Docs & style ----
|
||||
/README.md @aaddrick
|
||||
/CLAUDE.md @aaddrick
|
||||
/AGENTS.md @aaddrick
|
||||
/CONTRIBUTING.md @aaddrick
|
||||
/CHANGELOG.md @aaddrick
|
||||
/RELEASING.md @aaddrick
|
||||
/SECURITY.md @aaddrick
|
||||
/docs/styleguides/ @aaddrick
|
||||
/STYLEGUIDE.md @aaddrick
|
||||
/docs/ @aaddrick
|
||||
|
||||
# ---- Testing & release quality ----
|
||||
@@ -81,9 +76,9 @@
|
||||
/.github/workflows/tests.yml @sabiut
|
||||
|
||||
# Shared review — either owner can approve.
|
||||
# troubleshooting.md is mostly the --doctor user-facing guide; lint
|
||||
# TROUBLESHOOTING is mostly the --doctor user-facing guide; lint
|
||||
# touches everything, so either maintainer can sign off.
|
||||
/docs/troubleshooting.md @aaddrick @sabiut
|
||||
/docs/TROUBLESHOOTING.md @aaddrick @sabiut
|
||||
/.github/workflows/shellcheck.yml @aaddrick @sabiut
|
||||
|
||||
#===============================================================================
|
||||
|
||||
23
.github/workflows/build-amd64.yml
vendored
23
.github/workflows/build-amd64.yml
vendored
@@ -49,29 +49,6 @@ jobs:
|
||||
fi
|
||||
./build.sh ${{ inputs.build_flags }} $TAG_FLAG
|
||||
|
||||
# Static-grep the shipped asar for the cowork patch markers
|
||||
# defined in scripts/cowork-patch-markers.tsv (issue #559 D6,
|
||||
# PR #555). Pinned to amd64-deb because the patched JS is
|
||||
# identical across formats, so one verification per CI run is
|
||||
# sufficient — no need to duplicate across the matrix.
|
||||
- name: Verify cowork patches in shipped asar
|
||||
if: inputs.artifact_suffix == 'deb'
|
||||
run: |
|
||||
deb_file=$(find . -maxdepth 1 -name 'claude-desktop_*amd64.deb' \
|
||||
-print -quit)
|
||||
if [[ -z "$deb_file" ]]; then
|
||||
echo "verify-patches: no .deb artifact found" >&2
|
||||
exit 1
|
||||
fi
|
||||
extract_dir=$(mktemp -d)
|
||||
dpkg-deb -x "$deb_file" "$extract_dir"
|
||||
asar_path=$(find "$extract_dir" -name app.asar -print -quit)
|
||||
if [[ -z "$asar_path" ]]; then
|
||||
echo "verify-patches: app.asar not found in deb" >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/verify-patches.sh "$asar_path"
|
||||
|
||||
- name: Upload AMD64 Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -674,7 +674,6 @@ jobs:
|
||||
'gpgcheck=1' \
|
||||
'repo_gpgcheck=1' \
|
||||
'gpgkey=https://pkg.claude-desktop-debian.dev/KEY.gpg' \
|
||||
'metadata_expire=1h' \
|
||||
> rpm/claude-desktop.repo
|
||||
|
||||
- name: Re-upload signed RPMs to GitHub Release
|
||||
|
||||
3
.github/workflows/test-artifacts.yml
vendored
3
.github/workflows/test-artifacts.yml
vendored
@@ -44,8 +44,7 @@ jobs:
|
||||
if: matrix.format != 'rpm'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y file libfuse2 nodejs npm \
|
||||
xvfb dbus-x11 procps
|
||||
sudo apt-get install -y file libfuse2 nodejs npm
|
||||
|
||||
- name: Run artifact tests
|
||||
run: |
|
||||
|
||||
6
.github/workflows/test-flags.yml
vendored
6
.github/workflows/test-flags.yml
vendored
@@ -4,12 +4,6 @@ on:
|
||||
workflow_call: # Make this workflow reusable
|
||||
workflow_dispatch: # Allows manual triggering for testing
|
||||
|
||||
concurrency:
|
||||
group: test-flags-${{ github.ref }}
|
||||
# Matches ci.yml: queue rather than cancel, so a reusable invocation
|
||||
# from an in-flight CI run isn't killed mid-flight on the next push.
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test-flags:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -24,13 +24,6 @@ Thumbs.db
|
||||
# Test build output
|
||||
test-build/
|
||||
|
||||
# Playwright stray output — the harness writes to
|
||||
# tools/test-harness/results/ per playwright.config.ts, but Playwright
|
||||
# also drops a default `test-results/.last-run.json` next to the cwd
|
||||
# it's invoked from. Ignore it at the repo root so an accidental run
|
||||
# from here doesn't dirty the tree.
|
||||
test-results/
|
||||
|
||||
# Reference files for source inspection
|
||||
build-reference/
|
||||
|
||||
@@ -40,3 +33,7 @@ result-*
|
||||
|
||||
# Wrangler (Cloudflare Worker dev/deploy cache)
|
||||
worker/.wrangler/
|
||||
|
||||
# UI snapshots — captured renderer state, intentionally ignored to avoid
|
||||
# diff churn. See docs/testing/ui-snapshots/README.md.
|
||||
docs/testing/ui-snapshots/*.json
|
||||
|
||||
493
AGENTS.md
493
AGENTS.md
@@ -1,492 +1,13 @@
|
||||
# AGENTS.md
|
||||
|
||||
<!--
|
||||
This file is read by AI tools that support the agents.md vendor-neutral
|
||||
standard. The content below is duplicated in CLAUDE.md (read by Claude
|
||||
Code) so that contributors using either receive the same instructions
|
||||
without needing to cross-reference. Keep CLAUDE.md and AGENTS.md
|
||||
byte-identical below the H1 title (the sync-policy comment above is the
|
||||
one place they intentionally differ) — if you edit one, edit the other.
|
||||
-->
|
||||
All project instructions, conventions, and development guidelines are maintained in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Required reading
|
||||
Strictly follow the rules defined there.
|
||||
|
||||
These documents are the source of truth. If anything in this file conflicts with them, they win. Read them before opening a non-trivial issue or PR.
|
||||
## Project Tooling
|
||||
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — what we accept, what goes upstream, subsystem owners, AI-attribution policy.
|
||||
- [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md) — shell-script conventions (forked from YSAP). Tabs, 80 cols, `[[ ]]`, no `set -e`, no `eval`.
|
||||
- [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) — page anatomy, naming, antipatterns for the `docs/` tree.
|
||||
- [`docs/index.md`](docs/index.md) — entry point for the rest of the repo docs.
|
||||
- [`SECURITY.md`](SECURITY.md) — vulnerability reporting; what's in scope vs. upstream.
|
||||
Subagent definitions, skills, and orchestration scripts live in [`.claude/`](.claude/):
|
||||
|
||||
This file is a fast reference for the highest-leverage rules and the project's accumulated archaeology. New policy goes in the style guides or CONTRIBUTING.md.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux, applying necessary patches for Linux compatibility.
|
||||
|
||||
## Learnings
|
||||
|
||||
The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical knowledge from debugging and fixing issues — things that aren't obvious from reading the code or docs alone. Consult these before working on related areas. Add new entries when you discover something non-obvious that would save future contributors (human or AI) significant time.
|
||||
|
||||
- [`nix.md`](docs/learnings/nix.md) — NixOS packaging, Electron resource path resolution, testing without NixOS
|
||||
- [`cowork-vm-daemon.md`](docs/learnings/cowork-vm-daemon.md) — Cowork VM daemon lifecycle, respawn logic, crash diagnosis
|
||||
- [`plugin-install.md`](docs/learnings/plugin-install.md) — Anthropic & Partners plugin install flow, gate logic, backend endpoints, and DevTools recipes
|
||||
- [`apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) — APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbook
|
||||
- [`tray-rebuild-race.md`](docs/learnings/tray-rebuild-race.md) — why destroy + recreate on `nativeTheme` updates briefly duplicates the tray icon on KDE Plasma, and the in-place `setImage` + `setContextMenu` fast-path that avoids the SNI re-registration race
|
||||
- [`mcp-double-spawn.md`](docs/learnings/mcp-double-spawn.md) — Stdio MCPs spawn 2× when chat and Code/Agent panels are both active, root cause in upstream session managers, MCP-author workaround
|
||||
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
|
||||
- [`test-harness-electron-hooks.md`](docs/learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps are silently bypassed by `frame-fix-wrapper`'s Proxy, and the prototype-method hook pattern that works (used by the Quick Entry test runners)
|
||||
- [`test-harness-ax-tree-walker.md`](docs/learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker after the AX-tree migration: AX-enable async lag, navigateTo-to-same-URL no-op, claude.ai's flat `dialog>button[]` lists, the `more options for X` per-row shape, and sidebar virtualization vs the lookup-failure threshold
|
||||
- [`patching-minified-js.md`](docs/learnings/patching-minified-js.md) — general lessons from maintaining a long-lived patch suite against an actively re-minified upstream: anchor selection (literals over identifiers), the `\w` vs `$` identifier-capture trap, beautified false-negatives, idempotency guards, multi-site coordination, non-unique anchor disambiguation, and the SHA-256-pinned hypothesis-verification recipe
|
||||
|
||||
## Code Style
|
||||
|
||||
All shell scripts in this project must follow the [Bash Style Guide](docs/styleguides/bash_styleguide.md). Key points:
|
||||
|
||||
- Tabs for indentation, lines under 80 characters (exception: URLs and regex patterns)
|
||||
- Use `[[ ]]` for conditionals, `$(...)` for command substitution
|
||||
- Single quotes for literals, double quotes for expansions
|
||||
- Lowercase variables; UPPERCASE only for constants/exports
|
||||
- Use `local` in functions, avoid `set -e` and `eval`
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Don't `set -e`.** It interacts badly with `$(...)` capture and function return values, and the project has historically debugged enough silent exits to settle the question. Check status explicitly: `cmd || handle_err`.
|
||||
- **Don't `eval`.** Use arrays for argv composition (`cmd "${args[@]}"`). `eval` defeats every parser and is a permanent SC2046 magnet.
|
||||
- **Don't use POSIX `[ ... ]`.** Always `[[ ... ]]`. POSIX `[` mis-parses unquoted expansions in ways `[[` does not.
|
||||
- **Don't backtick.** Always `$(...)`. Backticks don't nest cleanly and conflict with markdown when patches are pasted into PR comments.
|
||||
- **Don't hardcode the work directory.** Scripts that operate during a build use `$work_dir` (set by `build.sh`). A hardcoded path silently breaks the AppImage build, which runs in a different layout from the deb/rpm builds.
|
||||
- **Don't wrap commands in `if cmd; then true; else false; fi`-style scaffolding.** Just `cmd` — the exit code is already there.
|
||||
- **Don't append to a baseline file to silence `shellcheck`.** Fix the underlying issue. If a warning is genuinely a false positive, use a per-line `# shellcheck disable=SCXXXX` with a comment explaining why.
|
||||
|
||||
### Linting
|
||||
|
||||
Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `actionlint` before pushing. When lint issues are found:
|
||||
|
||||
1. **Fix the code** - Correct the underlying issue rather than suppressing the warning
|
||||
2. **Disable directives are a last resort** - Only use `# shellcheck disable=SCXXXX` when:
|
||||
- The warning is a false positive
|
||||
- The pattern is intentional and unavoidable
|
||||
- Always add a comment explaining why the disable is needed
|
||||
3. **Run `/lint` to check manually** - Use this skill to check for issues before pushing
|
||||
|
||||
## Docs
|
||||
|
||||
- **One declarative sentence then a code block or list at the top of every page.** No "In this guide we will explore…" preamble. See [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md).
|
||||
- **Lowercase kebab-case filenames** for everything in `docs/`. Order belongs in [`docs/index.md`](docs/index.md), not filenames or numeric prefixes.
|
||||
- **Real domain nouns over `foo`/`bar`** in walkthroughs. The project vocabulary is `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`, `the doctor surface`.
|
||||
- **Subsystem deep-dives go under [`docs/learnings/`](docs/learnings/).** Surfacing knowledge there beats burying it in commit messages or in patch-script comments. Add an entry when you discover something non-obvious that would save the next contributor significant time.
|
||||
- **Decisions go in [`docs/decisions.md`](docs/decisions.md) (ADR format).** Don't relitigate a settled direction inside a how-to page; link the decision instead.
|
||||
- **Troubleshooting headings are the literal symptom**, not editorialized prose. `## Black screen on Fedora KDE under Wayland`, not `## Troubles with Wayland`. Search ranks headings.
|
||||
- **CHANGELOG follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).** Bullets grouped under Added / Fixed / Changed / Deprecated / Removed / Security; one bullet per change; PR link for the deep dive; inline **BREAKING** prefix for breaking changes. See [`CHANGELOG.md`](CHANGELOG.md) for the current state and [`RELEASING.md`](RELEASING.md) for when entries get promoted from `[Unreleased]`.
|
||||
|
||||
## GitHub Workflow
|
||||
|
||||
### General Approach
|
||||
|
||||
- Use `gh` CLI for all GitHub interactions
|
||||
- Create branches based on issue numbers: `fix/123-description` or `feature/123-description`
|
||||
- Reference issues in commits and PRs with `#123` or `Fixes #123`
|
||||
- After creating a PR, add a comment to the related issue with a summary and link to the PR
|
||||
|
||||
### Investigating Issues
|
||||
|
||||
For older issues, review the state of the code when the issue was raised - it may have already been addressed:
|
||||
|
||||
```bash
|
||||
# Get issue creation date
|
||||
gh issue view 123 --json createdAt
|
||||
|
||||
# Find the commit just before the issue was created
|
||||
git log --oneline --until="2025-08-23T08:48:35Z" -1
|
||||
|
||||
# View a file at that point in time
|
||||
git show <commit>:path/to/file.sh
|
||||
|
||||
# Search for relevant changes since the issue was created
|
||||
git log --oneline --after="2025-08-23" -- path/to/file.sh
|
||||
|
||||
# View a specific commit that may have fixed the issue
|
||||
git show <commit>
|
||||
```
|
||||
|
||||
This helps identify if the issue was already fixed, and allows referencing the specific commit in the response.
|
||||
|
||||
### Attribution
|
||||
|
||||
**For PR descriptions**, include full attribution:
|
||||
|
||||
```
|
||||
---
|
||||
Generated with [Claude Code](https://claude.ai/code)
|
||||
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
|
||||
<XX>% AI / <YY>% Human
|
||||
Claude: <what AI did>
|
||||
Human: <what human did>
|
||||
```
|
||||
|
||||
- Use the actual model name (e.g., `Claude Opus 4.5`, `Claude Sonnet 4`)
|
||||
- The percentage split should honestly reflect the contribution balance for that specific work
|
||||
- This provides a trackable record of AI-assisted development over time
|
||||
|
||||
**For issues and comments**, use simplified attribution:
|
||||
|
||||
```
|
||||
---
|
||||
Written by Claude <model-name> via [Claude Code](https://claude.ai/code)
|
||||
```
|
||||
|
||||
**For commits**, include a Co-Authored-By trailer:
|
||||
|
||||
```
|
||||
Co-Authored-By: Claude <claude@anthropic.com>
|
||||
```
|
||||
|
||||
### Contributor Credits
|
||||
|
||||
The README Acknowledgments section credits external contributors in chronological order (by merge date or fix date). Update it when:
|
||||
|
||||
1. **Merging an external PR** — Add the author to the Acknowledgments list with a link to their GitHub profile and a brief description of their contribution.
|
||||
2. **Implementing a fix suggested in an issue** — If an issue author (or commenter) provided a concrete fix, workaround, code snippet, or detailed technical analysis that was directly used, credit them too.
|
||||
|
||||
Contributors are listed in chronological order: inspirational projects first (k3d3, emsi, leobuskin), then contributors ordered by when their contribution was merged or implemented.
|
||||
|
||||
## Working with Minified JavaScript
|
||||
|
||||
### Important Guidelines
|
||||
|
||||
1. **Always use regex patterns** when modifying the source JavaScript. Patches live in `scripts/patches/*.sh` (one file per subsystem: `tray.sh`, `cowork.sh`, `claude-code.sh`, etc.); `build.sh` is only an orchestrator that sources them. Variable and function names are minified and **change between releases**.
|
||||
|
||||
2. **The beautified code in `build-reference/` has different spacing** than the actual minified code in the app. Patterns must handle both:
|
||||
- Minified: `oe.nativeTheme.on("updated",()=>{`
|
||||
- Beautified: `oe.nativeTheme.on("updated", () => {`
|
||||
|
||||
3. **Use `-E` flag with sed** for extended regex support when patterns need grouping or alternation.
|
||||
|
||||
4. **Extract variable names dynamically** rather than hardcoding them. Shared extraction helpers live in `scripts/patches/_common.sh`. Example:
|
||||
```bash
|
||||
# Extract function name from a known pattern
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
```
|
||||
|
||||
5. **Handle optional whitespace** in regex patterns:
|
||||
```bash
|
||||
# Bad: assumes no spaces
|
||||
sed -i 's/oe.nativeTheme.on("updated",()=>{/...'
|
||||
|
||||
# Good: handles optional whitespace
|
||||
sed -i -E 's/(oe\.nativeTheme\.on\(\s*"updated"\s*,\s*\(\)\s*=>\s*\{)/...'
|
||||
```
|
||||
|
||||
### Reference Files
|
||||
|
||||
- `build-reference/app-extracted/` - Extracted and beautified source for analysis
|
||||
- `build-reference/tray-icons/` - Tray icon assets for reference
|
||||
|
||||
## Frame Fix Wrapper
|
||||
|
||||
The app uses a wrapper system to intercept and fix Electron behavior for Linux:
|
||||
|
||||
- **`frame-fix-wrapper.js`** - Intercepts `require('electron')` to patch BrowserWindow defaults (e.g., `frame: true` for proper window decorations on Linux)
|
||||
- **`frame-fix-entry.js`** - Entry point that loads the wrapper before the main app
|
||||
|
||||
These are injected by `scripts/patches/app-asar.sh` (inside `patch_app_asar`) and referenced in `package.json`'s `main` field. The wrapper pattern allows fixing Electron behavior without modifying the minified app code directly.
|
||||
|
||||
## Setting Up build-reference
|
||||
|
||||
If `build-reference/` is missing or you need to inspect source for a new version, follow these steps to download, extract, and beautify the source code.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Install required tools
|
||||
sudo apt install p7zip-full wget nodejs npm
|
||||
|
||||
# Install asar and prettier globally (or use npx)
|
||||
npm install -g @electron/asar prettier
|
||||
```
|
||||
|
||||
### Step 1: Download the Windows Installer
|
||||
|
||||
The Windows installer contains the app.asar which has the full Electron app source.
|
||||
|
||||
```bash
|
||||
# Create working directory
|
||||
mkdir -p build-reference && cd build-reference
|
||||
|
||||
# Download URL pattern (update version as needed):
|
||||
# x64: https://downloads.claude.ai/releases/win32/x64/VERSION/Claude-COMMIT.exe
|
||||
# arm64: https://downloads.claude.ai/releases/win32/arm64/VERSION/Claude-COMMIT.exe
|
||||
|
||||
# Example for version 1.1.381:
|
||||
wget -O Claude-Setup-x64.exe "https://downloads.claude.ai/releases/win32/x64/1.1.381/Claude-c2a39e9c82f5a4d51f511f53f532afd276312731.exe"
|
||||
```
|
||||
|
||||
### Step 2: Extract the Installer
|
||||
|
||||
```bash
|
||||
# Extract the exe (it's a 7z archive)
|
||||
7z x -y Claude-Setup-x64.exe -o"exe-contents"
|
||||
|
||||
# Find and extract the nupkg
|
||||
cd exe-contents
|
||||
NUPKG=$(find . -name "AnthropicClaude-*.nupkg" | head -1)
|
||||
7z x -y "$NUPKG" -o"nupkg-contents"
|
||||
cd ..
|
||||
|
||||
# Copy out the important files
|
||||
cp exe-contents/nupkg-contents/lib/net45/resources/app.asar .
|
||||
cp -a exe-contents/nupkg-contents/lib/net45/resources/app.asar.unpacked .
|
||||
|
||||
# Optional: copy tray icons for reference
|
||||
mkdir -p tray-icons
|
||||
cp exe-contents/nupkg-contents/lib/net45/resources/*.png tray-icons/ 2>/dev/null || true
|
||||
cp exe-contents/nupkg-contents/lib/net45/resources/*.ico tray-icons/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
### Step 3: Extract app.asar
|
||||
|
||||
```bash
|
||||
# Extract the asar archive
|
||||
asar extract app.asar app-extracted
|
||||
```
|
||||
|
||||
### Step 4: Beautify the JavaScript Files
|
||||
|
||||
The extracted JS files are minified. Use prettier to make them readable:
|
||||
|
||||
```bash
|
||||
# Beautify all JS files in the build directory
|
||||
npx prettier --write "app-extracted/.vite/build/*.js"
|
||||
|
||||
# Or beautify specific files
|
||||
npx prettier --write app-extracted/.vite/build/index.js
|
||||
npx prettier --write app-extracted/.vite/build/mainWindow.js
|
||||
```
|
||||
|
||||
### Step 5: Clean Up (Optional)
|
||||
|
||||
```bash
|
||||
# Remove intermediate files, keep only what's needed for reference
|
||||
rm -rf exe-contents
|
||||
rm Claude-Setup-x64.exe
|
||||
rm -rf app.asar app.asar.unpacked # Keep only app-extracted
|
||||
```
|
||||
|
||||
### Final Structure
|
||||
|
||||
```
|
||||
build-reference/
|
||||
├── app-extracted/
|
||||
│ ├── .vite/
|
||||
│ │ ├── build/
|
||||
│ │ │ ├── index.js # Main process (beautified)
|
||||
│ │ │ ├── mainWindow.js # Main window preload
|
||||
│ │ │ ├── mainView.js # Main view preload
|
||||
│ │ │ └── ...
|
||||
│ │ └── renderer/
|
||||
│ │ └── ...
|
||||
│ ├── node_modules/
|
||||
│ │ └── @ant/claude-native/ # Native bindings (stubs)
|
||||
│ └── package.json
|
||||
├── tray-icons/
|
||||
│ ├── TrayIconTemplate.png # Black icon (for light panels)
|
||||
│ ├── TrayIconTemplate-Dark.png # White icon (for dark panels)
|
||||
│ └── ...
|
||||
└── nupkg-contents/ # Optional: full extracted nupkg
|
||||
```
|
||||
|
||||
## Adding New Package Formats or Repositories
|
||||
|
||||
When adding support for new distribution formats (e.g., RPM, Flatpak, Snap) or package repositories, follow these guidelines to avoid iterative debugging in CI.
|
||||
|
||||
### Research Before Implementing
|
||||
|
||||
1. **Understand the target system's constraints** - Each package format has specific rules:
|
||||
- Version string formats (e.g., RPM cannot have hyphens in Version field)
|
||||
- Required metadata fields
|
||||
- Signing requirements and tools
|
||||
|
||||
2. **Search for existing CI implementations** - Look for "GitHub Actions [format] signing" or similar. Existing workflows reveal required flags, environment setup, and common pitfalls.
|
||||
|
||||
3. **Check tool behavior in non-interactive environments** - CI has no TTY. Tools like GPG need flags like `--batch` and `--yes` to work without prompts.
|
||||
|
||||
### Consider Concurrency
|
||||
|
||||
1. **Multiple jobs writing to the same branch will race** - If APT and DNF repos both push to `gh-pages`, add:
|
||||
- Job dependencies (`needs: [other-job]`), or
|
||||
- Retry loops with `git pull --rebase` before push
|
||||
|
||||
2. **External processes may also modify branches** - GitHub Pages deployment runs automatically and can cause push conflicts.
|
||||
|
||||
### Test the Full Pipeline
|
||||
|
||||
1. **Test CI steps locally first** - Run the signing/packaging commands manually to catch errors before committing.
|
||||
|
||||
2. **Use a test tag for new infrastructure** - Create a non-release tag to validate the full CI pipeline before merging to main.
|
||||
|
||||
3. **Verify the end-user experience** - After CI succeeds, actually test the install commands from the README on a clean system.
|
||||
|
||||
### Common CI Pitfalls
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| GPG "cannot open /dev/tty" | Add `--batch` flag |
|
||||
| GPG "File exists" error | Add `--yes` flag to overwrite |
|
||||
| Push rejected (ref changed) | Add `git pull --rebase` before push, with retry loop |
|
||||
| Version format invalid | Research target format's version constraints upfront |
|
||||
| Signing key not found | Ensure key is imported before signing step, check key ID output |
|
||||
|
||||
## CI/CD
|
||||
|
||||
### Triggering Builds
|
||||
|
||||
```bash
|
||||
# Trigger CI on a branch
|
||||
gh workflow run CI --ref branch-name
|
||||
|
||||
# Watch the run
|
||||
gh run watch RUN_ID
|
||||
|
||||
# Download artifacts
|
||||
gh run download RUN_ID -n artifact-name
|
||||
```
|
||||
|
||||
### Build Artifacts
|
||||
|
||||
- `claude-desktop-VERSION-amd64.deb` - Debian package for x86_64
|
||||
- `claude-desktop-VERSION-amd64.AppImage` - AppImage for x86_64
|
||||
- `claude-desktop-VERSION-arm64.deb` - Debian package for ARM64
|
||||
- `claude-desktop-VERSION-arm64.AppImage` - AppImage for ARM64
|
||||
- `result/` - Nix build output (symlink, gitignored)
|
||||
|
||||
## Distribution
|
||||
|
||||
APT and DNF binaries are fronted by a Cloudflare Worker at `pkg.claude-desktop-debian.dev`. Metadata (`InRelease`, `Packages`, `KEY.gpg`, `repodata/*`) passes through to the `gh-pages` branch; binary requests (`/pool/.../*.deb`, `/rpm/*/*.rpm`) get 302'd to the corresponding GitHub Release asset. This keeps `.deb` / `.rpm` files out of `gh-pages` entirely, so they never hit GitHub's 100 MB per-file push cap.
|
||||
|
||||
Key files:
|
||||
- `worker/src/worker.js` — Worker source
|
||||
- `worker/wrangler.toml` — Worker config (route, `custom_domain = true`)
|
||||
- `.github/workflows/deploy-worker.yml` — deploys on push to `main` when `worker/**` changes
|
||||
- `.github/workflows/apt-repo-heartbeat.yml` — daily chain validation, auto-opens tracking issue on failure
|
||||
- `update-apt-repo` and `update-dnf-repo` jobs in `.github/workflows/ci.yml` — gate a strip step on Worker liveness, so binaries are removed from the local pool tree before push
|
||||
|
||||
Repo secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`. Token scoped to the "Edit Cloudflare Workers" template.
|
||||
|
||||
Full details including the redirect chain, the http-scheme-downgrade gotcha, credential ownership, and heartbeat failure runbook: [`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md).
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Build
|
||||
|
||||
```bash
|
||||
./build.sh --build appimage --clean no
|
||||
```
|
||||
|
||||
### Nix Build
|
||||
|
||||
```bash
|
||||
nix build .#claude-desktop
|
||||
nix build .#claude-desktop-fhs
|
||||
```
|
||||
|
||||
### Testing AppImage
|
||||
|
||||
```bash
|
||||
# Run with logging
|
||||
./test-build/claude-desktop-*.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
## Debugging Workflow
|
||||
|
||||
### Inspecting the Running App's Code
|
||||
|
||||
```bash
|
||||
# Find the mounted AppImage path
|
||||
mount | grep claude
|
||||
# Example: /tmp/.mount_claudeXXXXXX
|
||||
|
||||
# Extract the running app's asar for inspection
|
||||
npx asar extract /tmp/.mount_claudeXXXXXX/usr/lib/node_modules/electron/dist/resources/app.asar /tmp/claude-inspect
|
||||
|
||||
# Search for patterns in the extracted code
|
||||
grep -n "pattern" /tmp/claude-inspect/.vite/build/index.js
|
||||
```
|
||||
|
||||
### Checking DBus/Tray Status
|
||||
|
||||
```bash
|
||||
# List registered tray icons
|
||||
gdbus call --session --dest=org.kde.StatusNotifierWatcher \
|
||||
--object-path=/StatusNotifierWatcher \
|
||||
--method=org.freedesktop.DBus.Properties.Get \
|
||||
org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems
|
||||
|
||||
# Find which process owns a DBus connection
|
||||
gdbus call --session --dest=org.freedesktop.DBus \
|
||||
--object-path=/org/freedesktop/DBus \
|
||||
--method=org.freedesktop.DBus.GetConnectionUnixProcessID ":1.XXXX"
|
||||
```
|
||||
|
||||
### Log Locations
|
||||
|
||||
- Launcher log: `~/.cache/claude-desktop-debian/launcher.log`
|
||||
- App logs: `~/.config/Claude/logs/`
|
||||
- Run with logging: `./app.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log`
|
||||
|
||||
## Useful Locations
|
||||
|
||||
- App data: `~/.config/Claude/`
|
||||
- Logs: `~/.config/Claude/logs/`
|
||||
- SingletonLock: `~/.config/Claude/SingletonLock`
|
||||
- Launcher log: `~/.cache/claude-desktop-debian/launcher.log`
|
||||
|
||||
## Versioning
|
||||
|
||||
Release versions are managed via two GitHub Actions repository variables (not files):
|
||||
|
||||
- **`REPO_VERSION`** - The project's own version (e.g., `1.3.23`). Bump this manually via `gh variable set REPO_VERSION --body "X.Y.Z"` when shipping project changes.
|
||||
- **`CLAUDE_DESKTOP_VERSION`** - The upstream Claude Desktop version (e.g., `1.1.8629`). Updated automatically by the `check-claude-version` workflow when a new upstream release is detected.
|
||||
|
||||
### Tag format
|
||||
|
||||
Tags follow the pattern `v{REPO_VERSION}+claude{CLAUDE_DESKTOP_VERSION}`, e.g., `v1.3.23+claude1.1.7714`. Pushing a tag triggers the CI release build.
|
||||
|
||||
```bash
|
||||
# Check current values
|
||||
gh variable get REPO_VERSION
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
|
||||
# Bump repo version and tag a release
|
||||
gh variable set REPO_VERSION --body "1.3.24"
|
||||
git tag "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
git push origin "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
```
|
||||
|
||||
When upstream Claude Desktop updates, the `check-claude-version` workflow automatically updates `CLAUDE_DESKTOP_VERSION`, patches the URLs in `scripts/setup/detect-host.sh`, and creates a new tag — no manual intervention needed.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **`.zsync` files** - Used for delta updates, can be ignored/deleted
|
||||
- **AppImage mount points** - Running AppImages mount to `/tmp/.mount_claude*`; check with `mount | grep claude`
|
||||
- **Killing the app** - Must kill all electron child processes, not just the main one:
|
||||
```bash
|
||||
pkill -9 -f "mount_claude"
|
||||
```
|
||||
- **SingletonLock** - If app won't start, check for stale lock: `~/.config/Claude/SingletonLock`
|
||||
- **Node version** - Build requires Node.js; the script downloads its own if needed
|
||||
- **Nix hashes** - When Claude Desktop version changes, both the URLs in `scripts/setup/detect-host.sh` and `nix/claude-desktop.nix` (version, URLs, SRI hashes) must be updated. The CI handles this automatically.
|
||||
- **Claude Desktop version** - A GitHub Action automatically updates the `CLAUDE_DESKTOP_VERSION` repo variable and the URLs in `scripts/setup/detect-host.sh` on main when a new version is detected. Before committing `scripts/setup/detect-host.sh`, ensure your branch has the latest URLs:
|
||||
```bash
|
||||
# Check repo variable (source of truth)
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
|
||||
# Check current version in the detect_architecture case statement
|
||||
grep -oP 'x64/\K[0-9]+\.[0-9]+\.[0-9]+' scripts/setup/detect-host.sh | head -1
|
||||
|
||||
# If outdated, pull URLs from main branch
|
||||
gh api repos/aaddrick/claude-desktop-debian/contents/scripts/setup/detect-host.sh?ref=main \
|
||||
--jq '.content' | base64 -d | grep -E "claude_download_url="
|
||||
```
|
||||
Update both amd64 and arm64 URLs in `detect_architecture()` to match main
|
||||
- `.claude/agents/` - Specialized subagent definitions for the Task tool
|
||||
- `.claude/skills/` - User-invocable skills (slash commands)
|
||||
- `.claude/scripts/` - Orchestration scripts that chain multiple Claude CLI calls
|
||||
|
||||
240
CHANGELOG.md
240
CHANGELOG.md
@@ -1,240 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `aaddrick/claude-desktop-debian` are documented in this file.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — semantic versioning applies to `REPO_VERSION`; upstream Claude Desktop bumps (the `+claude{X.Y.Z}` suffix on the tag) are tracked separately by the `check-claude-version` workflow.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Tracks upstream Claude Desktop 1.8555.2.
|
||||
<!-- Updated automatically by check-claude-version; will be current at release time. -->
|
||||
|
||||
### Added
|
||||
|
||||
- `--doctor` flags filesystems with `NAME_MAX < 200` (eCryptfs, certain encrypted overlays) and surfaces the LUKS-symlink workaround for cowork. Thanks @RayCharlizard, @lizthegrey for the repro. ([#614](https://github.com/aaddrick/claude-desktop-debian/pull/614), fixes [#590](https://github.com/aaddrick/claude-desktop-debian/issues/590))
|
||||
- Top-level governance docs: this `CHANGELOG.md`, [`RELEASING.md`](RELEASING.md) (pre-release checklist + tag-driven CI flow), [`SECURITY.md`](SECURITY.md) (private GHSA reporting + in/out-of-scope), [`docs/index.md`](docs/index.md) (navigation hub), and [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) (page anatomy, naming, antipatterns). [`CLAUDE.md`](CLAUDE.md) gains explicit § Required reading, § Anti-patterns, and § Docs sections; [`AGENTS.md`](AGENTS.md) becomes a byte-identical mirror of the new body (was a 13-line stub) so non-Claude tools get the same instructions.
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) "Before you start" triage section: where to go for a bug, a fix-in-hand, a new-feature ask, or a security report.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tray: extracted JS identifier captures now accept `$` so the 1.8089.1 minified bundle ('`i$A`' menu handler) matches. Switches `\w+` to `[\w$]+`. ([#627](https://github.com/aaddrick/claude-desktop-debian/pull/627), fixes [#625](https://github.com/aaddrick/claude-desktop-debian/issues/625))
|
||||
|
||||
### Changed
|
||||
|
||||
- Credit @lizthegrey, @sabiut, @typedrat, @RayCharlizard in README Acknowledgments. ([#626](https://github.com/aaddrick/claude-desktop-debian/pull/626))
|
||||
- Troubleshooting: new "Repeated Electron Crashes / GPU Process FATAL" section documenting `CLAUDE_DISABLE_GPU=1`. Adds tuning-rationale comments around the `--doctor` 3-in-7-days threshold and the `coredumpctl` `COMM=electron` assumption. Thanks @sabiut. ([#615](https://github.com/aaddrick/claude-desktop-debian/pull/615), addresses [#608](https://github.com/aaddrick/claude-desktop-debian/issues/608))
|
||||
- Docs filenames are now lowercase kebab-case (`docs/building.md`, `docs/configuration.md`, `docs/decisions.md`, `docs/troubleshooting.md`); `STYLEGUIDE.md` moved to [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md). Cross-references swept across README, CONTRIBUTING, CODEOWNERS, `.github/`, `.claude/`, `scripts/`, and `claude-desktop --doctor` user-facing output.
|
||||
- `[$\w]+` is the codified identifier-capture convention for patch-script regexes (CONTRIBUTING § Patch-script regexes; `patch-engineer` agent examples updated to match). Closes a docs-vs-code gap that left the rule only in [`docs/learnings/patching-minified-js.md`](docs/learnings/patching-minified-js.md) — the same `\w+` trap fixed in patches by [#555](https://github.com/aaddrick/claude-desktop-debian/pull/555) and [#627](https://github.com/aaddrick/claude-desktop-debian/pull/627).
|
||||
|
||||
## [v2.0.12] — 2026-05-19
|
||||
|
||||
Tracks upstream Claude Desktop 1.7196.3.
|
||||
|
||||
### Added
|
||||
|
||||
- Headless launch + `--doctor` smoke tests for the AppImage artifact. ([#592](https://github.com/aaddrick/claude-desktop-debian/pull/592))
|
||||
|
||||
### Changed
|
||||
|
||||
- CI: add concurrency group to `test-flags` workflow. ([#606](https://github.com/aaddrick/claude-desktop-debian/pull/606))
|
||||
|
||||
## [v2.0.11] — 2026-05-16
|
||||
|
||||
Tracks upstream Claude Desktop 1.7196.1.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Catch About window after upstream `titleBarStyle` change; guard Hardware Buddy. ([#481](https://github.com/aaddrick/claude-desktop-debian/pull/481), [#489](https://github.com/aaddrick/claude-desktop-debian/pull/489))
|
||||
- RPM `chrome-sandbox` SUID now set via `%attr` instead of `%post chmod`. ([#539](https://github.com/aaddrick/claude-desktop-debian/pull/539), [#595](https://github.com/aaddrick/claude-desktop-debian/pull/595))
|
||||
- No-op `autoUpdater` on Linux to defend against feed activation; mask thenable/coercion traps on the Proxy. ([#567](https://github.com/aaddrick/claude-desktop-debian/pull/567), [#596](https://github.com/aaddrick/claude-desktop-debian/pull/596))
|
||||
- `node-pty` install fails loudly on `npm install` failure; require `gcc`/`make`/`python3`. ([#401](https://github.com/aaddrick/claude-desktop-debian/pull/401), [#598](https://github.com/aaddrick/claude-desktop-debian/pull/598))
|
||||
- Fetch electron binary via `@electron/get`, drop `^41` pin; resolve from `work_dir` not script dir. ([#587](https://github.com/aaddrick/claude-desktop-debian/pull/587))
|
||||
- Dedupe packages mapped from multiple commands.
|
||||
|
||||
## [v2.0.10] — 2026-05-06
|
||||
|
||||
Tracks upstream Claude Desktop 1.6259.0, 1.6259.1, 1.6608.0, 1.6608.2, 1.7196.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `--doctor` surfaces recent Electron crashes with a `#583` pointer; `CLAUDE_DISABLE_GPU=1` opt-in for GPU-process fatal crashes. ([#583](https://github.com/aaddrick/claude-desktop-debian/pull/583), [#585](https://github.com/aaddrick/claude-desktop-debian/pull/585))
|
||||
- `--doctor` detects IBus/GTK misconfigurations that break input. ([#572](https://github.com/aaddrick/claude-desktop-debian/pull/572))
|
||||
- Launcher: `CLAUDE_GTK_IM_MODULE` opt-in override. ([#571](https://github.com/aaddrick/claude-desktop-debian/pull/571))
|
||||
- Launcher: log session/IME env block at startup. ([#570](https://github.com/aaddrick/claude-desktop-debian/pull/570))
|
||||
- Linux compatibility test harness. ([#579](https://github.com/aaddrick/claude-desktop-debian/pull/579))
|
||||
- Lifecycle: notify and offer restart on in-place package upgrade. ([#564](https://github.com/aaddrick/claude-desktop-debian/pull/564))
|
||||
- `desktopName` set for Wayland window grouping. Thanks @jslatten. ([#562](https://github.com/aaddrick/claude-desktop-debian/pull/562))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pin electron to `^41` to restore postinstall binary fetch. ([#584](https://github.com/aaddrick/claude-desktop-debian/pull/584), [#586](https://github.com/aaddrick/claude-desktop-debian/pull/586))
|
||||
- Nix: make electron binary executable. ([#581](https://github.com/aaddrick/claude-desktop-debian/pull/581))
|
||||
- `cowork.sh`: emit WARNING on Patch 2a/2b inner anchor miss. ([#576](https://github.com/aaddrick/claude-desktop-debian/pull/576))
|
||||
- CI: force primary GPG key for `repomd.xml` signing. Thanks @ProfFlow. ([#566](https://github.com/aaddrick/claude-desktop-debian/pull/566))
|
||||
- DNF: set `metadata_expire=1h` on generated `.repo`. ([#551](https://github.com/aaddrick/claude-desktop-debian/pull/551))
|
||||
- BATS: isolate `cleanup_stale_cowork_socket` from host `pgrep` state. ([#534](https://github.com/aaddrick/claude-desktop-debian/pull/534))
|
||||
|
||||
### Changed
|
||||
|
||||
- Static-grep shipped asar for PR #555 markers as a verification step. ([#559](https://github.com/aaddrick/claude-desktop-debian/pull/559), [#575](https://github.com/aaddrick/claude-desktop-debian/pull/575))
|
||||
- New `patching-minified-js` learnings doc + `CONTRIBUTING`. ([#574](https://github.com/aaddrick/claude-desktop-debian/pull/574))
|
||||
- Refine `mcp-double-spawn` root cause and routing in learnings. ([#546](https://github.com/aaddrick/claude-desktop-debian/pull/546), [#547](https://github.com/aaddrick/claude-desktop-debian/pull/547))
|
||||
- Archive upstream report draft for #546 (filed as `anthropics/claude-code#55353`). ([#552](https://github.com/aaddrick/claude-desktop-debian/pull/552))
|
||||
|
||||
## [v2.0.8] — 2026-05-02
|
||||
|
||||
Tracks upstream Claude Desktop 1.5354.0 (unchanged from v2.0.7).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cowork starts again on Claude Desktop 1.5354.0. Upstream's minifier started emitting `$`-containing identifiers (`C$i`, `g$i`); two regex anchors in `scripts/patches/cowork.sh` used `\w+`, which doesn't match `$`. Patch 2b silently no-op'd, the Swift VM module assignment never landed, and you'd hit `Swift VM addon not available` at session init. Widens both anchors to `[\w$]+`. Patch 6 also moves from `indexOf` to `lastIndexOf` on the retry-delay anchor. Thanks @sirfaber, @HumboldtJoker, @zabka. ([#555](https://github.com/aaddrick/claude-desktop-debian/pull/555), fixes [#558](https://github.com/aaddrick/claude-desktop-debian/issues/558), likely fixes [#553](https://github.com/aaddrick/claude-desktop-debian/issues/553) and [#445](https://github.com/aaddrick/claude-desktop-debian/issues/445))
|
||||
|
||||
## [v2.0.7] — 2026-05-01
|
||||
|
||||
Tracks upstream Claude Desktop 1.5354.0 (unchanged from v2.0.6).
|
||||
|
||||
### Added
|
||||
|
||||
- Linux in-app topbar works now. New `hybrid` titlebar mode is the default: native OS frame plus a BrowserView preload shim that satisfies claude.ai's UA gate, so the hamburger, sidebar, search, and nav buttons render and are clickable. Layout is stacked (DE titlebar above the in-app topbar) rather than combined like Windows. Set `CLAUDE_TITLEBAR_STYLE=native` to opt out and hide the in-app topbar. The upstream `frame:false` + WCO config is preserved as `hidden` for investigation but still has unclickable buttons on Linux; `--doctor` warns when it's active. Verified on KDE Plasma X11/Wayland and Hyprland; GNOME, Sway, Niri, and NixOS pending. ([#538](https://github.com/aaddrick/claude-desktop-debian/pull/538))
|
||||
|
||||
## [v2.0.6] — 2026-05-01
|
||||
|
||||
Tracks upstream Claude Desktop 1.5354.0. Absorbs three upstream bumps from v2.0.5: 1.4758.0, 1.5220.0, 1.5354.0.
|
||||
|
||||
### Added
|
||||
|
||||
- Cowork bwrap mounts accept a `{src, dst}` form, so you can map a host directory under `$HOME` onto a different path inside the sandbox. Unlocks persistent-`/tmp` so Bash tool calls don't wipe state between invocations. String form unchanged. Thanks @cbonnissent. ([#531](https://github.com/aaddrick/claude-desktop-debian/pull/531))
|
||||
- `--doctor` warns when `COWORK_VM_BACKEND` is set to an unknown value instead of silently falling through to auto-detect; adds a `COWORK_VM_BACKEND` row and a Cowork Backend section to `docs/configuration.md`. Thanks @CyPack. ([#324](https://github.com/aaddrick/claude-desktop-debian/issues/324))
|
||||
- `--doctor` warns when an additional bwrap mount destination shadows a default sandbox path like `/usr`, `/etc`, `/bin`, `/sbin`, `/lib`. ([#531](https://github.com/aaddrick/claude-desktop-debian/pull/531))
|
||||
- Troubleshooting entries for Cowork VM connection timeout, virtiofsd outside `$PATH` on Fedora/RHEL (`/usr/libexec/virtiofsd`), and Fedora tmpfs `EXDEV` errors. ([#324](https://github.com/aaddrick/claude-desktop-debian/issues/324))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Closing the window no longer kills the app on Linux. The X button hides to tray, matching Windows and macOS. Quit explicitly with Ctrl+Q, the tray menu, or your DE's quit shortcut. Set `CLAUDE_QUIT_ON_CLOSE=1` to restore the old behavior. Fixes scheduled tasks and `/schedule` firings getting silently dropped overnight. Thanks @lizthegrey. ([#451](https://github.com/aaddrick/claude-desktop-debian/pull/451))
|
||||
- "Run on startup" toggle persists on Linux now. Electron's `setLoginItemSettings` isn't implemented on Linux; the wrapper backs the toggle with `~/.config/autostart/claude-desktop.desktop` per the XDG Autostart spec. Thanks @lizthegrey. ([#450](https://github.com/aaddrick/claude-desktop-debian/pull/450), fixes [#128](https://github.com/aaddrick/claude-desktop-debian/issues/128))
|
||||
- Tray icon updates in place on OS theme change instead of briefly duplicating on KDE Plasma. Uses `setImage` + `setContextMenu` rather than destroy + recreate. Thanks @IliyaBrook. ([#515](https://github.com/aaddrick/claude-desktop-debian/pull/515))
|
||||
- Window visibility check works again after an upstream minified-name change broke it. Thanks @Andrej730. ([#496](https://github.com/aaddrick/claude-desktop-debian/pull/496), fixes [#495](https://github.com/aaddrick/claude-desktop-debian/issues/495))
|
||||
|
||||
### Changed
|
||||
|
||||
- APT/DNF install instructions point at `pkg.claude-desktop-debian.dev` directly, bypassing the GitHub Pages 301. Pages serves the redirect over `http://` because it can't provision a cert for the `pkg.` subdomain (DNS belongs to the Cloudflare Worker), and `apt` refuses HTTPS→HTTP downgrades. DNF was unaffected. ([#510](https://github.com/aaddrick/claude-desktop-debian/pull/510), [#514](https://github.com/aaddrick/claude-desktop-debian/pull/514))
|
||||
|
||||
## [v2.0.5] — 2026-04-23
|
||||
|
||||
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI: smoke test accepts release-assets CDN hostname. ([#509](https://github.com/aaddrick/claude-desktop-debian/pull/509))
|
||||
- Strip CRLF from `cowork-plugin-shim.sh` during staging. ([#499](https://github.com/aaddrick/claude-desktop-debian/pull/499), [#505](https://github.com/aaddrick/claude-desktop-debian/pull/505))
|
||||
|
||||
## [v2.0.4] — 2026-04-23
|
||||
|
||||
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0. No GitHub Release published.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI: smoke test accepts `http://` on Pages 301 hop. ([#506](https://github.com/aaddrick/claude-desktop-debian/pull/506))
|
||||
- Worker: use `raw.githubusercontent.com` as origin to avoid Pages 301 loop. ([#504](https://github.com/aaddrick/claude-desktop-debian/pull/504))
|
||||
|
||||
### Changed
|
||||
|
||||
- Worker: flip route from staging to production for Phase 4a. ([#503](https://github.com/aaddrick/claude-desktop-debian/pull/503))
|
||||
|
||||
## [v2.0.3] — 2026-04-23
|
||||
|
||||
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0. No GitHub Release published.
|
||||
|
||||
### Added
|
||||
|
||||
- APT/DNF Worker scaffolding. ([#498](https://github.com/aaddrick/claude-desktop-debian/pull/498))
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI: resolve DNF Worker chain blockers. ([#500](https://github.com/aaddrick/claude-desktop-debian/issues/500), [#501](https://github.com/aaddrick/claude-desktop-debian/issues/501), [#502](https://github.com/aaddrick/claude-desktop-debian/pull/502))
|
||||
|
||||
### Changed
|
||||
|
||||
- Plan APT/DNF distribution via Cloudflare Worker. ([#493](https://github.com/aaddrick/claude-desktop-debian/pull/493), [#494](https://github.com/aaddrick/claude-desktop-debian/pull/494))
|
||||
|
||||
## [v2.0.2] — 2026-04-22
|
||||
|
||||
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0.
|
||||
|
||||
### Added
|
||||
|
||||
- BATS unit tests for `launcher-common.sh`. ([#395](https://github.com/aaddrick/claude-desktop-debian/pull/395))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Copy `ion-dist` static assets for the `app://` protocol handler. ([#490](https://github.com/aaddrick/claude-desktop-debian/pull/490))
|
||||
|
||||
## [v2.0.1] — 2026-04-21
|
||||
|
||||
Wrapper/packaging update; tracks upstream Claude Desktop 1.3561.0, 1.3883.0.
|
||||
|
||||
### Added
|
||||
|
||||
- Triage Phase 4 sub-PRs: Stage 8c enhancement-design variant, suspicious-input tells, `regression_of` + edit-during-triage. ([#470](https://github.com/aaddrick/claude-desktop-debian/pull/470), [#471](https://github.com/aaddrick/claude-desktop-debian/pull/471), [#472](https://github.com/aaddrick/claude-desktop-debian/pull/472))
|
||||
- Triage Phase 3: Stage 6 adversarial reviewer + duplicate gate. ([#465](https://github.com/aaddrick/claude-desktop-debian/pull/465))
|
||||
- Decision log with D-001 (auto-update direction). ([#477](https://github.com/aaddrick/claude-desktop-debian/pull/477))
|
||||
- `@sabiut` added to CODEOWNERS for testing & release quality. ([#468](https://github.com/aaddrick/claude-desktop-debian/pull/468))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Export `GDK_BACKEND=wayland` in native Wayland mode. Thanks @aJV99. ([#397](https://github.com/aaddrick/claude-desktop-debian/pull/397))
|
||||
- Scope Ctrl+Q to the focused window, not system-wide. ([#484](https://github.com/aaddrick/claude-desktop-debian/pull/484))
|
||||
- Cowork: forward `CLAUDE_CODE_OAUTH_TOKEN` to VM spawn env. ([#482](https://github.com/aaddrick/claude-desktop-debian/pull/482), [#485](https://github.com/aaddrick/claude-desktop-debian/pull/485))
|
||||
- Launcher: disable GPU compositing on XRDP sessions. ([#475](https://github.com/aaddrick/claude-desktop-debian/pull/475))
|
||||
- Triage: normalize `claimed_version` before drift compare. ([#483](https://github.com/aaddrick/claude-desktop-debian/pull/483))
|
||||
- Triage: drift-as-banner — demote drift from gate to modifier. ([#476](https://github.com/aaddrick/claude-desktop-debian/pull/476))
|
||||
- Triage: pull broken-expectation rule up into first-pass classify. ([#469](https://github.com/aaddrick/claude-desktop-debian/pull/469))
|
||||
- Triage: raise 8b comment word cap 150 → 300. ([#464](https://github.com/aaddrick/claude-desktop-debian/pull/464))
|
||||
|
||||
### Changed
|
||||
|
||||
- Triage v2 production cutover; README synced with shipped pipeline (drop plan + research). ([#478](https://github.com/aaddrick/claude-desktop-debian/pull/478), [#480](https://github.com/aaddrick/claude-desktop-debian/pull/480))
|
||||
- Rename `feature` classification to `enhancement` in triage. ([#466](https://github.com/aaddrick/claude-desktop-debian/pull/466))
|
||||
|
||||
## [v2.0.0] — 2026-04-20
|
||||
|
||||
First v2 wrapper release; tracks upstream Claude Desktop 1.3109.0, 1.3561.0.
|
||||
|
||||
### Added
|
||||
|
||||
- Always-on lifecycle logging for `cowork-vm-service`. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
|
||||
- `cowork-vm-daemon` learnings doc and Anthropic & Partners plugin install flow doc. ([#439](https://github.com/aaddrick/claude-desktop-debian/pull/439))
|
||||
- `.github/CODEOWNERS` for per-subsystem review ownership.
|
||||
- `shellcheck -x` to follow sourced modules in CI.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore `cowork-vm-service` daemon recovery after crash. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
|
||||
- Forward `userSelectedFolders[0]` as `sharedCwdPath` on cowork spawn. ([#412](https://github.com/aaddrick/claude-desktop-debian/pull/412), [#436](https://github.com/aaddrick/claude-desktop-debian/pull/436))
|
||||
- Strip mode on `node-pty` cp at source; retire `chmod`. Chmod `node-pty` unpacked files before overwriting in Nix builds. ([#432](https://github.com/aaddrick/claude-desktop-debian/pull/432), [#438](https://github.com/aaddrick/claude-desktop-debian/pull/438))
|
||||
- Diagnose AppArmor userns block on bwrap probe. ([#351](https://github.com/aaddrick/claude-desktop-debian/issues/351), [#434](https://github.com/aaddrick/claude-desktop-debian/pull/434))
|
||||
- Suppress Cowork tab auto-select on every launch. ([#341](https://github.com/aaddrick/claude-desktop-debian/issues/341), [#433](https://github.com/aaddrick/claude-desktop-debian/pull/433))
|
||||
- `home --dir` before SDK `--ro-bind` in bwrap sandbox. ([#426](https://github.com/aaddrick/claude-desktop-debian/pull/426))
|
||||
- Only route `claude` commands through SDK binary in `cowork-vm-service`. ([#430](https://github.com/aaddrick/claude-desktop-debian/pull/430))
|
||||
- `launcher-common.sh` self-match and stale socket cleanup. ([#407](https://github.com/aaddrick/claude-desktop-debian/pull/407), [#425](https://github.com/aaddrick/claude-desktop-debian/pull/425))
|
||||
- Translate guest paths inside `--allowedTools` and `--disallowedTools`. ([#411](https://github.com/aaddrick/claude-desktop-debian/pull/411))
|
||||
- Resolve working directory from primary mount on HostBackend. ([#392](https://github.com/aaddrick/claude-desktop-debian/pull/392))
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING**: Split `build.sh` into topical modules under `scripts/`; relocate packaging scripts into `scripts/packaging/`; extract `--doctor` into `scripts/doctor.sh`. Patch files now live in `scripts/patches/*.sh` (one per subsystem); `build.sh` is just an orchestrator. CI paths updated to `scripts/setup/detect-host.sh`.
|
||||
- Simplify cowork daemon recovery patch. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
|
||||
|
||||
[Unreleased]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.12+claude1.8555.2...HEAD
|
||||
[v2.0.12]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.11+claude1.7196.1...v2.0.12+claude1.7196.3
|
||||
[v2.0.11]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.10+claude1.7196.0...v2.0.11+claude1.7196.1
|
||||
[v2.0.10]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.8+claude1.5354.0...v2.0.10+claude1.6259.0
|
||||
[v2.0.8]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.7+claude1.5354.0...v2.0.8+claude1.5354.0
|
||||
[v2.0.7]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.6+claude1.5354.0...v2.0.7+claude1.5354.0
|
||||
[v2.0.6]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.5+claude1.5354.0...v2.0.6+claude1.5354.0
|
||||
[v2.0.5]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.4+claude1.3883.0...v2.0.5+claude1.3883.0
|
||||
[v2.0.4]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.3+claude1.3883.0...v2.0.4+claude1.3883.0
|
||||
[v2.0.3]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.2+claude1.3883.0...v2.0.3+claude1.3883.0
|
||||
[v2.0.2]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.1+claude1.3883.0...v2.0.2+claude1.3883.0
|
||||
[v2.0.1]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.0+claude1.3561.0...v2.0.1+claude1.3883.0
|
||||
[v2.0.0]: https://github.com/aaddrick/claude-desktop-debian/releases/tag/v2.0.0+claude1.3109.0
|
||||
46
CLAUDE.md
46
CLAUDE.md
@@ -1,26 +1,5 @@
|
||||
# Claude Desktop Debian - Development Notes
|
||||
|
||||
<!--
|
||||
This file is read by Claude Code. The content below is duplicated in
|
||||
AGENTS.md (read by other AI tools per the agents.md standard) so that
|
||||
contributors using either receive the same instructions without needing
|
||||
to cross-reference. Keep CLAUDE.md and AGENTS.md byte-identical below
|
||||
the H1 title (the sync-policy comment above is the one place they
|
||||
intentionally differ) — if you edit one, edit the other.
|
||||
-->
|
||||
|
||||
## Required reading
|
||||
|
||||
These documents are the source of truth. If anything in this file conflicts with them, they win. Read them before opening a non-trivial issue or PR.
|
||||
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — what we accept, what goes upstream, subsystem owners, AI-attribution policy.
|
||||
- [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md) — shell-script conventions (forked from YSAP). Tabs, 80 cols, `[[ ]]`, no `set -e`, no `eval`.
|
||||
- [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) — page anatomy, naming, antipatterns for the `docs/` tree.
|
||||
- [`docs/index.md`](docs/index.md) — entry point for the rest of the repo docs.
|
||||
- [`SECURITY.md`](SECURITY.md) — vulnerability reporting; what's in scope vs. upstream.
|
||||
|
||||
This file is a fast reference for the highest-leverage rules and the project's accumulated archaeology. New policy goes in the style guides or CONTRIBUTING.md.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux, applying necessary patches for Linux compatibility.
|
||||
@@ -38,11 +17,10 @@ The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical k
|
||||
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
|
||||
- [`test-harness-electron-hooks.md`](docs/learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps are silently bypassed by `frame-fix-wrapper`'s Proxy, and the prototype-method hook pattern that works (used by the Quick Entry test runners)
|
||||
- [`test-harness-ax-tree-walker.md`](docs/learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker after the AX-tree migration: AX-enable async lag, navigateTo-to-same-URL no-op, claude.ai's flat `dialog>button[]` lists, the `more options for X` per-row shape, and sidebar virtualization vs the lookup-failure threshold
|
||||
- [`patching-minified-js.md`](docs/learnings/patching-minified-js.md) — general lessons from maintaining a long-lived patch suite against an actively re-minified upstream: anchor selection (literals over identifiers), the `\w` vs `$` identifier-capture trap, beautified false-negatives, idempotency guards, multi-site coordination, non-unique anchor disambiguation, and the SHA-256-pinned hypothesis-verification recipe
|
||||
|
||||
## Code Style
|
||||
|
||||
All shell scripts in this project must follow the [Bash Style Guide](docs/styleguides/bash_styleguide.md). Key points:
|
||||
All shell scripts in this project must follow the [Bash Style Guide](STYLEGUIDE.md). Key points:
|
||||
|
||||
- Tabs for indentation, lines under 80 characters (exception: URLs and regex patterns)
|
||||
- Use `[[ ]]` for conditionals, `$(...)` for command substitution
|
||||
@@ -50,16 +28,6 @@ All shell scripts in this project must follow the [Bash Style Guide](docs/styleg
|
||||
- Lowercase variables; UPPERCASE only for constants/exports
|
||||
- Use `local` in functions, avoid `set -e` and `eval`
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Don't `set -e`.** It interacts badly with `$(...)` capture and function return values, and the project has historically debugged enough silent exits to settle the question. Check status explicitly: `cmd || handle_err`.
|
||||
- **Don't `eval`.** Use arrays for argv composition (`cmd "${args[@]}"`). `eval` defeats every parser and is a permanent SC2046 magnet.
|
||||
- **Don't use POSIX `[ ... ]`.** Always `[[ ... ]]`. POSIX `[` mis-parses unquoted expansions in ways `[[` does not.
|
||||
- **Don't backtick.** Always `$(...)`. Backticks don't nest cleanly and conflict with markdown when patches are pasted into PR comments.
|
||||
- **Don't hardcode the work directory.** Scripts that operate during a build use `$work_dir` (set by `build.sh`). A hardcoded path silently breaks the AppImage build, which runs in a different layout from the deb/rpm builds.
|
||||
- **Don't wrap commands in `if cmd; then true; else false; fi`-style scaffolding.** Just `cmd` — the exit code is already there.
|
||||
- **Don't append to a baseline file to silence `shellcheck`.** Fix the underlying issue. If a warning is genuinely a false positive, use a per-line `# shellcheck disable=SCXXXX` with a comment explaining why.
|
||||
|
||||
### Linting
|
||||
|
||||
Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `actionlint` before pushing. When lint issues are found:
|
||||
@@ -71,16 +39,6 @@ Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `a
|
||||
- Always add a comment explaining why the disable is needed
|
||||
3. **Run `/lint` to check manually** - Use this skill to check for issues before pushing
|
||||
|
||||
## Docs
|
||||
|
||||
- **One declarative sentence then a code block or list at the top of every page.** No "In this guide we will explore…" preamble. See [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md).
|
||||
- **Lowercase kebab-case filenames** for everything in `docs/`. Order belongs in [`docs/index.md`](docs/index.md), not filenames or numeric prefixes.
|
||||
- **Real domain nouns over `foo`/`bar`** in walkthroughs. The project vocabulary is `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`, `the doctor surface`.
|
||||
- **Subsystem deep-dives go under [`docs/learnings/`](docs/learnings/).** Surfacing knowledge there beats burying it in commit messages or in patch-script comments. Add an entry when you discover something non-obvious that would save the next contributor significant time.
|
||||
- **Decisions go in [`docs/decisions.md`](docs/decisions.md) (ADR format).** Don't relitigate a settled direction inside a how-to page; link the decision instead.
|
||||
- **Troubleshooting headings are the literal symptom**, not editorialized prose. `## Black screen on Fedora KDE under Wayland`, not `## Troubles with Wayland`. Search ranks headings.
|
||||
- **CHANGELOG follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).** Bullets grouped under Added / Fixed / Changed / Deprecated / Removed / Security; one bullet per change; PR link for the deep dive; inline **BREAKING** prefix for breaking changes. See [`CHANGELOG.md`](CHANGELOG.md) for the current state and [`RELEASING.md`](RELEASING.md) for when entries get promoted from `[Unreleased]`.
|
||||
|
||||
## GitHub Workflow
|
||||
|
||||
### General Approach
|
||||
@@ -167,7 +125,7 @@ Contributors are listed in chronological order: inspirational projects first (k3
|
||||
4. **Extract variable names dynamically** rather than hardcoding them. Shared extraction helpers live in `scripts/patches/_common.sh`. Example:
|
||||
```bash
|
||||
# Extract function name from a known pattern
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
```
|
||||
|
||||
5. **Handle optional whitespace** in regex patterns:
|
||||
|
||||
161
CONTRIBUTING.md
161
CONTRIBUTING.md
@@ -1,161 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
## Before you start
|
||||
|
||||
A few minutes here saves a round-trip later. Match your task to the right channel:
|
||||
|
||||
- **Found a bug?** File an [issue](https://github.com/aaddrick/claude-desktop-debian/issues/new/choose)
|
||||
with the bug template. Paste full `claude-desktop --doctor` output;
|
||||
include distro, DE, and session type (Wayland/X11). See
|
||||
[Filing an issue](#filing-an-issue).
|
||||
- **Have a fix in hand?** PRs that fix existing behaviour, restore parity
|
||||
with Windows/macOS, or improve packaging are always welcome. Open the
|
||||
PR; an issue isn't strictly required if the fix is small.
|
||||
- **Want to add a new feature?** Open a [discussion](https://github.com/aaddrick/claude-desktop-debian/discussions)
|
||||
or an issue first. We're a repackager; most net-new behaviour is
|
||||
declined by default — see [What we accept](#what-we-accept).
|
||||
- **Security concern?** Don't file a public issue. Use
|
||||
[SECURITY.md](SECURITY.md) — GitHub Security Advisories route to
|
||||
@aaddrick privately.
|
||||
|
||||
## Where to find what
|
||||
|
||||
- [CLAUDE.md](CLAUDE.md): conventions, build, patches, attribution.
|
||||
- [AGENTS.md](AGENTS.md): vendor-neutral mirror of CLAUDE.md for non-Claude AI tools.
|
||||
- [docs/index.md](docs/index.md): full docs entry point.
|
||||
- [docs/styleguides/bash_styleguide.md](docs/styleguides/bash_styleguide.md):
|
||||
bash style ([style.ysap.sh](https://style.ysap.sh)). Tabs, 80 cols, `[[ ]]`, no `set -e`.
|
||||
- [docs/styleguides/docs_styleguide.md](docs/styleguides/docs_styleguide.md):
|
||||
page anatomy and naming if you're adding a doc.
|
||||
- [docs/learnings/](docs/learnings/): subsystem deep-dives. Read the
|
||||
relevant entry first.
|
||||
- [docs/building.md](docs/building.md): local build setup.
|
||||
- [docs/decisions.md](docs/decisions.md): architectural choices (ADR format).
|
||||
- [CHANGELOG.md](CHANGELOG.md): release-grouped history from v2.0.0 onward.
|
||||
- [RELEASING.md](RELEASING.md): how a release ships (tag-driven CI).
|
||||
- [SECURITY.md](SECURITY.md): private vulnerability reporting.
|
||||
- [.github/CODEOWNERS](.github/CODEOWNERS): auto-review routing.
|
||||
|
||||
## What we accept
|
||||
|
||||
We're a repackager, not a fork. Net-new feature PRs default to no: we'd
|
||||
own that behaviour across every re-minified upstream release.
|
||||
Exception: parity patches for Windows features broken on Linux
|
||||
(input methods, tray on Wayland/X11, frame defaults). Always welcome:
|
||||
|
||||
- Bug fixes against existing behaviour.
|
||||
- Parity patches bringing Linux closer to the Windows build.
|
||||
- Packaging, distribution, launcher fixes.
|
||||
- Docs, tests, CI improvements.
|
||||
|
||||
## What goes upstream, not here
|
||||
|
||||
We patch the binary blob; we don't fix application logic inside it.
|
||||
If the bug reproduces on Windows, file at
|
||||
[anthropics/claude-code](https://github.com/anthropics/claude-code).
|
||||
In-app `/bug` and `/feedback` are inert.
|
||||
|
||||
| File here | File upstream |
|
||||
|----------------------------------------|-------------------------------------|
|
||||
| `apt update` errors, install failures | Plugin install fails on all OSes |
|
||||
| Tray icon missing on KDE Wayland | Conversation rendering glitch |
|
||||
| AppImage won't launch on distro X | MCP server connection drops |
|
||||
| `--doctor` reports wrong diagnosis | Account / login flow broken |
|
||||
|
||||
## Filing an issue
|
||||
|
||||
1. Use the issue template, not freeform.
|
||||
2. Paste full `./build.sh --doctor` (or `claude-desktop --doctor`)
|
||||
output. Most-skipped step.
|
||||
3. Include distro, DE, session type (Wayland/X11). Most Linux-only
|
||||
bugs trace to one of these.
|
||||
4. Reproduce on a clean config: move `~/.config/Claude` aside, relaunch.
|
||||
Stale config causes false positives.
|
||||
|
||||
## Patches against upstream
|
||||
|
||||
Patches live in `scripts/patches/*.sh`, one per subsystem; `build.sh`
|
||||
sources them. Before writing or editing one, read [the
|
||||
patching-minified-js learnings doc][pmj]: anchor selection, capture,
|
||||
idempotency, beautified-vs-minified gap. Short form: CLAUDE.md §
|
||||
Working with Minified JavaScript.
|
||||
|
||||
Priority rule: a broken-patch upstream release beats feature work.
|
||||
|
||||
## Subsystem owners
|
||||
|
||||
CODEOWNERS auto-requests reviews; this list is for human discoverability.
|
||||
|
||||
- **@aaddrick**: default. Build, non-Cowork patches, desktop, packaging, docs.
|
||||
- **@sabiut**: `tests/`, `scripts/doctor.sh`, test workflows.
|
||||
- **@RayCharlizard**: Cowork (`scripts/patches/cowork.sh`,
|
||||
`scripts/cowork-vm-service.js`, `tests/cowork-*.bats`).
|
||||
- **@typedrat**: Nix (`flake.nix`, `flake.lock`, `/nix/`).
|
||||
|
||||
## Before submitting a PR
|
||||
|
||||
- Run `/lint` (or `shellcheck` + `actionlint`). See CLAUDE.md § Linting.
|
||||
- Local build: `./build.sh --build appimage --clean no`. Catches
|
||||
patch failures unit tests miss.
|
||||
- Branch: `fix/123-description` or `feature/123-description`.
|
||||
- PR body links the issue: `Fixes #123` or `Refs #123`.
|
||||
- AI-assisted? Add the attribution block (next section).
|
||||
|
||||
## AI-assisted contributions
|
||||
|
||||
AI-assisted PRs accepted with disclosure. PR descriptions:
|
||||
|
||||
```
|
||||
---
|
||||
Generated with [Claude Code](https://claude.ai/code)
|
||||
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
|
||||
XX% AI / YY% Human
|
||||
Claude: <what AI did>
|
||||
Human: <what human did>
|
||||
```
|
||||
|
||||
Real model name (e.g., "Claude Opus 4.7"). Honest split.
|
||||
Breakdown lines make the ratio auditable against the diff.
|
||||
|
||||
Commits: `Co-Authored-By: Claude <claude@anthropic.com>`.
|
||||
|
||||
Issues/comments:
|
||||
`Written by Claude <model-name> via [Claude Code](https://claude.ai/code)`.
|
||||
|
||||
## Conventions in this file
|
||||
|
||||
### Patch-script regexes
|
||||
|
||||
Two rules apply to regexes that target the minified upstream bundle.
|
||||
|
||||
**Identifier captures use `[$\w]+`, not `\w+`.** Upstream's minifier
|
||||
emits `$` inside JS identifiers (`C$i`, `g$i`, `i$A`). `\w` is
|
||||
`[A-Za-z0-9_]` and does not match `$`, so a `\w+` capture against
|
||||
`$e` returns the suffix `e` instead of the whole identifier. PR #555
|
||||
and PR #627 closed two cohorts of patches with this exact bug. The
|
||||
learnings doc has the full background and the canonical character
|
||||
class is `[$\w]+` (the equivalent `[\w$]+` is fine; either form
|
||||
matches the same set, the order is convention only).
|
||||
|
||||
**Intent comments accompany whitespace-tolerant patterns.** When a
|
||||
patch regex uses `\s*` or `[ \t]*` between tokens, add a one-line
|
||||
intent comment with whitespace stripped so the matched shape stays
|
||||
readable:
|
||||
|
||||
```js
|
||||
// Intent: VAR.code==="ENOENT"
|
||||
const enoentRe = /([$\w]+)\.code\s*===\s*"ENOENT"/g;
|
||||
```
|
||||
|
||||
Apply both rules to new patches and to existing regexes when you're
|
||||
editing them for other reasons. No churn PRs. Background:
|
||||
[the patching-minified-js learnings doc][pmj].
|
||||
|
||||
[pmj]: docs/learnings/patching-minified-js.md
|
||||
|
||||
### Markdown prose wrapping
|
||||
|
||||
Wrap prose at ~80 chars, matching the bash column rule in
|
||||
[docs/styleguides/bash_styleguide.md](docs/styleguides/bash_styleguide.md).
|
||||
Tables, code blocks, URLs, alt text may exceed when breaking hurts
|
||||
readability.
|
||||
23
README.md
23
README.md
@@ -4,8 +4,6 @@ This project provides build scripts to run Claude Desktop natively on Linux syst
|
||||
|
||||
**Note:** This is an unofficial build script. For official support, please visit [Anthropic's website](https://www.anthropic.com). For issues with the build script or Linux implementation, please [open an issue](https://github.com/aaddrick/claude-desktop-debian/issues) in this repository.
|
||||
|
||||
**Documentation:** Full docs at [`docs/index.md`](docs/index.md). Release history in [`CHANGELOG.md`](CHANGELOG.md). Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md). Security reports: [`SECURITY.md`](SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ APT migration notice (April 2026)**
|
||||
@@ -137,7 +135,7 @@ Download the latest `.deb`, `.rpm`, or `.AppImage` from the [Releases page](http
|
||||
|
||||
### Building from Source
|
||||
|
||||
See [docs/building.md](docs/building.md) for detailed build instructions.
|
||||
See [docs/BUILDING.md](docs/BUILDING.md) for detailed build instructions.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -146,13 +144,13 @@ Model Context Protocol settings are stored in:
|
||||
~/.config/Claude/claude_desktop_config.json
|
||||
```
|
||||
|
||||
For additional configuration options including environment variables and Wayland support, see [docs/configuration.md](docs/configuration.md).
|
||||
For additional configuration options including environment variables and Wayland support, see [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run `claude-desktop --doctor` for built-in diagnostics that check common issues (display server, sandbox permissions, MCP config, stale locks, and more). It also reports cowork mode readiness — which isolation backend will be used, and which dependencies (KVM, QEMU, vsock, socat, virtiofsd, bubblewrap) are installed or missing.
|
||||
|
||||
For additional troubleshooting, uninstallation instructions, and log locations, see [docs/troubleshooting.md](docs/troubleshooting.md).
|
||||
For additional troubleshooting, uninstallation instructions, and log locations, see [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
@@ -187,7 +185,6 @@ Special thanks to:
|
||||
- Version update contributions
|
||||
- Close-to-tray on Linux to keep in-app schedulers, MCP servers, and the tray icon alive across window close
|
||||
- "Run on startup" persistence on Linux via XDG Autostart, fixing the toggle that would silently revert
|
||||
- In-place package upgrade detection that watches `app.asar` for dpkg/rpm replacement and offers a click-to-restart notification, fixing the Quick Entry / About / Ctrl+Q symptom cluster from a running v(N) main process loading v(N+1) renderer assets (#564)
|
||||
- **[mathys-lopinto](https://github.com/mathys-lopinto)**
|
||||
- AUR package
|
||||
- Automated deployment
|
||||
@@ -201,9 +198,6 @@ Special thanks to:
|
||||
- `--doctor` diagnostic command
|
||||
- SHA-256 checksum validation for downloads
|
||||
- Post-build integration tests for deb, rpm, and AppImage artifacts
|
||||
- `tests.yml` CI workflow that runs the 186-test BATS suite on push and PR — the suite was inert in CI before this (#520)
|
||||
- Isolating `cleanup_stale_cowork_socket` BATS from host `pgrep` state so the test passes on developer machines running Claude Desktop (#533, #534)
|
||||
- Headless launch and `--doctor` smoke tests for the AppImage artifact, catching runtime regressions (frame-fix-wrapper syntax errors, asar patch breakage, `main` field mismatches) that the structural test missed (#592)
|
||||
- **[milog1994](https://github.com/milog1994)**
|
||||
- Popup detection
|
||||
- Functional stubs
|
||||
@@ -233,7 +227,6 @@ Special thanks to:
|
||||
- node-pty derivation
|
||||
- CI auto-update
|
||||
- Fixing the flake package scoping regression
|
||||
- Fixing the NixOS electron binary not being marked executable (#431, #581)
|
||||
- **[cbonnissent](https://github.com/cbonnissent)**
|
||||
- Reverse-engineering the Cowork VM guest RPC protocol
|
||||
- Fixing the KVM startup blocker
|
||||
@@ -249,8 +242,6 @@ Special thanks to:
|
||||
- Detailed analysis of the self-referential `.mcpb-cache` symlink ELOOP bug
|
||||
- Fixing auto-memory path translation on HostBackend
|
||||
- Fixing the `ion-dist` static asset copy for the `app://` protocol handler
|
||||
- `--doctor` diagnostic that detects the Ubuntu 24.04 AppArmor `apparmor_restrict_unprivileged_userns=1` block on bwrap, instead of letting it silently fall through to a hanging KVM probe (#351, #434)
|
||||
- Documenting the upstream MCP double-spawn root-cause analysis in `docs/learnings/mcp-double-spawn.md` (#526, #527)
|
||||
- **[reinthal](https://github.com/reinthal)** for fixing the NixOS build breakage caused by the nixpkgs `nodePackages` removal
|
||||
- **[gianluca-peri](https://github.com/gianluca-peri)**
|
||||
- Reporting the GNOME quit accessibility issue
|
||||
@@ -268,14 +259,6 @@ Special thanks to:
|
||||
- **[zabka](https://github.com/zabka)** for identifying that `cowork-vm-service.js` was never auto-spawned on Linux and contributing a systemd-unit workaround that scoped the daemon auto-launch fix (#445)
|
||||
- **[sirfaber](https://github.com/sirfaber)** for fixing the `$`-in-minified-identifier breakage of cowork Patch 2b (vm module assignment) and Patch 6 step 2 (retry-delay auto-launch) on Claude Desktop 1.5354.0 (#555)
|
||||
- **[ProfFlow](https://github.com/ProfFlow)** for re-fixing the RPM repodata signing regression by appending `!` to the keyid passed to `gpg --default-key`, forcing `repomd.xml` to be signed by the primary key instead of the auto-selected signing subkey (#566)
|
||||
- **[jslatten](https://github.com/jslatten)** for fixing the KDE Plasma Wayland launcher-grouping bug by setting `pkg.desktopName` in the packaged `app.asar`'s `package.json`, format-conditional so deb/rpm get `claude-desktop.desktop` and AppImage gets `io.github.aaddrick.claude-desktop-debian.desktop` (#562)
|
||||
- **[JoshuaVlantis](https://github.com/JoshuaVlantis)**
|
||||
- RPM `chrome-sandbox` SUID via `%attr(4755, ...)` instead of a `%post` chmod scriptlet so the bit survives `--noscripts` and layered images (#539)
|
||||
- `autoUpdater` no-op Proxy on Linux that defends against future feed activation, with a thenable allowlist masking `then`/`catch`/`finally`/`Symbol.toPrimitive`/`Symbol.iterator` to `undefined` (#567)
|
||||
- Failing loudly on `npm install node-pty` failures instead of silently shipping the upstream Windows binaries, plus auto-installing `gcc`/`g++`/`make`/`python3` on minimal build environments (#401)
|
||||
- **[Hayao0819](https://github.com/Hayao0819)** for diagnosing the upstream `titleBarStyle:""` → `titleBarStyle:"hiddenInset"` migration that broke the About window render on GNOME/X11 and contributing the `isPopupWindow()` match extension (#481, #489)
|
||||
- **[michelsfun](https://github.com/michelsfun)** for reporting the cowork `ENAMETOOLONG` failure on eCryptfs-encrypted home directories with detailed `--doctor` output that pinpointed the short-NAME_MAX filesystem as the cause (#590)
|
||||
- **[proffalken](https://github.com/proffalken)** for the LUKS-volume + `pam_mount` workaround documented in `docs/troubleshooting.md`, restoring cowork support on legacy eCryptfs-encrypted home directories (#590)
|
||||
|
||||
## Sponsorship
|
||||
|
||||
|
||||
80
RELEASING.md
80
RELEASING.md
@@ -1,80 +0,0 @@
|
||||
# Releasing
|
||||
|
||||
This project ships through tag-driven CI. A tag of the form `v{REPO_VERSION}+claude{CLAUDE_DESKTOP_VERSION}` on `main` triggers the release job in [`.github/workflows/ci.yml`](.github/workflows/ci.yml), which builds for both architectures, attaches the artifacts to a GitHub Release, and updates the APT, DNF, and AUR repositories.
|
||||
|
||||
There are two flavors of release:
|
||||
|
||||
- **Upstream-tracking retag.** A `check-claude-version` workflow runs daily, detects new Claude Desktop releases, bumps the `CLAUDE_DESKTOP_VERSION` repo variable, patches URLs and SRI hashes in `scripts/setup/detect-host.sh` and `nix/claude-desktop.nix`, and pushes a new tag with the same `REPO_VERSION` and a new `+claude{X.Y.Z}` suffix. **No human action required.** These do not get CHANGELOG entries — they're tracked in the tag suffix.
|
||||
- **Project release.** You bumped `REPO_VERSION` because you shipped project changes. Follow the checklist below.
|
||||
|
||||
## Pre-release checklist
|
||||
|
||||
1. **CI is green on `main`.** All required workflows (CI, tests, shellcheck, codespell) passed on the commit you're about to tag.
|
||||
|
||||
```bash
|
||||
gh run list --branch main --limit 5
|
||||
```
|
||||
|
||||
2. **`CHANGELOG.md` is updated.** The `[Unreleased]` section now reflects what you're about to ship. Move it under a new `[v{REPO_VERSION}]` heading with today's date.
|
||||
|
||||
3. **Local tests pass.**
|
||||
|
||||
```bash
|
||||
bats tests/
|
||||
shellcheck scripts/**/*.sh build.sh
|
||||
```
|
||||
|
||||
See [`CLAUDE.md`](CLAUDE.md#linting) for the canonical lint command.
|
||||
|
||||
4. **AppImage artifact boots on a clean system.** The `test-artifacts.yml` reusable workflow already runs a `--doctor` smoke test against each format in CI (#592), but if you've touched the launcher or patch surface, build locally and confirm:
|
||||
|
||||
```bash
|
||||
./build.sh --build appimage --clean no
|
||||
./test-build/claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
5. **The version variables are in sync.**
|
||||
|
||||
```bash
|
||||
gh variable get REPO_VERSION
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
grep -oP 'x64/\K[0-9]+\.[0-9]+\.[0-9]+' scripts/setup/detect-host.sh | head -1
|
||||
```
|
||||
|
||||
The grep value should match the `CLAUDE_DESKTOP_VERSION` variable. If not, pull the latest URLs from `main` — the `check-claude-version` workflow may have updated them on `main` without rebasing your branch ([`CLAUDE.md`](CLAUDE.md#common-gotchas) has the recipe).
|
||||
|
||||
## Bumping and tagging
|
||||
|
||||
```bash
|
||||
# 1. Bump the project version (this is a GitHub Actions variable, not a file).
|
||||
gh variable set REPO_VERSION --body "2.0.13"
|
||||
|
||||
# 2. Tag with both versions in the tag name.
|
||||
git tag "v2.0.13+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
|
||||
# 3. Push the tag — this is what kicks off the release build.
|
||||
git push origin "v2.0.13+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
```
|
||||
|
||||
The `REPO_VERSION` variable bump can happen before or after the tag push; CI reads neither directly. The variable exists so future workflow runs know the current project version.
|
||||
|
||||
## What CI does on tag push
|
||||
|
||||
The [`release`](.github/workflows/ci.yml) job in `ci.yml` is gated on `startsWith(github.ref, 'refs/tags/v')`. After `test-flags`, `build-amd64`, `build-arm64`, and `test-artifacts` pass:
|
||||
|
||||
1. Downloads all nine assets (six packages -- amd64 + arm64, each in deb/rpm/AppImage -- plus two `.zsync` delta files and a `reference-source.tar.gz`).
|
||||
2. Pulls release notes from the separate [`aaddrick/claude-desktop-versions`](https://github.com/aaddrick/claude-desktop-versions) repo if available; falls back to the autogenerated changelog otherwise.
|
||||
3. Creates the GitHub Release and attaches the nine assets.
|
||||
4. Hands off to `update-apt-repo`, `update-dnf-repo`, and `update-aur-repo`, which publish to the Cloudflare-fronted package repos ([`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) for the redirect chain).
|
||||
|
||||
## After the release lands
|
||||
|
||||
- **Verify the Release page.** Nine assets attached, sizes look right, release notes rendered.
|
||||
- **Smoke-test one artifact.** Download the AppImage and run `--doctor` against it.
|
||||
- **Watch `apt-repo-heartbeat`.** The next daily run validates the redirect chain end-to-end. If it opens a tracking issue, walk the chain in [`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md#heartbeat-failure-runbook).
|
||||
|
||||
## If something goes wrong mid-release
|
||||
|
||||
- **Build fails.** Push the fix to `main`, then re-tag with a new `+claude` suffix (or a `+rebuild.N` suffix if upstream hasn't moved). The original tag stays — releases are append-only.
|
||||
- **A bad release shipped.** Mark the GitHub Release as a pre-release / draft and ship a follow-up. Don't delete artifacts that may already be cached by the APT/DNF Worker.
|
||||
- **The `check-claude-version` workflow conflicts with your local branch.** Pull URL changes from `main` before pushing your tag — the workflow autobumps `scripts/setup/detect-host.sh` between your work and your tag.
|
||||
35
SECURITY.md
35
SECURITY.md
@@ -1,35 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
Report suspected vulnerabilities privately via [GitHub Security Advisories](https://github.com/aaddrick/claude-desktop-debian/security/advisories/new). Do not open a public issue or post details in Discussions.
|
||||
|
||||
## Scope
|
||||
|
||||
This project repackages an upstream Electron app. The boundary matters:
|
||||
|
||||
**In scope** — things this repo ships:
|
||||
|
||||
- Patches in `scripts/patches/*.sh`
|
||||
- Packaging scripts in `scripts/packaging/`
|
||||
- The launcher (`scripts/launcher-common.sh`) and the `claude-desktop --doctor` surface
|
||||
- CI workflows under `.github/workflows/`
|
||||
- The APT/DNF Cloudflare Worker under `worker/`
|
||||
- The frame-fix wrapper and any other JS we inject into `app.asar`
|
||||
|
||||
**Out of scope** — file upstream:
|
||||
|
||||
- Vulnerabilities in the Claude Desktop application itself, the Anthropic API, or the claude.ai web app. Those go to Anthropic's support / disclosure channels — not here. This project can't fix them and shouldn't be the public record.
|
||||
|
||||
## What to include in a report
|
||||
|
||||
- Reproducer: commands, environment, distro / desktop / session type
|
||||
- Output of `claude-desktop --doctor` if relevant
|
||||
- Affected version(s) — `git describe --tags` or the release tag you installed from
|
||||
- Any related upstream CVEs or advisories you found while investigating
|
||||
|
||||
## Response
|
||||
|
||||
GitHub Advisories notify @aaddrick. Acknowledgement is usually within a few days. Fix turnaround depends on the surface — packaging-layer bugs are usually fast; patches against minified upstream JS may need to wait for a tractable anchor in a future upstream release.
|
||||
|
||||
## Disclosure history
|
||||
|
||||
Past privacy-sensitive fixes (e.g., issue-triage bot scoping, log redaction in `--doctor` output) landed through the normal PR flow with public history; there have been no embargoed disclosures to date. If that changes, this section gets entries with the advisory ID, the affected versions, and the fix.
|
||||
@@ -41,17 +41,6 @@ The build script automatically detects your distribution and selects the appropr
|
||||
| Arch Linux | `.AppImage` (via AUR) | yay/paru |
|
||||
| Other | `.AppImage` | - |
|
||||
|
||||
## Build Environment Variables
|
||||
|
||||
The build pulls the Electron prebuilt binary from `github.com/electron/electron/releases` via `@electron/get`. Two upstream environment variables let you redirect that fetch:
|
||||
|
||||
- `ELECTRON_MIRROR` — base URL to fetch Electron releases from instead of GitHub. Useful for mirrors or local proxies. Example: `ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/`.
|
||||
- `ELECTRON_CUSTOM_DIR` — overrides the path segment after the mirror. Defaults to `v{version}`.
|
||||
|
||||
The cache location is fixed at `~/.cache/electron/` (resolved by `@electron/get` via `envPaths`) and is reused across builds. `ELECTRON_CACHE` is **not** read by `@electron/get` — set `ELECTRON_MIRROR` if you need to avoid the public CDN.
|
||||
|
||||
The pinned Electron version lives in `scripts/setup/dependencies.sh` (`electron_version`) and must match `build-reference/app-extracted/package.json` — the upstream Claude Desktop `app.asar` is built against a specific Electron major and running a different one is unsupported.
|
||||
|
||||
## Installing the Built Package
|
||||
|
||||
### For .deb packages (Debian/Ubuntu)
|
||||
259
docs/TROUBLESHOOTING.md
Normal file
259
docs/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,259 @@
|
||||
[< Back to README](../README.md)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Built-in Diagnostics
|
||||
|
||||
Run the `--doctor` flag to check your system for common issues:
|
||||
|
||||
```bash
|
||||
# Deb install
|
||||
claude-desktop --doctor
|
||||
|
||||
# AppImage
|
||||
./claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
This runs 10 checks and prints pass/fail results with suggested fixes:
|
||||
|
||||
| Check | What it verifies |
|
||||
|-------|-----------------|
|
||||
| Installed version | Package version via dpkg |
|
||||
| Display server | Wayland/X11 detection and mode |
|
||||
| Electron binary | Existence and version |
|
||||
| Chrome sandbox | Correct permissions (4755/root) |
|
||||
| SingletonLock | Stale lock file detection |
|
||||
| MCP config | JSON validity and server count |
|
||||
| Node.js | Version (v20+ recommended for MCP) |
|
||||
| Desktop entry | `.desktop` file presence |
|
||||
| Disk space | Free space on config partition |
|
||||
| Log file | Log file size |
|
||||
|
||||
Example output:
|
||||
```
|
||||
Claude Desktop Diagnostics
|
||||
================================
|
||||
|
||||
[PASS] Installed version: 1.1.4498-1.3.15
|
||||
[PASS] Display server: Wayland (WAYLAND_DISPLAY=wayland-0)
|
||||
[PASS] Electron: found at /usr/lib/claude-desktop/node_modules/electron/dist/electron
|
||||
[PASS] Chrome sandbox: permissions OK
|
||||
[PASS] SingletonLock: no lock file (OK)
|
||||
[PASS] MCP config: valid JSON
|
||||
[PASS] Node.js: v22.14.0
|
||||
[PASS] Desktop entry: /usr/share/applications/claude-desktop.desktop
|
||||
[PASS] Disk space: 632284MB free
|
||||
[PASS] Log file: 1352KB
|
||||
|
||||
All checks passed.
|
||||
```
|
||||
|
||||
When opening an issue, include the output of `--doctor` to help with diagnosis.
|
||||
|
||||
## Application Logs
|
||||
|
||||
Runtime logs are available at:
|
||||
```
|
||||
~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Window Scaling Issues
|
||||
|
||||
If the window doesn't scale correctly on first launch:
|
||||
1. Right-click the Claude Desktop tray icon
|
||||
2. Select "Quit" (do not force quit)
|
||||
3. Restart the application
|
||||
|
||||
This allows the application to save display settings properly.
|
||||
|
||||
### Global Hotkey Not Working (Wayland)
|
||||
|
||||
If the global hotkey (Ctrl+Alt+Space) doesn't work, ensure you're not running in native Wayland mode:
|
||||
|
||||
1. Check your logs at `~/.cache/claude-desktop-debian/launcher.log`
|
||||
2. Look for "Using X11 backend via XWayland" - this means hotkeys should work
|
||||
3. If you see "Using native Wayland backend", unset `CLAUDE_USE_WAYLAND` or ensure it's not set to `1`
|
||||
|
||||
**Note:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal.
|
||||
|
||||
See [CONFIGURATION.md](CONFIGURATION.md) for more details on the `CLAUDE_USE_WAYLAND` environment variable.
|
||||
|
||||
### AppImage Sandbox Warning
|
||||
|
||||
AppImages run with `--no-sandbox` due to electron's chrome-sandbox requiring root privileges for unprivileged namespace creation. This is a known limitation of AppImage format with Electron applications.
|
||||
|
||||
For enhanced security, consider:
|
||||
- Using the .deb package instead
|
||||
- Running the AppImage within a separate sandbox (e.g., bubblewrap)
|
||||
- Using Gear Lever's integrated AppImage management for better isolation
|
||||
|
||||
### Cowork on Ubuntu 24.04+ (AppArmor Blocks User Namespaces)
|
||||
|
||||
Ubuntu 24.04 ships with `apparmor_restrict_unprivileged_userns=1`
|
||||
by default, which blocks the unprivileged user namespaces that
|
||||
Cowork's bubblewrap sandbox relies on. Symptoms:
|
||||
|
||||
- `claude-desktop --doctor` reports `bubblewrap: sandbox probe failed`
|
||||
with `Operation not permitted` in stderr.
|
||||
- `~/.config/Claude/logs/cowork_vm_daemon.log` contains
|
||||
`bwrap is installed but cannot create a user namespace`.
|
||||
- Cowork sessions hang at "Starting VM..." or loop on reconnect.
|
||||
|
||||
Permit user namespaces for `bwrap` via an AppArmor profile (one-time
|
||||
setup, requires sudo):
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/bwrap <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile bwrap /usr/bin/bwrap flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/bwrap>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/bwrap
|
||||
```
|
||||
|
||||
After applying the profile, run `claude-desktop --doctor` — the
|
||||
bubblewrap probe should pass, and Cowork should start without
|
||||
falling back to host-direct.
|
||||
|
||||
**Security note:** this grants `/usr/bin/bwrap` the unconfined
|
||||
profile plus the `userns` capability. It matches the behavior
|
||||
bwrap had on Ubuntu 22.04 and earlier, and on most other distros,
|
||||
but is a system-wide change that affects every program invoking
|
||||
`/usr/bin/bwrap` (not just Claude Desktop). Review the profile
|
||||
against your threat model before applying.
|
||||
|
||||
Credit: this workaround was contributed by
|
||||
[@hfyeh](https://github.com/hfyeh) in
|
||||
[#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
|
||||
|
||||
### Cowork: "VM connection timeout after 60 seconds"
|
||||
|
||||
If Cowork fails with a VM timeout, the KVM backend is selected but the guest VM cannot connect back to the host via vsock within the timeout window. Common causes:
|
||||
|
||||
1. **First-boot initialization** — the guest VM may take longer than 60 seconds on first launch
|
||||
2. **vsock driver issues** — the host may be missing the `vhost_vsock` module (`sudo modprobe vhost_vsock`), or the guest initrd may lack `vmw_vsock_virtio_transport`
|
||||
|
||||
**Fix:** Force the bubblewrap backend, which provides namespace-level isolation without a VM:
|
||||
|
||||
```bash
|
||||
COWORK_VM_BACKEND=bwrap claude-desktop
|
||||
```
|
||||
|
||||
See [CONFIGURATION.md](CONFIGURATION.md#cowork-backend) for how to make this permanent.
|
||||
|
||||
### Cowork: virtiofsd not found (Fedora/RHEL)
|
||||
|
||||
On Fedora and RHEL, `virtiofsd` installs to `/usr/libexec/virtiofsd` which is
|
||||
outside `$PATH`. The `--doctor` check detects it there automatically and will
|
||||
show `[PASS]`, but the KVM backend spawns `virtiofsd` by name at runtime and
|
||||
resolves it through `$PATH` only.
|
||||
|
||||
**Fix:** Create a symlink so the KVM backend can find it at runtime:
|
||||
|
||||
```bash
|
||||
sudo ln -s /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
|
||||
```
|
||||
|
||||
On Debian/Ubuntu, the same issue can occur with `/usr/lib/qemu/virtiofsd`.
|
||||
|
||||
### Cowork: cross-device link error on Fedora tmpfs /tmp
|
||||
|
||||
On Fedora, `/tmp` is a tmpfs by default. VM bundle downloads may fail with `EXDEV: cross-device link not permitted` when moving files from `/tmp` to `~/.config/Claude/`.
|
||||
|
||||
**Fix:** Set `TMPDIR` to a directory on the same filesystem:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/Claude/tmp
|
||||
TMPDIR=~/.config/Claude/tmp claude-desktop
|
||||
```
|
||||
|
||||
Or add `TMPDIR=%h/.config/Claude/tmp` to the `Exec=` line in your `.desktop` file.
|
||||
|
||||
### Authentication Errors (401)
|
||||
|
||||
If you encounter recurring "API Error: 401" messages after periods of inactivity, the cached OAuth token may need to be cleared. This is an upstream application issue reported in [#156](https://github.com/aaddrick/claude-desktop-debian/issues/156).
|
||||
|
||||
To fix manually (credit: [MrEdwards007](https://github.com/MrEdwards007)):
|
||||
|
||||
1. Close Claude Desktop completely
|
||||
2. Edit `~/.config/Claude/config.json`
|
||||
3. Remove the line containing `"oauth:tokenCache"` (and any trailing comma if needed)
|
||||
4. Save the file and restart Claude Desktop
|
||||
5. Log in again when prompted
|
||||
|
||||
A scripted solution is also available at the bottom of [this comment](https://github.com/aaddrick/claude-desktop-debian/issues/156#issuecomment-2682547498).
|
||||
|
||||
## Uninstallation
|
||||
|
||||
### For APT repository installations (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
|
||||
# Remove the repository and GPG key
|
||||
sudo rm /etc/apt/sources.list.d/claude-desktop.list
|
||||
sudo rm /usr/share/keyrings/claude-desktop.gpg
|
||||
```
|
||||
|
||||
### For DNF repository installations (Fedora/RHEL)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
|
||||
# Remove the repository
|
||||
sudo rm /etc/yum.repos.d/claude-desktop.repo
|
||||
```
|
||||
|
||||
### For AUR installations (Arch Linux)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -R claude-desktop-appimage
|
||||
|
||||
# Or using paru
|
||||
paru -R claude-desktop-appimage
|
||||
|
||||
# Or using pacman directly
|
||||
sudo pacman -R claude-desktop-appimage
|
||||
```
|
||||
|
||||
### For .deb packages (manual install)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
# Or: sudo dpkg -r claude-desktop
|
||||
|
||||
# Remove package and configuration
|
||||
sudo dpkg -P claude-desktop
|
||||
```
|
||||
|
||||
### For .rpm packages
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
# Or: sudo rpm -e claude-desktop
|
||||
```
|
||||
|
||||
### For AppImages
|
||||
|
||||
1. Delete the `.AppImage` file
|
||||
2. Remove the `.desktop` file from `~/.local/share/applications/`
|
||||
3. If using Gear Lever, use its uninstall option
|
||||
|
||||
### Remove user configuration (all formats)
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/Claude
|
||||
```
|
||||
@@ -1,65 +0,0 @@
|
||||
# Documentation
|
||||
|
||||
Linux packaging, patching, and operations docs for the [Claude Desktop for Debian](../README.md) project. The README is the storefront; this is the manual.
|
||||
|
||||
```bash
|
||||
# If you're here because something broke:
|
||||
claude-desktop --doctor
|
||||
# Then check troubleshooting.md below.
|
||||
```
|
||||
|
||||
## Installation & building
|
||||
|
||||
- [**Building from source**](building.md) — `./build.sh`, format flags, the Electron mirror env vars
|
||||
- [**Configuration**](configuration.md) — MCP config file locations, env vars, where state lives
|
||||
- [**Troubleshooting**](troubleshooting.md) — symptom-keyed fixes, `--doctor` warning index
|
||||
|
||||
## Project direction
|
||||
|
||||
- [**Decision log**](decisions.md) — ADR-format record of what we ship and (more importantly) what we won't
|
||||
- [**Releasing**](../RELEASING.md) — pre-release checklist, tag recipe, what CI does on tag push
|
||||
- [**Changelog**](../CHANGELOG.md) — `v2.0.0` onward, grouped by REPO_VERSION
|
||||
|
||||
## How the patches work — subsystem deep-dives
|
||||
|
||||
Hard-won knowledge from debugging real bugs. Consult before working on the related subsystem; add a new entry when you discover something non-obvious that would save the next contributor (human or AI) significant time.
|
||||
|
||||
- [**Patching minified JavaScript**](learnings/patching-minified-js.md) — anchor selection, the `\w` vs `$` capture trap, beautified false-negatives, idempotency guards
|
||||
- [**APT/DNF Worker architecture**](learnings/apt-worker-architecture.md) — Cloudflare Worker + GitHub Releases redirect chain, credential ownership, heartbeat runbook
|
||||
- [**Nix packaging**](learnings/nix.md) — NixOS specifics, Electron resource path resolution, testing without NixOS
|
||||
- [**Linux topbar shim**](learnings/linux-topbar-shim.md) — why the in-app topbar is missing on Linux and the four gates that hide it
|
||||
- [**Tray rebuild race**](learnings/tray-rebuild-race.md) — KDE SNI re-registration race; the in-place `setImage`/`setContextMenu` fast path
|
||||
- [**Plugin install flow**](learnings/plugin-install.md) — Anthropic & Partners plugin gate logic and DevTools recipes
|
||||
- [**Cowork VM daemon**](learnings/cowork-vm-daemon.md) — lifecycle, respawn logic, crash diagnosis
|
||||
- [**MCP double-spawn**](learnings/mcp-double-spawn.md) — why stdio MCPs spawn twice with chat + Code/Agent panels open
|
||||
- [**Test harness — Electron hooks**](learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps get bypassed by the frame-fix Proxy
|
||||
- [**Test harness — AX-tree walker**](learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker
|
||||
|
||||
## Testing
|
||||
|
||||
- [**Testing overview**](testing/README.md) — what we test and how it's organized
|
||||
- [**Test runbook**](testing/runbook.md) — running tests locally
|
||||
- [**Test matrix**](testing/matrix.md) — what runs on what distro / format
|
||||
- [**Test automation**](testing/automation.md) — CI workflow shape
|
||||
- [**Quick-entry closeout**](testing/quick-entry-closeout.md) — the Quick Entry test runner
|
||||
|
||||
## Operations
|
||||
|
||||
- [**Issue triage bot**](issue-triage/README.md) — how the GitHub Actions issue-triage workflow works
|
||||
- [**Upstream bug reports**](upstream-reports/) — bugs we've filed against the upstream Electron app
|
||||
|
||||
## Style guides
|
||||
|
||||
- [**Bash style guide**](styleguides/bash_styleguide.md) — the project's shell-script conventions (forked from YSAP)
|
||||
- [**Docs style guide**](styleguides/docs_styleguide.md) — how to write and organize docs (start here if you're adding a page)
|
||||
|
||||
## Contributing
|
||||
|
||||
- [**CONTRIBUTING.md**](../CONTRIBUTING.md) — what we accept, what goes upstream, AI-attribution policy
|
||||
- [**CLAUDE.md**](../CLAUDE.md) — instructions for AI coding assistants (and a useful project archaeology read for humans)
|
||||
- [**AGENTS.md**](../AGENTS.md) — vendor-neutral mirror of `CLAUDE.md` for non-Claude AI tools
|
||||
- [**SECURITY.md**](../SECURITY.md) — private vulnerability reporting
|
||||
|
||||
## Cowork-Linux handover (historical)
|
||||
|
||||
- [**Cowork-Linux handover**](cowork-linux-handover.md) — record of the original cowork Linux work, kept for the historical context. Day-to-day cowork docs live in [`learnings/cowork-vm-daemon.md`](learnings/cowork-vm-daemon.md).
|
||||
@@ -273,7 +273,7 @@ unusable on Linux today.
|
||||
mode. Shim runtime behavior (className intercept, UA spoof) is
|
||||
not unit-tested — verified empirically via the click test in
|
||||
this doc
|
||||
- `docs/configuration.md` — user-facing env-var docs
|
||||
- `docs/CONFIGURATION.md` — user-facing env-var docs
|
||||
|
||||
## Diagnostic recipes
|
||||
|
||||
|
||||
@@ -37,67 +37,46 @@ external services with single-connection contracts, etc.
|
||||
|
||||
## Root Cause (Upstream)
|
||||
|
||||
Multiple session managers live inside Electron main, each
|
||||
holding its own MCP coordinator state with its own registry. The
|
||||
two that spawn stdio MCPs from `claude_desktop_config.json` and
|
||||
trigger this bug:
|
||||
Two parallel session managers live inside Electron main, each
|
||||
holding an independent Claude Agent SDK `query`:
|
||||
|
||||
| Manager class | IPC namespace | Coordinator | Logs prefix |
|
||||
|--------------------------|------------------------------------------|-----------------|-------------|
|
||||
| `LocalSessions` | `claude.web_$_LocalSessions_$_*` | `n2t("ccd")` | `[CCD]` |
|
||||
| `LocalAgentModeSessions` | `claude.web_$_LocalAgentModeSessions_$_*`| `n2t("cowork")` | `[LAM]` |
|
||||
|
||||
A third coordinator class — `SshMcpServerManager` — follows the
|
||||
same per-coordinator-registry pattern but uses an SSH transport
|
||||
and doesn't contribute to the local-node double-spawn. Its
|
||||
existence does say something about the design intent: per-
|
||||
coordinator isolated state appears to be a deliberate
|
||||
architectural pattern, not a one-off oversight.
|
||||
|
||||
The logs prefixes are what to grep `~/.config/Claude/logs/` for to
|
||||
confirm a session is hitting both coordinators (and therefore this
|
||||
bug specifically).
|
||||
|
||||
Each coordinator dedups **within its own scope**: CCD's launch
|
||||
function serializes per server name through a promise queue and
|
||||
shuts down any prior entry before respawn; LAM's
|
||||
`getOrCreateConnection` reuses connected entries from its own
|
||||
`connections` Map. The double-spawn is strictly **cross-
|
||||
coordinator** — one process per coordinator that has the server
|
||||
in its config.
|
||||
|
||||
In current versions (verified against `1.5354.0`) both
|
||||
coordinators route their transport creation through a shared
|
||||
Claude Desktop-side factory, but the factory itself doesn't
|
||||
dedupe and the per-coordinator registries above it aren't
|
||||
unified.
|
||||
Each `query` holds its own SDK transport. The transport's
|
||||
`spawnLocalProcess` (`Du.spawn`) launches stdio MCPs **without
|
||||
consulting the global registry** that *would* dedupe them
|
||||
(`hZ` map, accessed via `oUt(serverName)` /
|
||||
`launchMcpServer`). That registry is only used for the
|
||||
"internal" cowork in-process MessageChannelMain path.
|
||||
|
||||
Net result: 2 coordinators × N configured MCPs = 2N processes.
|
||||
|
||||
### Symbol drift
|
||||
|
||||
Minified symbols rename across upstream releases. Issue
|
||||
[#546](https://github.com/aaddrick/claude-desktop-debian/issues/546)
|
||||
maintains the current symbol mappings (verified against
|
||||
`1.5354.0`) plus extraction regexes that work against both
|
||||
minified and beautified bundles.
|
||||
Symbol names (`n2t`, `hZ`, `oUt`, `LocalSessions`,
|
||||
`LocalAgentModeSessions`) are minified and **will rename across
|
||||
upstream releases**.
|
||||
|
||||
## Status
|
||||
|
||||
**Upstream Claude Desktop bug. Not patchable in this repo.** The
|
||||
proximate cause is in Claude Desktop's session manager wiring. A
|
||||
real fix needs either:
|
||||
**Upstream Claude Desktop bug. Not patchable in this repo.** A
|
||||
fix would require either:
|
||||
|
||||
- LAM proxying its MCP traffic through CCD's existing connection
|
||||
(so only one coordinator owns the spawn), or
|
||||
- A multiplexing wrapper transport that lets one spawned stdio
|
||||
child serve multiple SDK clients via demuxing.
|
||||
- Routing the SDK stdio transport through `oUt`/`hZ` (the
|
||||
existing serialized-per-name registry), or
|
||||
- Sharing one MCP-server registry between the `ccd` and
|
||||
`cowork` coordinators.
|
||||
|
||||
Stdio MCP is 1:1 at the protocol layer — one stdin/stdout pair,
|
||||
one transport, one SDK client. Sharing one process across
|
||||
coordinators requires real engineering, not a sed patch on
|
||||
minified code, and exceeds this repo's "minimal Linux-compat
|
||||
patches only" charter.
|
||||
Both live inside the closed-source SDK transport / session
|
||||
manager wiring. Regex-matching the minified symbols from
|
||||
`scripts/patches/` would be fragile against release-to-release
|
||||
renames and exceeds this repo's "minimal Linux-compat patches
|
||||
only" charter.
|
||||
|
||||
## What's Already Verified Clean
|
||||
|
||||
@@ -139,15 +118,13 @@ The reporter's `baro-voyager` MCP shipped both in commit
|
||||
|
||||
- **Primary:** in-app feedback (Help → Send Feedback) or
|
||||
`support@anthropic.com`. The duplication happens in
|
||||
closed-source Desktop main, in the per-coordinator registry
|
||||
wiring.
|
||||
- **Secondary:** an issue on
|
||||
closed-source Desktop main.
|
||||
- **Secondary:** an SDK-transport-flavored issue on
|
||||
[`anthropics/claude-agent-sdk-typescript`](https://github.com/anthropics/claude-agent-sdk-typescript)
|
||||
is defensible only if it advocates for a shared-transport /
|
||||
multiplex primitive that would make this kind of bug
|
||||
structurally harder. The SDK's spawn implementation is doing
|
||||
what it's told — the bug is one layer up, in Claude Desktop
|
||||
calling spawn from two separate coordinators.
|
||||
is defensible — the spawn path goes through the **Claude Agent
|
||||
SDK's** `query` transport (`spawnLocalProcess` / `Du.spawn`),
|
||||
which is shared surface area. Reference the missing `hZ`
|
||||
consultation explicitly.
|
||||
|
||||
The embedded Claude Code CLI subprocess inside Claude Desktop is
|
||||
**not** the cause — it receives `--mcp-config` only when the
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
# Patching minified JavaScript
|
||||
|
||||
Hard-won lessons from maintaining a long-lived patch suite against an
|
||||
actively re-minified upstream. Each section names a failure mode and
|
||||
the fix.
|
||||
|
||||
The verification recipes below use claude-desktop-debian-specific
|
||||
incantations (Claude-Setup.exe, nupkg extraction, `build.sh
|
||||
--build appimage`); substitute your own project's fetch/extract/build
|
||||
commands as needed.
|
||||
|
||||
## Capturing identifiers: `\w` doesn't match `$`
|
||||
|
||||
JS identifiers allow `$` and `_`; minifiers freely emit names like
|
||||
`$e`, `C$i`, `g$x`. The character class `\w` is `[A-Za-z0-9_]` — it
|
||||
does not match `$`. A `(\w+)` against `$e` captures the suffix `e`
|
||||
and returns a name that doesn't exist in the file. The failure is
|
||||
silent: regex matches, downstream sed runs against a truncated name,
|
||||
asar ships broken JS. Three recurrences (PRs #253, #421, #555) before
|
||||
the convention stuck.
|
||||
|
||||
Use `[$\w]+` (repo convention; `[\w$]+` is equivalent). Strict
|
||||
superset of `\w+`, so pre-`$` versions still match. Live at
|
||||
`cowork.sh:484-502`:
|
||||
|
||||
```bash
|
||||
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
|
||||
```
|
||||
|
||||
## The beautified false-negative trap
|
||||
|
||||
Testing a regex against `build-reference/` is not verification. The
|
||||
beautified copy has whitespace the regex doesn't account for.
|
||||
|
||||
During PR #555, both `\w+` and `[\w$]+` tested false against the
|
||||
beautified file. Shipped minified bytes:
|
||||
|
||||
```js
|
||||
await new Promise(n=>setTimeout(n,g$x))
|
||||
```
|
||||
|
||||
Beautified copy:
|
||||
|
||||
```js
|
||||
await new Promise((n) => setTimeout(n, g$x))
|
||||
```
|
||||
|
||||
`await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)` fails
|
||||
the beautified version on the parens and spaces around `=>`. Always
|
||||
close the loop against shipped bytes.
|
||||
|
||||
## Whitespace tolerance: `\s*` vs `[ \t]*`
|
||||
|
||||
`\s` matches newlines. A `\s*`-padded pattern is a license to span
|
||||
across structural boundaries the original line layout meant to
|
||||
keep apart — usually fine on minified bytes (no newlines to span),
|
||||
much looser on beautified.
|
||||
|
||||
Use `[ \t]*` when the intent is "spaces but stay on this line."
|
||||
Reserve `\s*` for crossing structural boundaries on purpose. The
|
||||
existing `cowork.sh` patches mix both — `\s*` where the surrounding
|
||||
context is bounded enough that newline-spanning is harmless, and
|
||||
literal token sequences (`",b:` etc.) when stricter adjacency is
|
||||
required.
|
||||
|
||||
## Replacement-string escaping: `\1`, `&`, `$1`
|
||||
|
||||
A regex can match correctly and still produce corrupted output
|
||||
because the *replacement string* has its own metacharacters. Match
|
||||
debugging shows green; the asar still ships broken bytes. Three
|
||||
flavors:
|
||||
|
||||
**sed `&`** — the entire match. `sed 's/foo/&_suffix/'` is fine
|
||||
(`foo_suffix`). `sed 's/foo/literal_&_dollar/'` accidentally
|
||||
interpolates the match (`literal_foo_dollar`). Escape with `\&` if
|
||||
you want a literal ampersand:
|
||||
|
||||
```bash
|
||||
sed 's/foo/literal_\&_dollar/' # → literal_&_dollar
|
||||
```
|
||||
|
||||
**sed `\1`** — backreferences in the replacement. These work as
|
||||
expected in BRE/ERE. The footgun is the *pattern* side: in BRE, `$`
|
||||
is the end-of-line anchor, so a literal `$` in the search pattern
|
||||
needs `\$`. `_common.sh:25` does exactly this for `electron_var`,
|
||||
which can be `$e` on newer upstream:
|
||||
|
||||
```bash
|
||||
electron_var_re="${electron_var//\$/\\$}"
|
||||
```
|
||||
|
||||
That escaping is for the sed *pattern*, not its replacement.
|
||||
|
||||
**JS `String.prototype.replace`: `$1`, `$&`, `$$`** — the JS
|
||||
replacement DSL is its own thing. `$&` is the whole match; `$1..$9`
|
||||
are capture groups; `$$` is a literal `$`. Plain `$` followed by an
|
||||
unrelated char is left alone, but `$&` and `$N` get interpolated:
|
||||
|
||||
```js
|
||||
code.replace(/foo/g, '$cost') // → '$cost' (safe, no special)
|
||||
code.replace(/foo/g, '$&_x') // → 'foo_x' ($& = match)
|
||||
code.replace(/foo/g, '$$cost') // → '$cost' (escaped)
|
||||
```
|
||||
|
||||
If the replacement is an injected JS snippet that happens to
|
||||
contain `$1` or `$&` (template literals, jQuery, regex source), JS
|
||||
will eat them. Use `$$` to escape, or build the string with
|
||||
concatenation so `$` never sits next to a digit or `&`.
|
||||
|
||||
## Idempotency: a re-run must be byte-identical
|
||||
|
||||
Without it, CI re-runs and partial builds layer mutations until
|
||||
something breaks visibly. Three patterns:
|
||||
|
||||
**Re-key the guard to post-rename names.** `tray.sh:174-180` keys its
|
||||
fast-path guard on the post-rename
|
||||
`${tray_var}.setImage(${electron_var}.nativeImage.createFromPath(${path_var}))`
|
||||
sequence, so the second run recognizes its own first-run output.
|
||||
|
||||
**Negative lookbehind, inline.** `cowork.sh:102-106` — the
|
||||
`(?<!...)` prevents a second match against text the first run
|
||||
already wrapped:
|
||||
|
||||
```js
|
||||
const logRe = new RegExp(
|
||||
'(?<!\\|\\|process\\.platform==="linux"\\))' +
|
||||
win32Var.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
||||
'(\\s*\\?\\s*"vmClient \\(TypeScript\\)")'
|
||||
);
|
||||
```
|
||||
|
||||
**Explicit `code.includes(...)` check.** `cowork.sh:227-230`
|
||||
separates "anchor missing" from "already applied" in the build log:
|
||||
|
||||
```js
|
||||
} else if (code.includes(
|
||||
'getDownloadStatus(){return process.platform==="linux"?'
|
||||
)) {
|
||||
console.log(' Cowork auto-nav suppression already applied');
|
||||
}
|
||||
```
|
||||
|
||||
PR #436 verified by running the patch twice and diffing the output.
|
||||
|
||||
## Anchor selection: prefer literals over identifiers
|
||||
|
||||
The above sections cover making a patch work on first run. This one
|
||||
covers keeping it working release after release. A patch can apply
|
||||
cleanly today and silently no-op next month.
|
||||
|
||||
Minified identifiers churn every release. Developer strings —
|
||||
property names, log messages, IPC channel names — survive
|
||||
minification untouched (true for the upstream bundler used here; a
|
||||
`--mangle-props` build would invalidate property-name anchors).
|
||||
Anchor on those. A hardcoded minified name silently no-ops the next
|
||||
release; the build log still says "patched."
|
||||
|
||||
Three patterns from the suite:
|
||||
|
||||
- **Quick-window (PR #390, fixing #144).** Original patch:
|
||||
`s/e.hide()/e.blur(),e.hide()/`. When `e` became `Sa`, it no-oped.
|
||||
The rewrite anchors on `"pop-up-menu"` (`quick-window.sh:17`), the
|
||||
`isWindowFocused` property name (`quick-window.sh:60`), and the
|
||||
`[QuickEntry]` log strings (`quick-window.sh:88-91`).
|
||||
- **Cowork spawn (PR #436).** Anchored on `,VAR.mountConda)`
|
||||
(`cowork.sh:741`) — unique to the 12-arg call path, absent from the
|
||||
10-arg one-shot. Asserts match count is exactly 1 and bails
|
||||
otherwise (`cowork.sh:744`), so a future second caller surfaces
|
||||
immediately.
|
||||
- **Tray (PR #515).** `tray.sh:16` uses the literal `"menuBarEnabled"`
|
||||
as a *position anchor*, then captures the surrounding minified
|
||||
identifier (`\K\w+(?=\(\)\})`) as the actual patch target. Two
|
||||
stages: stable literal → derived identifier. Every other tray name
|
||||
chains off that single dynamic extraction.
|
||||
|
||||
The lesson is about finding stable points to anchor on, not about
|
||||
what gets patched. The patch target is usually a minified identifier;
|
||||
the *anchor* should be a developer string nearby.
|
||||
|
||||
## Multi-site coordinated patches: surface partial application
|
||||
|
||||
Site 1 patches, site 2 misses, the asar ships half-wired. The
|
||||
pattern: each sub-patch sets a per-site boolean flag on success,
|
||||
then a single named WARNING fires if any flag is false:
|
||||
|
||||
```js
|
||||
if (!siteADone || !siteBDone) {
|
||||
console.log(' WARNING: <ticket> partial — siteA=' + siteADone +
|
||||
' siteB=' + siteBDone + '; <fallback consequence>');
|
||||
}
|
||||
```
|
||||
|
||||
CI greps the build log for `WARNING:` and fails the build. That
|
||||
catches the half-patched state even when individual sub-patches each
|
||||
log "applied." See `cowork.sh:759-763` for a real instance —
|
||||
three-site `sharedCwdPath` forwarding, daemon fallback if any site
|
||||
misses.
|
||||
|
||||
## Disambiguating non-unique anchors: lastIndexOf over indexOf
|
||||
|
||||
A string anchor can appear in source maps, dead exports, or
|
||||
chunk-merged duplicates alongside the live code. `indexOf` returns
|
||||
the first; that may be wrong.
|
||||
|
||||
`cowork.sh:264` uses `lastIndexOf(serviceErrorStr)` to bias toward
|
||||
appended code. On 1.5354.0 the string occurs once, so the change is
|
||||
a no-op there — the defense is for a future upstream that
|
||||
reintroduces the string in onboarding text or sample data far from
|
||||
the live retry-loop site.
|
||||
|
||||
When neither side is reliable, narrow the search region first.
|
||||
`cowork.sh:269-276` does this for the ENOENT check, scanning only a
|
||||
300-character window before the error string.
|
||||
|
||||
## Verifying a hypothesis before shipping a fix
|
||||
|
||||
Pull the pinned URL and SHA from `scripts/setup/detect-host.sh`,
|
||||
download, verify hash, extract without beautifying, and test the
|
||||
regex against the minified bytes:
|
||||
|
||||
```bash
|
||||
url=$(grep -oP "claude_download_url='\K[^']+" \
|
||||
scripts/setup/detect-host.sh | head -1)
|
||||
expected=$(grep -oP "claude_exe_sha256='\K[^']+" \
|
||||
scripts/setup/detect-host.sh | head -1)
|
||||
mkdir -p /tmp/verify && cd /tmp/verify
|
||||
wget -q -O Claude-Setup.exe "$url"
|
||||
echo "$expected Claude-Setup.exe" | sha256sum -c -
|
||||
|
||||
7z x -y Claude-Setup.exe -o exe
|
||||
nupkg=$(find exe -name 'AnthropicClaude-*.nupkg' | head -1)
|
||||
7z x -y "$nupkg" -o nupkg
|
||||
npx asar extract nupkg/lib/net45/resources/app.asar app
|
||||
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const code = fs.readFileSync(
|
||||
"app/.vite/build/index.js", "utf8");
|
||||
const re = /await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/;
|
||||
const m = code.match(re);
|
||||
console.log(m ? `MATCH: ${m[0]}` : "NO MATCH");
|
||||
'
|
||||
```
|
||||
|
||||
`NO MATCH` means the regex is wrong. Verifying the SHA defends against
|
||||
stale URL pinning or server-side binary swap.
|
||||
|
||||
## End-to-end verification (post-build)
|
||||
|
||||
Four layers: build log, syntactic validity, asar markers, runtime.
|
||||
|
||||
1. Check the patch-count line:
|
||||
|
||||
```bash
|
||||
./build.sh --build appimage --clean no 2>&1 | tee build.log
|
||||
grep -E 'Applied [0-9]+ cowork patches' build.log
|
||||
```
|
||||
|
||||
Healthy 1.5354.0 build: `Applied 12 cowork patches`. A lower
|
||||
number, or any `WARNING:` in the cowork section, is a half-patched
|
||||
asar.
|
||||
|
||||
2. `node --check` on the patched `index.js` — catches malformed
|
||||
replacements that serialize but don't parse (PR #436 used this in
|
||||
dry-run validation):
|
||||
|
||||
```bash
|
||||
node --check test-build/.../app.asar.contents/.vite/build/index.js
|
||||
```
|
||||
|
||||
3. Static-grep the shipped asar for the 9 cowork markers from PR
|
||||
#555. `scripts/verify-patches.sh` automates this (issue #559 D6)
|
||||
and runs in CI on every `amd64-deb` build via the
|
||||
`Verify cowork patches in shipped asar` step in
|
||||
`.github/workflows/build-amd64.yml`. Reusable for non-cowork patch
|
||||
sets — pass any same-shape TSV as the second arg.
|
||||
|
||||
4. Launch the AppImage and check runtime state:
|
||||
|
||||
```bash
|
||||
tail -20 ~/.config/Claude/logs/cowork_vm_daemon.log
|
||||
ls -la "${XDG_RUNTIME_DIR}/cowork-vm-service.sock"
|
||||
ss -lpx | grep cowork-vm-service.sock
|
||||
```
|
||||
|
||||
Daemon log should have `lifecycle startup` and `lifecycle
|
||||
listening`; socket should exist and be owned by the
|
||||
`cowork-vm-service.js` process listed by `ss`.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `tray-rebuild-race.md` "Resilience to minifier churn" — prior art
|
||||
for dynamic extraction across a six-variable patch site and the
|
||||
post-rename idempotency-guard pattern.
|
||||
- `plugin-install.md` "Getting the Minified Source for Any Shipped
|
||||
Version" — the `reference-source.tar.gz` release asset gives
|
||||
beautified asar contents of any prior version for diffing. Useful
|
||||
for spotting when an identifier renamed and which version did it.
|
||||
@@ -1,144 +0,0 @@
|
||||
[< Back to docs index](../index.md)
|
||||
|
||||
# Docs Style Guide
|
||||
|
||||
How docs are organized and written in this repo. The patterns here come from a survey of well-organized open-source docs (Spatie, Filament, laravel-docs, earendil-works/pi) plus what's worked in this project's own `docs/` tree. If you're adding a page, read the **Page anatomy** section before you start.
|
||||
|
||||
## Structure
|
||||
|
||||
- **Flat `docs/`**, **lowercase kebab-case** filenames (`troubleshooting.md`, not `TROUBLESHOOTING.md`; `building.md`, not `BUILDING.md`). Order belongs in this index, not filenames.
|
||||
- One entry point: **[`docs/index.md`](../index.md)**. It's the GitHub-browsable landing page and the link target from every other doc.
|
||||
- **Subdirectories only when a topic grows past ~5 pages.** Current subdirs:
|
||||
- [`docs/learnings/`](../learnings/) — subsystem deep-dives. Promoted out of the top level once there were >3.
|
||||
- [`docs/testing/`](../testing/) — test harness docs.
|
||||
- [`docs/issue-triage/`](../issue-triage/) — the issue-triage bot config and prompts.
|
||||
- [`docs/upstream-reports/`](../upstream-reports/) — bug reports filed against upstream that we keep alongside the patch.
|
||||
- `docs/styleguides/` — meta-docs about how to write docs and shell scripts.
|
||||
- **`docs/images/`** for screenshots and diagrams. Never scatter `.png`s next to `.md`s.
|
||||
- **Repo-root auxiliary files stay at the root** so GitHub auto-detects them: `README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, `LICENSE-*`, `RELEASING.md`, `CLAUDE.md`, `AGENTS.md`. Don't move them under `docs/`.
|
||||
|
||||
## Page anatomy
|
||||
|
||||
Three skeletons recur across well-organized docs in this project. Pick one before starting a page.
|
||||
|
||||
### Setup / how-to page
|
||||
|
||||
Used for: `building.md`, `configuration.md`, `releasing.md` (in the root).
|
||||
|
||||
```
|
||||
<one declarative sentence: what this page is for>
|
||||
<one code block showing the minimum working command>
|
||||
## Prerequisites -> short list; assume Linux + git unless stated
|
||||
## <Step 1> -> one short paragraph + code block
|
||||
## <Step 2>
|
||||
## Common variations -> distro-specific or flag-specific quirks
|
||||
## Troubleshooting -> link out to troubleshooting.md, don't duplicate
|
||||
```
|
||||
|
||||
Open with the minimum command, not the prerequisites table. Readers skim to the code block first.
|
||||
|
||||
### Troubleshooting / FAQ page
|
||||
|
||||
Used for: `troubleshooting.md`.
|
||||
|
||||
```
|
||||
<one declarative sentence: what kind of problem this page solves>
|
||||
## <Symptom or error message verbatim> -> one ### Fix per symptom, with a code block
|
||||
## <Next symptom>
|
||||
```
|
||||
|
||||
The headings are **the symptom users type into search.** Don't editorialize ("Troubles with Wayland" is wrong — `## Black screen on Fedora KDE under Wayland` is right). One `### Fix` per `##`. If a symptom needs explanation, prose goes under the fix, not in the heading.
|
||||
|
||||
### Subsystem deep-dive (a "learning")
|
||||
|
||||
Used for: everything in `docs/learnings/`.
|
||||
|
||||
```
|
||||
<one paragraph: what subsystem this covers, when it runs, why it's non-obvious>
|
||||
**Source files:** bullet list of GitHub links to the relevant source
|
||||
## Overview -> 2–3 paragraphs of context
|
||||
## <Mechanic> -> for each non-trivial mechanic, prose + diagram only when state transitions need one
|
||||
## <Failure mode> -> for each known failure, repro + diagnosis + fix path
|
||||
## References -> issues, PRs, upstream bugs, useful commits
|
||||
```
|
||||
|
||||
Deep-dives can be long — `apt-worker-architecture.md` and `patching-minified-js.md` are >10 kB and that's fine. They serve repeat readers (future you, future contributors) hunting for a specific fact, not first-timers.
|
||||
|
||||
### Decision record (ADR)
|
||||
|
||||
Used for: entries in `docs/decisions.md`.
|
||||
|
||||
```
|
||||
## D-NNN — <short title>
|
||||
- **Status:** Accepted / Superseded / Proposed
|
||||
- **Decided:** YYYY-MM-DD
|
||||
- **Owner:** @handle
|
||||
- **Stakeholders:** ...
|
||||
### Context -> what triggered the decision
|
||||
### Decision -> the call in one or two sentences
|
||||
### Rationale -> bullets
|
||||
### Consequences -> what was accepted, what's now out of bounds
|
||||
### Alternatives Considered
|
||||
### References
|
||||
```
|
||||
|
||||
See [`decisions.md`](../decisions.md) for the live record. Don't delete superseded decisions — mark them and link forward.
|
||||
|
||||
## Content rules
|
||||
|
||||
1. **Open every page with one declarative sentence, then a code block or list.** No "In this guide we will explore…" preamble. If the page is in the root (not behind `[< Back to ...]`), the first line under the H1 is that sentence.
|
||||
2. **Imperative, second-person, present tense.** "Run the build." Not "users may wish to consider running the build."
|
||||
3. **Domain nouns.** This is a packaging project — use `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`. Don't say `foo`/`bar` in end-to-end recipes. Placeholders are tolerable in basic-usage; in walkthroughs they kill comprehension.
|
||||
4. **Real PR / issue / commit references over hand-waving.** "Fixed in [#475](https://github.com/aaddrick/claude-desktop-debian/pull/475)" beats "fixed in a recent PR." `git log --grep` works on links; not on adjectives.
|
||||
5. **Defaults first, then the override.** "The build auto-detects your distro. To force a format, pass `--build appimage`."
|
||||
6. **Warnings in alert blocks**, not paragraphs: `> [!NOTE]`, `> [!WARNING]`, `> [!TIP]`. GitHub renders them; reading them isn't optional.
|
||||
7. **Source-file blocks on deep-dives.** Bulleted GitHub links to the actual files. Don't bury source references in prose.
|
||||
8. **Cross-link liberally.** Every page should link to 2–4 others. `docs/index.md` should link to every page in `docs/`.
|
||||
9. **One file per topic.** Don't paste the same config block into three pages. Show it once in `configuration.md`; excerpt subsections elsewhere with a link back.
|
||||
10. **Rationale lives in `decisions.md` or a learning**, not sprinkled through feature docs. If you find yourself writing "we did this because…" in a how-to page, that paragraph belongs in `learnings/<topic>.md` or `decisions.md`.
|
||||
|
||||
## Patterns worth stealing
|
||||
|
||||
- **Comparison tables for near-synonyms.** When something has overlapping siblings (deb vs. rpm vs. AppImage vs. nix; Wayland vs. XWayland; SUID sandbox vs. user namespaces), a `| feature | A | B | C |` table beats three prose paragraphs.
|
||||
- **"Source files" block at the top of deep-dives.** See [`docs/learnings/apt-worker-architecture.md`](../learnings/apt-worker-architecture.md) for the canonical example.
|
||||
- **`[< Back to <parent>]` link at the top of subpages.** GitHub doesn't render breadcrumbs; this is the manual equivalent. Use it on pages inside subdirectories.
|
||||
- **Verbatim error messages as `##` headings in `troubleshooting.md`.** Users land via search; search hits the heading.
|
||||
|
||||
## Antipatterns
|
||||
|
||||
- **Duplicating quickstart in three places.** README is pitch + install one-liner + link to docs. Real install lives in `building.md`, and only there.
|
||||
- **`docs/` without an `index.md`.** GitHub renders an alphabetical file list and contributors get lost.
|
||||
- **Uppercase / SHOUTY filenames** (`TROUBLESHOOTING.md`). Hard to type, looks dated, inconsistent with `docs/learnings/*.md`. Lowercase kebab-case throughout.
|
||||
- **Numbered prefixes** (`01-introduction.md`). Order belongs in `index.md`. Renumbering rots cross-links.
|
||||
- **Free-form FAQ prose** ("Q: How do I…? A: Well, you might…"). Use `## <error message>` → `### Fix` → code instead. Search ranks headings, not paragraphs.
|
||||
- **One page past ~30 kB that isn't a reference/deep-dive.** Promote to a subdirectory or split. CLAUDE.md is the exception — it's an archaeology document, not a how-to.
|
||||
- **Inline "this changed in v2.0.7" annotations** scattered through current docs. Version notes belong in `CHANGELOG.md`.
|
||||
- **Code blocks without a "when to use this" sentence above them.** Turns docs into a man-page dump.
|
||||
- **Hiding `CONTRIBUTING.md` or `SECURITY.md` under `docs/`.** GitHub stops auto-detecting them.
|
||||
|
||||
## Page-size honesty
|
||||
|
||||
Length should track topic depth, not editorial consistency.
|
||||
|
||||
| Size | When |
|
||||
|---|---|
|
||||
| <500 B | Single config snippet + 2 sentences. Stub pages and redirects. |
|
||||
| 1.5–3 kB | Platform notes, single-flag install variants |
|
||||
| 3–8 kB | Standard how-to and setup pages |
|
||||
| 10–17 kB | Major how-to pages, learnings |
|
||||
| 17–25 kB | Deep-dive learnings with diagrams |
|
||||
| >30 kB | Smell. Either it's a reference page (rare in this repo), or it should split. |
|
||||
|
||||
Pages can be five sentences. **Don't pad short topics.**
|
||||
|
||||
## What stays in README vs. moves into `docs/`
|
||||
|
||||
| In README | In `docs/` |
|
||||
|---|---|
|
||||
| Elevator pitch (1–3 sentences) | Full prose docs |
|
||||
| Installation one-liners per package format | Complete build / configuration walkthroughs |
|
||||
| Link to `docs/index.md` | Everything else |
|
||||
| Acknowledgments (contributor credits) | — |
|
||||
| License + sponsor links | — |
|
||||
|
||||
The README is the project's storefront. `docs/` is the manual. Once a topic exists in `docs/`, the README links out — don't duplicate.
|
||||
@@ -11,6 +11,7 @@ This directory holds the manual test plan for the Linux fork of Claude Desktop.
|
||||
| [`matrix.md`](./matrix.md) | **The dashboard.** Cross-environment results table + per-section env-specific status snapshots. Single source of truth for test status. |
|
||||
| [`runbook.md`](./runbook.md) | How to run a sweep: VM setup, diagnostic capture, status update workflow, severity guidance. |
|
||||
| [`cases/`](./cases/) | Functional test specs grouped by feature surface. Stable IDs: `T###` cross-env, `S###` env-specific. |
|
||||
| [`ui/`](./ui/) | UI element inventory. Per-surface checklists — every interactive element with expected state. |
|
||||
|
||||
## Environment key
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@ tests, which anti-patterns to design against, and what to build first.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The 67 tests in [`cases/`](./cases/) already have stable IDs and
|
||||
standardized bodies. That structure is unusually friendly to
|
||||
automation — but only if the harness is shaped to match the corpus,
|
||||
rather than the other way around. Three things make that non-trivial:
|
||||
The 67 tests in [`cases/`](./cases/) plus the 10 surfaces in [`ui/`](./ui/)
|
||||
already have stable IDs, standardized bodies, and per-element checklists. That
|
||||
structure is unusually friendly to automation — but only if the harness is
|
||||
shaped to match the corpus, rather than the other way around. Three things
|
||||
make that non-trivial:
|
||||
|
||||
1. The tests aren't homogeneous. Some are pure-renderer (Code tab), some are
|
||||
native-OS-level (tray, autostart, URL handler), some are visual/UX checks
|
||||
@@ -39,7 +40,7 @@ rather than the other way around. Three things make that non-trivial:
|
||||
| 1 | **Single language: TypeScript.** Every runner is `.ts`; OS tools are shelled out via `child_process` and wrapped as TS helpers. Python only as a last-resort escape hatch for AT-SPI cases that resist portal mocking. | Playwright Electron is JS-native (post-Spectron); `dbus-next` covers DBus end-to-end; portal mocking removes the dogtail dependency for most native-dialog tests. Three-language overhead doesn't pay back. |
|
||||
| 2 | **Harness location: `tools/test-harness/`.** Sibling to `scripts/`. | Keeps `docs/testing/` documentation-only; matches the project's existing `tools/` / `scripts/` split. |
|
||||
| 3 | **VM images: Packer for imperative distros + Nix flake for `Hypr-N`.** | Packer builds golden snapshots that boot fast and rebuild as code; Nix flake handles NixOS natively without a second wrapper. Vagrant's per-boot provisioning model is the wrong tradeoff for hermetic per-test snapshots. |
|
||||
| 4 | **No CI infrastructure initially.** Harness is invocable from CI (orchestrator is a bash script with `ROW`, `ARTIFACT`, `OUTPUT_DIR` env vars), but sweeps run manually from the dev box for the first ~20 tests. CI wrapper comes after there's signal on which tests are stable enough to run unattended. | Avoids weeks of GHA / nested-KVM debugging for tests that aren't ready to be unattended. The bash orchestrator is the same code either way. |
|
||||
| 4 | **No CI infrastructure initially.** Harness is invokable from CI (orchestrator is a bash script with `ROW`, `ARTIFACT`, `OUTPUT_DIR` env vars), but sweeps run manually from the dev box for the first ~20 tests. CI wrapper comes after there's signal on which tests are stable enough to run unattended. | Avoids weeks of GHA / nested-KVM debugging for tests that aren't ready to be unattended. The bash orchestrator is the same code either way. |
|
||||
| 5 | **Selectors: semantic locators only (`getByRole`, `getByLabel`, `getByText`).** No CSS classes against minified renderer output. No proactive `data-testid` injection patch. Escalate per-test only when a specific test proves unstable: first ask upstream for a stable `data-testid`; only carry an `app-asar.sh` patch if upstream declines. | Building selector-injection infrastructure up front is a guess at where rot will happen. Modern React apps usually have enough ARIA roles and visible text for `getByRole`/`getByText` to be durable. Measure before patching. |
|
||||
| 6 | **X11-default verification is Smoke. Wayland-native characterization is Should.** Add a Smoke test asserting the launcher log shows X11/XWayland selected on each row (the project's release-gate behavior). Add per-row Should tests characterizing what happens if Electron's default Wayland selection is allowed — these are informational, not release-gating. | The project chose X11 default because portal `GlobalShortcuts` coverage is patchy. The new Wayland-default tests exist to map that landscape, not to gate releases on it. |
|
||||
| 7 | **Diagnostic retention: last 10 greens + all reds, on `main` only.** Captures `--doctor`, launcher log, screenshot every run. Reds retained indefinitely; greens rotate. | Cheap regression-bisect baseline; bounded storage; reds are the things you actually need to look at six weeks later. |
|
||||
@@ -52,7 +53,7 @@ bucket maps to a different shape of TS code (not a different language):
|
||||
|
||||
| Layer | What it covers | Implementation |
|
||||
|-------|----------------|----------------|
|
||||
| **L1 — Renderer** | Code tab, plugin install, settings, prompt area, slash menu, side chat | `playwright-electron` (`_electron.launch()`) directly |
|
||||
| **L1 — Renderer** | Code tab, plugin install, settings, prompt area, slash menu, side chat, most of `ui/code-tab-panes.md`, `prompt-area.md`, `settings.md` | `playwright-electron` (`_electron.launch()`) directly |
|
||||
| **L2 — Native / OS** | Tray (DBus), window decorations, URL handler (`xdg-open`), autostart, `--doctor`, multi-instance, hide-to-tray, native file picker (T17) | TS + `dbus-next` for DBus; `child_process` shell-outs wrapped as TS helpers (`xprop`, `wlr-randr`, `swaymsg`, `niri msg`, `pgrep`, `ydotool`); `dbus-next`-driven portal mocking for native-dialog tests |
|
||||
| **L3 — Manual** | "Icon is crisp on HiDPI", drag-and-drop feel, T28 catch-up after suspend (real wall-clock), subjective UX checks | Human eyes; capture in [`runbook.md`](./runbook.md) sweep loop |
|
||||
|
||||
|
||||
347
docs/testing/cases-grounding-prompt.md
Normal file
347
docs/testing/cases-grounding-prompt.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# docs/testing/cases grounding sweep — implementation prompt
|
||||
|
||||
This file is meant to be **copied verbatim into a fresh Claude Code
|
||||
session** as the initial user message. Don't paraphrase it; the
|
||||
orchestration depends on the exact directives below.
|
||||
|
||||
---
|
||||
|
||||
## Prompt to paste
|
||||
|
||||
You're picking up after the v7 walker, U01 wire-up, and the
|
||||
`claudeai.ts` AX-tree migration all landed. The page-objects are
|
||||
stable against the live renderer (T17_folder_picker passes on
|
||||
KDE-W). The next workstream is **grounding the case docs in
|
||||
`docs/testing/cases/` against actual upstream behavior**.
|
||||
|
||||
The cases were written from outside-in — observed user-visible
|
||||
flows, expected outcomes, diagnostic captures. Many describe
|
||||
behavior the test author *believed* exists in upstream Claude
|
||||
Desktop, but no one has cross-checked each Step / Expected against
|
||||
the actual extracted source. Your job is to spawn one subagent per
|
||||
case file, have each one read the case + grep the build-reference
|
||||
extract for the relevant feature, and report what's accurate, what's
|
||||
stale, and what's missing — then make in-place adjustments to the
|
||||
case files so each one is grounded in concrete code anchors before
|
||||
the next sweep cycle.
|
||||
|
||||
### Authoritative reference
|
||||
|
||||
Read these in order. They're the substrate the subagents will pull
|
||||
from.
|
||||
|
||||
- `docs/testing/cases/README.md` — the case-doc structure (severity,
|
||||
surface, applies-to, steps, expected, diagnostics, references).
|
||||
The "Standard test body" template at the bottom is the contract
|
||||
every case currently follows.
|
||||
- `docs/testing/matrix.md` — live Pass/Fail/Pending matrix per row.
|
||||
Tells you which cases have a runner and which are still
|
||||
human-execution-only.
|
||||
- `build-reference/app-extracted/.vite/build/` — the extracted +
|
||||
beautified Claude Desktop source. ~14 files; `index.js` is the
|
||||
main process (~546k lines after beautification), `mainView.js` /
|
||||
`mainWindow.js` / `quickWindow.js` are renderer preloads,
|
||||
`coworkArtifact.js` is the cowork BrowserView preload,
|
||||
`buddy.js` is the supervisor, etc. **This is the ground truth.**
|
||||
- `tools/test-harness/src/runners/` — existing runners that *do*
|
||||
have working selectors / event hooks. Sometimes the runner has
|
||||
more accurate code anchors than the case doc.
|
||||
- `CLAUDE.md` (project root) — project conventions, attribution
|
||||
format, commit style. Don't violate.
|
||||
|
||||
### Case files in scope
|
||||
|
||||
Eleven files plus the README. One subagent per file:
|
||||
|
||||
| File | Tests covered |
|
||||
|---|---|
|
||||
| `code-tab-foundations.md` | T15-T20 |
|
||||
| `code-tab-handoff.md` | T23-T25, T34, T38, T39 |
|
||||
| `code-tab-workflow.md` | T21-T22, T29-T32 |
|
||||
| `distribution.md` | S01-S05, S15, S16, S26 |
|
||||
| `extensibility.md` | T11, T33, T35-T37, S27, S28 |
|
||||
| `launch.md` | T01, T02, T13, T14 |
|
||||
| `platform-integration.md` | T09, T10, T12, S17, S18, S22-S25 |
|
||||
| `routines.md` | T26-T28, S19-S21 |
|
||||
| `shortcuts-and-input.md` | T05, T06, S06-S14, S29-S37 |
|
||||
| `tray-and-window-chrome.md` | T03, T04, T07, T08, S08, S13 |
|
||||
|
||||
### Why this iteration
|
||||
|
||||
Several cases have been silently bit-rotting against upstream
|
||||
changes — a Step says "click the X menu" but X was renamed two
|
||||
upstream versions ago, or an Expected references a behavior the
|
||||
team shipped behind a feature flag that's now off by default. When
|
||||
the sweep runs against a row that's stale, the failure looks like a
|
||||
Linux compatibility issue but is actually a doc-vs-upstream drift.
|
||||
Grounding the cases against the actual extracted source closes
|
||||
that gap and makes future sweeps interpretable.
|
||||
|
||||
This isn't a one-time correctness pass — it's a cycle. After every
|
||||
upstream version bump (`CLAUDE_DESKTOP_VERSION` rolls in
|
||||
`scripts/setup/detect-host.sh`), the grounding can drift again.
|
||||
Optimise for **leaving concrete code-anchor breadcrumbs** in each
|
||||
case so the next grounding pass is fast.
|
||||
|
||||
### Repo conventions
|
||||
|
||||
- Tabs for indentation in code; markdown is space-indented as the
|
||||
existing files do it.
|
||||
- Markdown lines wrap at ~80 chars unless they're tables or links
|
||||
that don't break naturally.
|
||||
- Don't commit. The user reviews and commits.
|
||||
- Don't run the host Claude Desktop. The user runs it. Read from
|
||||
`build-reference/` instead — that's already extracted +
|
||||
beautified specifically so you don't have to attach to a live
|
||||
app to verify behavior.
|
||||
|
||||
### Code anchors
|
||||
|
||||
- `build-reference/app-extracted/.vite/build/index.js` — main
|
||||
process. Every IPC channel registration, window-management
|
||||
decision, app-lifecycle hook, tray-menu construction, autostart
|
||||
toggle, dialog invocation, and protocol handler lives here.
|
||||
- `build-reference/app-extracted/.vite/build/quickWindow.js` —
|
||||
Quick Entry preload + window setup.
|
||||
- `build-reference/app-extracted/.vite/build/mainWindow.js` —
|
||||
main shell BrowserWindow preload (claude.ai is loaded into a
|
||||
child BrowserView; this preload runs in the shell frame).
|
||||
- `build-reference/app-extracted/.vite/build/mainView.js` —
|
||||
preload running inside the claude.ai BrowserView itself.
|
||||
- `build-reference/app-extracted/.vite/build/coworkArtifact.js` —
|
||||
preload running inside cowork's iframe-shaped artifact view.
|
||||
- `build-reference/app-extracted/.vite/build/buddy.js` — supervisor
|
||||
process (the daemon that respawns the cowork worker; see
|
||||
`docs/learnings/cowork-vm-daemon.md`).
|
||||
- `build-reference/app-extracted/package.json` — declared main /
|
||||
preloads, electron version, native deps. Quick reference for
|
||||
whether a feature is wired up at all.
|
||||
|
||||
### Phases
|
||||
|
||||
#### Phase 0 — calibration
|
||||
|
||||
1. `cd tools/test-harness && npm run typecheck` — should pass; if
|
||||
not, stop and report.
|
||||
2. Read `docs/testing/cases/README.md` end-to-end and one full case
|
||||
file (suggest `launch.md` — small, four tests, easy
|
||||
surface-area). Confirm you understand the case-doc contract
|
||||
before fanning out.
|
||||
3. Pick T01 (App launch) as a calibration case. Manually grep
|
||||
`build-reference/app-extracted/.vite/build/index.js` for the
|
||||
launcher-log / backend-selection logic referenced in T01's
|
||||
Expected. Confirm you can read the beautified source and locate
|
||||
the relevant code. Report the anchor (`index.js:N-M`) so the
|
||||
user knows the workflow is sound before you fan out.
|
||||
|
||||
If Phase 0 surfaces a problem (build-reference stale relative to
|
||||
the case doc, calibration anchor not findable, README structure
|
||||
unclear), stop and report. Don't fan out subagents against an
|
||||
unverified workflow.
|
||||
|
||||
#### Phase 1 — fan-out
|
||||
|
||||
Spawn one subagent per case file (eleven total). Use
|
||||
`subagent_type: 'general-purpose'`. Send them in **parallel** —
|
||||
they're independent. Keep the prompt to each subagent
|
||||
self-contained; the subagent has no context from this conversation.
|
||||
|
||||
Per-subagent prompt template (fill in the case file path):
|
||||
|
||||
```
|
||||
You're grounding ONE test-case file in
|
||||
docs/testing/cases/<FILE>.md against the extracted Claude Desktop
|
||||
source at build-reference/app-extracted/.vite/build/.
|
||||
|
||||
Read these first:
|
||||
- docs/testing/cases/README.md (case-doc contract)
|
||||
- docs/testing/cases/<FILE>.md (your case file)
|
||||
- CLAUDE.md (project conventions)
|
||||
|
||||
For each test in the file:
|
||||
|
||||
1. Read the test's Steps + Expected.
|
||||
2. Identify the load-bearing claim — the upstream behavior the
|
||||
test depends on (an IPC channel, a tray-menu item, a
|
||||
dialog.showOpenDialog call, a globalShortcut.register, a
|
||||
nativeTheme listener, etc.).
|
||||
3. Grep build-reference/app-extracted/.vite/build/ for that claim.
|
||||
Use ripgrep / grep -E. The code is beautified but minified
|
||||
variable names — anchor on string literals, IPC channel names,
|
||||
menu labels, event names, not variable identifiers.
|
||||
4. Classify the result:
|
||||
- **Grounded** — claim verified, anchor found. Append a
|
||||
`**Code anchors:** <file>:<line>` line to the test body
|
||||
directly under the existing References field.
|
||||
- **Drifted** — feature exists but the case's Steps or Expected
|
||||
don't match what's actually shipping. Edit the case to
|
||||
match upstream behavior. Note what changed.
|
||||
- **Missing** — feature isn't in the build at all (deprecated,
|
||||
never shipped, behind unset flag). Mark the test with a
|
||||
prepended block:
|
||||
`> **⚠ Missing in build 1.5354.0** — <one-line note>. Re-verify after next upstream bump.`
|
||||
- **Ambiguous** — claim could be one of several upstream code
|
||||
paths and you can't disambiguate from the case alone. Don't
|
||||
edit; report under "Open questions".
|
||||
|
||||
Per-test, prefer concrete code anchors over wordy explanations.
|
||||
The next person reading this case should see exactly where
|
||||
upstream implements the feature.
|
||||
|
||||
Constraints:
|
||||
- Don't fabricate anchors. If you can't find it, mark Missing or
|
||||
Ambiguous — never invent a `index.js:12345` reference.
|
||||
- Don't restructure the case files. Keep the existing template
|
||||
(Severity / Surface / Applies to / Issues / Steps / Expected /
|
||||
Diagnostics / References). Only add code anchors and edit
|
||||
Steps/Expected for drift.
|
||||
- Don't expand scope. If you notice an unrelated bug or missing
|
||||
test, note it under "Open questions" — don't fix it inline.
|
||||
- Don't run the host Claude Desktop. Read from build-reference/
|
||||
only.
|
||||
|
||||
Report shape (~300-500 words):
|
||||
|
||||
## <FILE>.md grounding
|
||||
|
||||
- Tests reviewed: N
|
||||
- Grounded: N
|
||||
- Drifted (edited): N (one-line per: <test-id> — <what changed>)
|
||||
- Missing (marked): N (one-line per: <test-id> — <what's gone>)
|
||||
- Ambiguous (flagged): N (one-line per: <test-id> — <why>)
|
||||
|
||||
### Code anchor highlights
|
||||
- <test-id>: <file>:<line> — <what the anchor proves>
|
||||
|
||||
### Open questions
|
||||
- ...
|
||||
|
||||
### Files touched
|
||||
- docs/testing/cases/<FILE>.md
|
||||
```
|
||||
|
||||
Keep the report tight. The orchestrator reads eleven of these and
|
||||
synthesizes.
|
||||
|
||||
#### Phase 2 — synthesis
|
||||
|
||||
Once all eleven subagents return:
|
||||
|
||||
1. Aggregate per-classification counts across all files. Big
|
||||
numbers in any column are signals:
|
||||
- Lots of **Drifted** → upstream had a recent feature shuffle;
|
||||
the team should know.
|
||||
- Lots of **Missing** → either the case doc was written
|
||||
speculatively or upstream removed features without telling.
|
||||
- Lots of **Ambiguous** → the case-doc template needs a
|
||||
"Implementation hint" field so future grounding has a
|
||||
starting point.
|
||||
2. Cross-check: did any subagent edit the same anchor differently?
|
||||
(Unlikely since each owns one file, but worth a sanity pass.)
|
||||
3. Check that `git diff docs/testing/cases/` matches what the
|
||||
subagents reported. If a subagent claimed Drifted but didn't
|
||||
write to disk, surface it.
|
||||
4. Build the user-facing summary (see "Final report format" below).
|
||||
|
||||
Don't make the user re-read the eleven subagent reports — give
|
||||
them the synthesised view + the per-file links.
|
||||
|
||||
### Self-correction loop
|
||||
|
||||
After Phase 1 returns:
|
||||
|
||||
1. If any subagent failed (no report, error, hit token limit),
|
||||
re-spawn just that one with a tighter scope (e.g. "process
|
||||
tests T15-T17 only, not the full file").
|
||||
2. If a subagent's report claims edits but `git diff` shows no
|
||||
changes, the subagent silently dropped the writes — re-spawn
|
||||
with explicit instruction to use the Edit tool.
|
||||
3. If two subagents flag the same upstream code path with
|
||||
contradictory claims (one says Grounded, one says Missing),
|
||||
re-read the source yourself and adjudicate.
|
||||
|
||||
Cap re-spawns at **2 per file** — past that, mark the file as
|
||||
"needs human review" in the final report and move on.
|
||||
|
||||
### Termination conditions
|
||||
|
||||
Stop and write a final report when one of:
|
||||
|
||||
1. **All eleven files grounded.** Per-file classification counts +
|
||||
diff stat. Done.
|
||||
2. **Hit the re-spawn cap on 3+ files.** Stop, write up which
|
||||
files are blocked, what each blocker looks like.
|
||||
3. **Build-reference is stale.** If multiple subagents report
|
||||
"Missing" against features the user knows shipped, the
|
||||
extract may be out of date — verify the version
|
||||
(`build-reference/app-extracted/package.json` `version` field
|
||||
vs `CLAUDE_DESKTOP_VERSION` repo variable) before continuing.
|
||||
|
||||
### What you should NOT do
|
||||
|
||||
- Don't commit. The user reviews everything.
|
||||
- Don't restructure the case-doc template. Eleven files, one
|
||||
shape — keep it that way.
|
||||
- Don't add new tests. Grounding is a verify-and-anchor pass, not
|
||||
a coverage expansion.
|
||||
- Don't run the host Claude Desktop. The build-reference extract
|
||||
exists specifically so you don't have to attach to a live app.
|
||||
- Don't edit anything outside `docs/testing/cases/`. If you find
|
||||
a runner discrepancy (case says "click X", runner clicks "Y"),
|
||||
flag it under Open questions; don't edit the runner.
|
||||
- Don't invent anchors. If the grep doesn't find the literal,
|
||||
classify Missing or Ambiguous — never write a fictional
|
||||
`index.js:12345` reference.
|
||||
|
||||
### Final report format
|
||||
|
||||
```markdown
|
||||
## Cases grounding summary
|
||||
|
||||
- Files reviewed: 11 / 11
|
||||
- Tests reviewed: N (sum across all files)
|
||||
- Grounded: N (with code anchors added)
|
||||
- Drifted (edited): N
|
||||
- Missing (marked): N
|
||||
- Ambiguous: N
|
||||
- Files needing
|
||||
human review: N
|
||||
|
||||
## Per-file breakdown
|
||||
|
||||
| File | Reviewed | Grounded | Drifted | Missing | Ambiguous |
|
||||
|---|---|---|---|---|---|
|
||||
| code-tab-foundations.md | ... | ... | ... | ... | ... |
|
||||
| ... | | | | | |
|
||||
|
||||
## Notable findings
|
||||
- <test-id>: <one-line significance>
|
||||
- ...
|
||||
|
||||
## Open questions
|
||||
- ...
|
||||
|
||||
## Files touched
|
||||
git status output (only docs/testing/cases/*.md should appear)
|
||||
|
||||
## Diff summary
|
||||
git diff --stat docs/testing/cases/
|
||||
```
|
||||
|
||||
### Operational notes
|
||||
|
||||
- Subagents are launched in parallel via a single message with
|
||||
multiple Agent tool calls. Don't serialize them — Phase 1 takes
|
||||
~15 minutes serial, ~3 minutes parallel.
|
||||
- Each subagent's Edit calls land directly in the working tree.
|
||||
No merge conflicts because each owns one file.
|
||||
- The build-reference `index.js` is 546k lines. Subagents should
|
||||
use `grep -nE` with anchored string literals, not full reads.
|
||||
Recommended grep pattern style:
|
||||
`grep -nE 'globalShortcut\.register\([^)]*' build-reference/app-extracted/.vite/build/index.js`
|
||||
- If a subagent needs to verify a renderer-side claim (DOM event
|
||||
flow, React component shape), the relevant preload is in
|
||||
`mainView.js` / `mainWindow.js`. Don't grep `index.js` for
|
||||
renderer-only behavior.
|
||||
|
||||
Begin with Phase 0. Don't fan out until calibration succeeds.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Functional Test Cases
|
||||
|
||||
Test specifications grouped by feature surface. For live status, see [`../matrix.md`](../matrix.md). For sweep workflow, see [`../runbook.md`](../runbook.md).
|
||||
Test specifications grouped by feature surface. For live status, see [`../matrix.md`](../matrix.md). For sweep workflow, see [`../runbook.md`](../runbook.md). For the UI element inventory, see [`../ui/`](../ui/).
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
|
||||
|
||||
**Diagnostics on failure:** `xrandr` (X11) / `wlr-randr` (wlroots) output before and after disconnect, captured popup coordinates, screenshot.
|
||||
|
||||
**Skip when:** Single-monitor VM or host. Skip with `-` in the dashboard.
|
||||
**Skip when:** Single-monitor VM or host. Not part of the [§ Mandatory matrix](../quick-entry-closeout.md#mandatory-matrix); skip with `-` in the dashboard.
|
||||
|
||||
**References:** upstream `index.js:515502`
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515502 (`return cHn();` early-return when no saved position), 515523-515527 (`cHn()` centres popup on `screen.getPrimaryDisplay()` workArea), 515514-515515 (`label`-only match fallback before primary-display fallback).
|
||||
|
||||
322
docs/testing/claudeai-lib-ax-migration-prompt.md
Normal file
322
docs/testing/claudeai-lib-ax-migration-prompt.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# lib/claudeai.ts AX-tree migration — implementation prompt
|
||||
|
||||
This file is meant to be **copied verbatim into a fresh Claude Code
|
||||
session** as the initial user message. Don't paraphrase it; the
|
||||
self-correction loop depends on the exact directives below.
|
||||
|
||||
---
|
||||
|
||||
## Prompt to paste
|
||||
|
||||
You're picking up after the v7 fingerprint walker + U01 wire-up
|
||||
landed. Walker, resolver, and U01 are all on the AX-tree substrate.
|
||||
The page-object library `tools/test-harness/src/lib/claudeai.ts` is
|
||||
still on the old substrate — `document.querySelector` against
|
||||
minified-tailwind class shapes (`button[aria-haspopup="menu"]` +
|
||||
`span.truncate.max-w-[Npx]`) — and that's where every claude.ai UI
|
||||
spec couples to upstream's React DOM. Your job is to migrate the
|
||||
brittle CSS-shape walks in `claudeai.ts` to AX-tree resolution using
|
||||
the v7 walker primitives, run the H/S spec families that consume
|
||||
them, and iterate until those specs pass without DOM-shape coupling.
|
||||
|
||||
### Authoritative reference
|
||||
|
||||
Read these in order. They contain the design, the gotchas, and the
|
||||
runtime contract — the prompt below assumes them as background.
|
||||
|
||||
- `docs/testing/fingerprint-v7-plan.md` — design contract for the v7
|
||||
fingerprint, kind-strictness matrix, resolver fallback chain. Skim
|
||||
the "Capture algorithm" and "Resolver / fallback chain" sections;
|
||||
the migration consumes the same primitives.
|
||||
- `docs/learnings/test-harness-ax-tree-walker.md` — the five
|
||||
non-obvious AX-tree traps (AX-enable async lag, navigateTo no-op,
|
||||
flat dialog>button[] lists, more-options shape, sidebar
|
||||
virtualization). All apply here too — `lib/claudeai.ts` calls run
|
||||
inside the same renderer the walker drives.
|
||||
- `tools/test-harness/src/lib/claudeai.ts` — the migration target.
|
||||
~340 lines, eight functions plus two classes (`CodeTab`,
|
||||
`LocalEnvPill`). Every public function is a discovery walk against
|
||||
`evalInRenderer` with `document.querySelectorAll`.
|
||||
|
||||
### Why this iteration
|
||||
|
||||
Per the v7 plan's design goal §2 "Resilient to cosmetic drift" —
|
||||
upstream regenerates tailwind class signatures on rebuild
|
||||
(`max-w-[Npx]`, `df-pill`-style atoms), so `claudeai.ts`'s CSS-shape
|
||||
walks break on any minor UI rebuild even when the AX-computed role
|
||||
and accessible name are stable. The U01 wire-up confirmed the AX
|
||||
tree is a usable substrate end-to-end (~7s/test, 89/90 stable across
|
||||
two consecutive sweeps). Pulling `claudeai.ts` onto the same
|
||||
substrate eliminates the recurring "tailwind regen breaks H05/S31
|
||||
again" failure mode.
|
||||
|
||||
Acceptance per the plan: H05 + S29-S37 + T-prefix specs that consume
|
||||
`claudeai.ts` keep passing on the same account, with zero new
|
||||
flakes. Migration is mechanical (replace the eval-string walks with
|
||||
AX-tree queries) and the existing tests are the contract.
|
||||
|
||||
### Repo conventions
|
||||
|
||||
- Tabs for indentation, lines under 80 chars, single quotes for
|
||||
literals, TypeScript strict mode (`tools/test-harness/tsconfig.json`
|
||||
enforces it).
|
||||
- Comments only when the WHY is non-obvious — write the `because:`
|
||||
clause, not the `that:` clause.
|
||||
- No backward-compatibility shims. If a function's signature needs
|
||||
to change, change every caller. Don't keep both code paths.
|
||||
- Don't commit. The user reviews and commits.
|
||||
|
||||
### Code anchors
|
||||
|
||||
- `tools/test-harness/explore/walker.ts` — exports the primitives
|
||||
you'll consume:
|
||||
- `findByFingerprint(inspector, fingerprint, kind)` — full
|
||||
resolver with strictness gating + relaxed-scope fallback.
|
||||
Overkill for one-shot lookups against the live renderer.
|
||||
- `queryAccessibleTree(elements, query)` — pure filter, used at
|
||||
capture and resolve time. Takes a `RawElement[]` snapshot and
|
||||
an `AxQuery` (ariaPath + leaf criteria). What you'll likely
|
||||
wrap.
|
||||
- `axTreeToSnapshot(nodes)` — converts CDP `AxNode[]` to the
|
||||
walker's `RawElement[]` shape. Drops ignored nodes.
|
||||
- `walkLandmarkAncestors(raw)` — emits the AriaStep[] for an
|
||||
element. Useful if a method needs to disambiguate by landmark.
|
||||
- `waitForAxTreeStable(inspector, opts)` — gating primitive used
|
||||
by walker + U01. Use `{ minNodes: 1, timeoutMs: 10000 }` for
|
||||
post-click reads (matches `snapshotSurface`'s default).
|
||||
- `tools/test-harness/src/lib/inspector.ts` — `getAccessibleTree`
|
||||
fetches the raw CDP tree filtered to the claude.ai webContents.
|
||||
- `tools/test-harness/src/lib/claudeai.ts` — the migration target.
|
||||
Read the file-header comment first; it documents the discovery
|
||||
strategy you're replacing.
|
||||
- `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`,
|
||||
`S31_quick_entry_submit_reaches_new_chat.spec.ts`,
|
||||
`S32_quick_entry_submit_gnome_stale_isfocused.spec.ts` — primary
|
||||
consumers of the methods being migrated.
|
||||
|
||||
### Phases
|
||||
|
||||
#### Phase A — spike on one method
|
||||
|
||||
1. `cd tools/test-harness && npm run typecheck` — must pass before
|
||||
doing anything.
|
||||
2. Pick `openPill(inspector, labelPattern, opts)` as the spike.
|
||||
It's the most CSS-shape-coupled method and exercises the
|
||||
menu-render polling pattern the rest of `claudeai.ts` reuses.
|
||||
3. Replace its body with an AX-tree query:
|
||||
- Fetch the AX tree (`inspector.getAccessibleTree('claude.ai')`),
|
||||
convert via `axTreeToSnapshot`.
|
||||
- Filter to elements with `computedRole === 'button'` and
|
||||
accessibleName matching `labelPattern`.
|
||||
- For each candidate, compute its parent landmark via
|
||||
`walkLandmarkAncestors`. The compact-pill discriminator —
|
||||
"has a `span.truncate.max-w-[Npx]` child" — needs an AX
|
||||
analogue. Most likely: parent is `toolbar` / `group` and the
|
||||
element has `aria-haspopup === 'menu'` (exposed in AX as
|
||||
`hasPopup` property; check whether `RawElement` carries it
|
||||
and extend if needed).
|
||||
- Click via `inspector.clickByBackendNodeId(raw.backendDOMNodeId)`.
|
||||
- Poll for menu items via AX role match (`menuitem`,
|
||||
`menuitemradio`, `menuitemcheckbox`).
|
||||
4. Run H05 against your branch (`./node_modules/.bin/playwright
|
||||
test src/runners/H05_ui_drift_check.spec.ts`). H05 doesn't
|
||||
directly call `openPill` but exercises the same renderer state;
|
||||
if H05 regresses your AX walk is wrong.
|
||||
5. Run S31 (`./node_modules/.bin/playwright test
|
||||
src/runners/S31_quick_entry_submit_reaches_new_chat.spec.ts`).
|
||||
This calls `openPill` indirectly via `CodeTab.activate` →
|
||||
`findCompactPills`.
|
||||
6. If both pass, the AX substrate works for at least one method.
|
||||
Commit the shape mentally (don't `git commit` — the user does
|
||||
that). If either fails, the spike is in trouble; re-read the
|
||||
AX-tree learnings doc for traps you missed and fix the
|
||||
primitive before expanding.
|
||||
|
||||
#### Phase B — migrate the rest
|
||||
|
||||
For each remaining function in `claudeai.ts`, port the discovery
|
||||
walk to AX:
|
||||
|
||||
- `activateTab(inspector, name)` — `button` with
|
||||
`accessibleName === name` under root or banner landmark. Existing
|
||||
`aria-label="X"` selector → AX `name` literal match.
|
||||
- `findCompactPills(inspector)` — list of buttons with
|
||||
`hasPopup === 'menu'` AND inner `span.truncate.max-w-[…]` text
|
||||
child. AX equivalent: button role + hasPopup + a child
|
||||
`genericContainer` (or whatever AX exposes for `<span>`) carrying
|
||||
the visible text. Returns `{text, maxW, expanded}` today —
|
||||
`maxW` is a tailwind artifact and should be dropped from the AX
|
||||
shape (callers don't use it for matching, just for diagnostics;
|
||||
keep a placeholder or remove from the type).
|
||||
- `clickMenuItem(inspector, textPattern, opts)` — element with
|
||||
role in `{menuitem, menuitemradio, menuitemcheckbox}` and
|
||||
accessibleName matching `textPattern`. The CSS attribute selector
|
||||
has an AX direct equivalent.
|
||||
- `pressEscape(inspector)` — keep as-is. It's a keydown dispatch,
|
||||
not a discovery walk.
|
||||
- `CodeTab.activate(opts)` — calls `activateTab` + polls
|
||||
`findCompactPills`. Migrates by transitivity.
|
||||
- `LocalEnvPill` — read its body to enumerate callers.
|
||||
|
||||
After each migration:
|
||||
1. `npm run typecheck` — must pass.
|
||||
2. `npx tsx explore/walker.ts` — selfTest must pass (you may have
|
||||
touched walker.ts to expose new primitives).
|
||||
3. Run the affected spec(s).
|
||||
|
||||
#### Phase C — full sweep
|
||||
|
||||
1. Run all H/S/T runners that consume `claudeai.ts`:
|
||||
- H05 (UI drift)
|
||||
- S31 (Code-tab submit)
|
||||
- S32 (GNOME stale isFocused)
|
||||
- any T-prefix that uses `installOpenDialogMock` or `pressEscape`
|
||||
2. Tally pass/fail. The post-migration baseline must equal the
|
||||
pre-migration baseline, modulo flakes characterized in
|
||||
`docs/learnings/test-harness-ax-tree-walker.md`.
|
||||
|
||||
Cap iterations at **5 sweep cycles** total (spike + 4 fix-rerun
|
||||
cycles) — past that, stop and report.
|
||||
|
||||
##### Failure classes
|
||||
|
||||
1. **AX-shape mismatch.** Element has the CSS shape the old code
|
||||
relied on but a different AX role/name than expected. Fix:
|
||||
probe the AX tree for the actual shape (use
|
||||
`inspector.getAccessibleTree('claude.ai')` interactively from a
|
||||
one-shot script), update the AX query.
|
||||
2. **Missing AX property exposure.** `hasPopup`, `expanded`, etc.
|
||||
may not be in `RawElement` today (the walker only reads role,
|
||||
name, ancestors, sibling info). Extend `RawElement` and
|
||||
`axTreeToSnapshot` to expose what the migration needs. Update
|
||||
walker.ts selfTest if you change the snapshot shape.
|
||||
3. **Race against menu render.** Old code polled
|
||||
`document.querySelectorAll('[role=menuitem]')` every 50ms. AX
|
||||
tree updates lag DOM by hundreds of ms; bake a
|
||||
`waitForAxTreeStable({ minNodes: 1 })` between click and
|
||||
menuitem fetch instead of a short DOM poll.
|
||||
4. **Tailwind-class diagnostic loss.** `findCompactPills` returns
|
||||
`maxW` which callers use only in error messages. If the
|
||||
AX-only return shape drops `maxW`, error messages get less
|
||||
informative — accept it, don't reintroduce DOM walks just for
|
||||
diagnostics. Keep the `maxW` field optional/null in the type.
|
||||
|
||||
##### What "fix" means
|
||||
|
||||
A fix is one of:
|
||||
- A code change in `claudeai.ts`, `walker.ts`, or `inspector.ts`.
|
||||
- A targeted extension of `RawElement` / `axTreeToSnapshot` to
|
||||
expose an AX property the migration needs.
|
||||
|
||||
Not a fix:
|
||||
- `// eslint-disable-next-line` / `// @ts-ignore` / `as unknown as ...`.
|
||||
- Keeping the old `document.querySelector` walk as a fallback.
|
||||
- Adding an AX walk that wraps a CSS walk that wraps an AX walk.
|
||||
|
||||
### Self-correction loop (general protocol)
|
||||
|
||||
After each phase's specific loop:
|
||||
|
||||
1. If `npm run typecheck` reports errors, fix root causes — no
|
||||
`// @ts-ignore`, no `any`, no `as unknown as ...`.
|
||||
2. If `npx tsx explore/walker.ts` (selfTest) fails, the change broke
|
||||
an algorithmic invariant. Don't relax the test; fix the change.
|
||||
3. **Cap fix attempts per problem class at 3.** After 3 attempts
|
||||
on the same class without progress, stop and report.
|
||||
4. Mark Phase complete only when every step in that Phase passes
|
||||
cleanly.
|
||||
|
||||
### Termination conditions
|
||||
|
||||
Stop and write a final report when one of:
|
||||
|
||||
1. **Migration is clean.** All `claudeai.ts` methods on AX
|
||||
substrate, all consuming specs pass at the pre-migration
|
||||
baseline. Report final pass tallies + diff stat.
|
||||
2. **Hit the 5-sweep cap.** Report what's done, what's blocked,
|
||||
and what each remaining failure looks like.
|
||||
3. **Hit the 3-attempt cap on a non-trivial issue.** Report
|
||||
attempts, why each failed, what's blocked.
|
||||
4. **AX exposure gap.** A claude.ai surface uses a property the AX
|
||||
tree doesn't expose (e.g., custom `data-state` attributes
|
||||
without a corresponding ARIA reflection). Stop, document the
|
||||
gap, ask the user before adding a hybrid AX+DOM walk.
|
||||
|
||||
### What you should NOT do
|
||||
|
||||
- Don't commit. The user reviews everything.
|
||||
- Don't keep both substrates. The migration is atomic per method:
|
||||
CSS walk out, AX walk in. No fallback chains.
|
||||
- Don't add new abstractions in `claudeai.ts` that aren't required
|
||||
by the migration. The file's shape (one function per UI verb) is
|
||||
load-bearing for callers — don't introduce a `PageObject` base
|
||||
class or a generic AX builder.
|
||||
- Don't run the host Claude Desktop. The user runs it. The H/S
|
||||
specs use `launchClaude` with `seedFromHost` or `null` isolation
|
||||
per spec — confirm with the user before any sweep.
|
||||
- Don't widen `RawElement` speculatively. Only add fields the
|
||||
migration consumes. Each new field bloats every snapshot.
|
||||
- Don't drill into a single-method workaround that other methods
|
||||
would have to duplicate. If a fix wants to live in a helper,
|
||||
put it next to `queryAccessibleTree` in `walker.ts`.
|
||||
|
||||
### Final report format
|
||||
|
||||
```markdown
|
||||
## Migration summary
|
||||
|
||||
- Functions migrated: N / N
|
||||
- Walker.ts changes: <one-line summary>
|
||||
- Inspector.ts changes: <one-line summary or none>
|
||||
- H/S/T specs run: N
|
||||
- H/S/T specs passed: N
|
||||
- New flakes introduced: N (description)
|
||||
|
||||
## Iteration log
|
||||
|
||||
### Spike — openPill
|
||||
- Result: ...
|
||||
- AX shape used: ...
|
||||
- Issues hit: ...
|
||||
|
||||
### Phase B — remaining methods
|
||||
- One block per method ...
|
||||
|
||||
### Phase C — full sweep
|
||||
- Per-spec pass/fail tally
|
||||
- Diff against pre-migration baseline
|
||||
|
||||
## Open issues
|
||||
- ...
|
||||
|
||||
## Files touched
|
||||
git status output
|
||||
|
||||
## Diff for review
|
||||
git diff --stat output
|
||||
```
|
||||
|
||||
### Operational notes
|
||||
|
||||
- Background runs: use `Bash run_in_background: true` for any
|
||||
multi-spec sweep, and `Monitor` with a tight grep filter
|
||||
(`✓|✘|Error|FAIL|EXIT=`) to stream events. Stop the monitor when
|
||||
the run completes.
|
||||
- Check for leftover Electron processes between runs
|
||||
(`pgrep -af '/usr/lib/claude-desktop/node_modules/electron'`)
|
||||
and stale tmpdirs (`ls /tmp/claude-test-*`) — clean both up if
|
||||
the prior run errored before teardown.
|
||||
- The U01 wire-up landed two `walker.ts` fixes that are part of
|
||||
the substrate you're inheriting:
|
||||
1. `findByFingerprint`: strictness gate also defers to
|
||||
`fingerprint.classification === 'instance'` for degenerate
|
||||
fingerprints.
|
||||
2. `redrivePath`: navigates to startUrl when current URL drifted;
|
||||
reloads only when already at startUrl.
|
||||
Both are live in the working tree (or just-merged main,
|
||||
depending on when this prompt fires).
|
||||
|
||||
Begin with Phase A. Read `claudeai.ts` end-to-end first — in
|
||||
particular the file-header discovery comment (lines 1-31) and the
|
||||
`openPill` body (lines 162-202) — so you understand what the
|
||||
existing CSS-shape walks are anchoring on before you replace them.
|
||||
218
docs/testing/claudeai-ui-map.md
Normal file
218
docs/testing/claudeai-ui-map.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# claude.ai UI Map
|
||||
|
||||
*Last updated: 2026-05-02*
|
||||
|
||||
This file is the index from "UI surface" → "test-harness abstraction." It
|
||||
answers: *which renderer surface does each Layer-2 helper cover, and where
|
||||
are the gaps?* For human-readable behavior and visual specs of each surface
|
||||
(what each button looks like, what each menu does), see [`ui/`](./ui/).
|
||||
For the architectural rationale and growth strategy of the wrapper, see
|
||||
[`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md).
|
||||
|
||||
A `✓` marker means the helper exists today, with a `file:line` reference
|
||||
into [`tools/test-harness/src/lib/claudeai.ts`](../../tools/test-harness/src/lib/claudeai.ts).
|
||||
A `TODO` marker is a planned helper — when a third test needs the same
|
||||
shape, promote it from inline `evalInRenderer` to a top-level helper or
|
||||
page-object method (see plan Phase 3).
|
||||
|
||||
## Top-level routes
|
||||
|
||||
- `/new` — chat composer page (default landing for signed-in users)
|
||||
- `/chat/<uuid>` — open chat session
|
||||
- `/epitaxy` — Code tab landing
|
||||
- `/projects/<id>` — project view
|
||||
- `/login`, `/auth/*` — pre-login routes (test harness skips here)
|
||||
|
||||
The Code df-pill click does **not** change the URL — the router rerenders
|
||||
the tab body inline. Helpers must poll for body-mount signals (e.g. a
|
||||
compact pill rendering) rather than waiting on navigation.
|
||||
|
||||
## Surfaces by tab
|
||||
|
||||
### Chat (df-pill "Chat", route /new)
|
||||
|
||||
UI reference: [`ui/prompt-area.md`](./ui/prompt-area.md),
|
||||
[`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md).
|
||||
|
||||
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
|
||||
- Composer textarea — TODO `ChatTab.composer()`
|
||||
- "+" submenu (Add files / Add to project / Skills / Connectors / ...)
|
||||
— TODO `ChatTab.openAttachMenu()`
|
||||
- Slash menu (triggered by typing `/`) — TODO `ChatTab.openSlashMenu()`
|
||||
- Model picker — TODO `ChatTab.openModelPicker()`
|
||||
- Permission mode picker — TODO `ChatTab.openPermissionPicker()`
|
||||
- Effort picker — TODO
|
||||
- Send button — TODO `ChatTab.send()`
|
||||
- Stop button (replaces Send while responding) — TODO `ChatTab.stop()`
|
||||
- Attachment chip / drag-drop overlay — TODO
|
||||
- Usage ring — TODO
|
||||
|
||||
### Cowork (df-pill "Cowork")
|
||||
|
||||
UI reference: see ghost-icon row in
|
||||
[`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md). No
|
||||
dedicated surface doc yet — the ghost icon is the canonical "topbar shim
|
||||
alive" indicator and the tab body itself is largely undocumented at the
|
||||
time of writing.
|
||||
|
||||
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
|
||||
- Workspace list — TODO `CoworkTab.listWorkspaces()`
|
||||
- Environment switcher — TODO `CoworkTab.switchEnvironment()`
|
||||
- Dispatch state indicator — TODO
|
||||
|
||||
### Code (df-pill "Code", route /epitaxy)
|
||||
|
||||
UI reference: [`ui/code-tab-panes.md`](./ui/code-tab-panes.md),
|
||||
[`ui/sidebar.md`](./ui/sidebar.md),
|
||||
[`ui/prompt-area.md`](./ui/prompt-area.md).
|
||||
|
||||
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
|
||||
- Tab activation + body-mount wait — `lib/claudeai.ts:CodeTab.activate` (:285) ✓
|
||||
- Env pill (Local / Cloud / SSH) — `lib/claudeai.ts:CodeTab.openEnvPill` (:317) ✓
|
||||
- Local env selection — `lib/claudeai.ts:CodeTab.selectLocal` (:350) ✓
|
||||
- Select-folder pill (rendered after Local) — used internally by
|
||||
`lib/claudeai.ts:CodeTab.openFolderPicker` (:368) ✓
|
||||
- Folder picker dialog (full chain) — `lib/claudeai.ts:CodeTab.openFolderPicker` (:368) ✓
|
||||
- Folder picker dialog mock + assertion — `lib/claudeai.ts:installOpenDialogMock`
|
||||
(:70) ✓ + `lib/claudeai.ts:getOpenDialogCalls` (:113) ✓
|
||||
- File tree (left panel) — TODO `CodeTab.fileTree()`
|
||||
- Editor pane — TODO `CodeTab.editor()`
|
||||
- Diff pane — TODO `CodeTab.openDiff()`
|
||||
- Preview pane — TODO `CodeTab.openPreview()`
|
||||
- Integrated terminal — TODO `CodeTab.openTerminal()`
|
||||
- Tasks / subagent / plan panes — TODO
|
||||
- Side-chat — TODO `CodeTab.openSideChat()`
|
||||
- Recent-folder selection (radio in Select-folder menu) — TODO
|
||||
|
||||
## Surfaces independent of tab
|
||||
|
||||
### Sidebar
|
||||
|
||||
UI reference: [`ui/sidebar.md`](./ui/sidebar.md).
|
||||
|
||||
- Search overlay (topbar Search icon) — TODO `SidebarNav.search()`
|
||||
- Recent conversations — TODO `SidebarNav.openRecent(idx | uuid)`
|
||||
- "More options" per row — TODO `SidebarNav.rowContextMenu(uuid)`
|
||||
- "+ New session" button — TODO `SidebarNav.newSession()`
|
||||
- Routines link — TODO `SidebarNav.openRoutines()`
|
||||
- Customize link — TODO `SidebarNav.openCustomize()`
|
||||
- Status / project / environment filters — TODO
|
||||
- Group-by control — TODO
|
||||
- Collapse toggle — TODO
|
||||
|
||||
### Window chrome / topbar (in-app hybrid)
|
||||
|
||||
UI reference: [`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md).
|
||||
|
||||
- Hamburger menu — TODO `Topbar.openHamburger()`
|
||||
- Sidebar toggle — TODO `Topbar.toggleSidebar()`
|
||||
- Back / forward arrows — TODO
|
||||
- Cowork ghost icon (topbar-alive sentinel) — TODO `Topbar.coworkGhostPresent()`
|
||||
|
||||
### Native dialogs
|
||||
|
||||
- File / folder picker mock — `lib/claudeai.ts:installOpenDialogMock` (:70) ✓
|
||||
- File / folder picker call inspection — `lib/claudeai.ts:getOpenDialogCalls` (:113) ✓
|
||||
- Message box / confirm — TODO `installShowMessageBoxMock`
|
||||
- Save dialog — TODO `installShowSaveDialogMock`
|
||||
|
||||
### Menus / popovers
|
||||
|
||||
- Compact-pill discovery — `lib/claudeai.ts:findCompactPills` (:130) ✓
|
||||
- Compact-pill open + menu read — `lib/claudeai.ts:openPill` (:162) ✓
|
||||
- Click any menuitem by text regex — `lib/claudeai.ts:clickMenuItem` (:210) ✓
|
||||
- Dismiss popover via Escape — `lib/claudeai.ts:pressEscape` (:256) ✓
|
||||
- Modal dismiss / confirm — TODO `Modal.dismiss()` / `Modal.confirm()`
|
||||
- Toast / status — TODO `waitForToast(regex)`
|
||||
- Right-click context menus (sidebar row, etc.) — TODO `openContextMenu(target)`
|
||||
|
||||
### Settings
|
||||
|
||||
UI reference: [`ui/settings.md`](./ui/settings.md).
|
||||
|
||||
- Open Settings — TODO `Settings.open()`
|
||||
- Hotkey rebind — TODO `Settings.rebindHotkey(action, chord)`
|
||||
- Theme toggle — TODO `Settings.setTheme('dark' | 'light' | 'auto')`
|
||||
- Account / sign-out — TODO `Settings.signOut()`
|
||||
- Computer-use toggle (absent on Linux per S22) — TODO
|
||||
- Keep-computer-awake toggle (per S20) — TODO
|
||||
|
||||
### Routines page
|
||||
|
||||
UI reference: [`ui/routines-page.md`](./ui/routines-page.md).
|
||||
|
||||
- Routines list — TODO `RoutinesPage.list()`
|
||||
- New-routine form — TODO `RoutinesPage.create(spec)`
|
||||
- Routine detail page — TODO `RoutinesPage.open(id)`
|
||||
|
||||
### Connectors and plugins
|
||||
|
||||
UI reference: [`ui/connectors-and-plugins.md`](./ui/connectors-and-plugins.md).
|
||||
|
||||
- Connector picker — TODO `ConnectorPicker.open()`
|
||||
- Connector list / status — TODO
|
||||
- Plugin browser — TODO `PluginBrowser.open()`
|
||||
- Plugin install (Anthropic & Partners flow) — TODO `PluginBrowser.install(slug)`
|
||||
- Plugin manager (installed list) — TODO
|
||||
|
||||
### Quick Entry popup
|
||||
|
||||
UI reference: [`ui/quick-entry.md`](./ui/quick-entry.md). Note: the
|
||||
Quick Entry harness lives in [`quickentry.ts`](../../tools/test-harness/src/lib/quickentry.ts),
|
||||
not `claudeai.ts`. The `installOpenDialogMock` shape here intentionally
|
||||
mirrors `QuickEntry.installInterceptor` (quickentry.ts:86) — keep them
|
||||
aligned when extending either.
|
||||
|
||||
- Open Quick Entry (global shortcut) — covered by `lib/quickentry.ts`
|
||||
- Compose + send — covered by `lib/quickentry.ts`
|
||||
- Closeout cases (S29–S37) — covered by `lib/quickentry.ts`
|
||||
|
||||
### Notifications
|
||||
|
||||
UI reference: [`ui/notifications.md`](./ui/notifications.md). libnotify
|
||||
rendering is environmental — likely stays a manual checklist rather than
|
||||
a renderer-side helper. No `claudeai.ts` coverage planned.
|
||||
|
||||
### Tray
|
||||
|
||||
UI reference: [`ui/tray.md`](./ui/tray.md). Tray is owned by the main
|
||||
process / native bindings, not the renderer DOM — outside the scope of
|
||||
`claudeai.ts`. Covered by separate tests (T03, S08).
|
||||
|
||||
## Atoms inventory
|
||||
|
||||
Stable structural patterns the lib already anchors on. See the
|
||||
discovery comment at the top of
|
||||
[`tools/test-harness/src/lib/claudeai.ts`](../../tools/test-harness/src/lib/claudeai.ts)
|
||||
for why each is shape-matched rather than class-matched.
|
||||
|
||||
| Atom | Fingerprint | Helper |
|
||||
|---|---|---|
|
||||
| df-pill | `button[aria-label][class*="df-pill"]` | `activateTab(name)` (:44) |
|
||||
| compact-pill | `button[aria-haspopup=menu] > span.truncate.max-w-[*]` | `findCompactPills` (:130), `openPill` (:162) |
|
||||
| menu / menuitem | `[role=menu] [role=menuitem*]` | `clickMenuItem(regex)` (:210) |
|
||||
| Escape dismiss | `document.dispatchEvent(KeyboardEvent('keydown', Escape))` | `pressEscape` (:256) |
|
||||
| Electron `dialog.showOpenDialog` | main-process IPC | `installOpenDialogMock` (:70), `getOpenDialogCalls` (:113) |
|
||||
|
||||
Atoms not yet abstracted (when a third test needs the same shape,
|
||||
promote to a top-level helper):
|
||||
|
||||
| Atom | Probable fingerprint | Status |
|
||||
|---|---|---|
|
||||
| modal | `[role=dialog]` | not seen yet |
|
||||
| toast | `[role=status][aria-live]` | not seen yet |
|
||||
| sidebar nav row | `[class*="df-row"] [aria-label]` | seen, not abstracted |
|
||||
| chat composer | textarea / contenteditable in composer container | not abstracted |
|
||||
| right-click context menu | `[role=menu]` triggered by `contextmenu` event | not abstracted |
|
||||
| Electron `dialog.showMessageBox` | main-process IPC | not abstracted |
|
||||
| Electron `dialog.showSaveDialog` | main-process IPC | not abstracted |
|
||||
| settings panel section | route-anchored container in Settings tab | not abstracted |
|
||||
|
||||
## See also
|
||||
|
||||
- [`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md) —
|
||||
governing plan and phase rollout
|
||||
- [`automation.md`](./automation.md) — harness architecture and the
|
||||
SIGUSR1 / runtime-attach pattern
|
||||
- [`ui/`](./ui/) — per-surface visual / behavior specs
|
||||
- [`cases/`](./cases/) — functional test specs (T## / S##)
|
||||
415
docs/testing/claudeai-ui-mapping-plan.md
Normal file
415
docs/testing/claudeai-ui-mapping-plan.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# claude.ai UI Mapping Plan
|
||||
|
||||
This is an executable plan for systematically mapping claude.ai's
|
||||
renderer UI into reusable test-harness abstractions. It can be picked
|
||||
up by a fresh session — start at "Phase 1" and walk down.
|
||||
|
||||
## Where we are
|
||||
|
||||
The harness already has one worked example: `tools/test-harness/src/lib/claudeai.ts`
|
||||
exports a `CodeTab` class plus atom helpers (`activateTab`,
|
||||
`installOpenDialogMock`, `findCompactPills`, `openPill`, `clickMenuItem`,
|
||||
`pressEscape`). `T17_folder_picker.spec.ts` is its only consumer
|
||||
today — drives the chain `Code df-pill → env pill → Local → Select
|
||||
folder → Open folder` and asserts `dialog.showOpenDialog` fires.
|
||||
|
||||
Discovery evidence captured by `tools/test-harness/probe.ts` (run
|
||||
against a live debugger on port 9229):
|
||||
|
||||
- df-pill is a stable atom — exactly 3 instances on Code-tab page
|
||||
(`Chat`, `Cowork`, `Code`), all with `class*="df-pill"` and
|
||||
matching `aria-label`.
|
||||
- compact-pill is a stable atom — `button[aria-haspopup=menu]` with
|
||||
a `span.truncate.max-w-[Npx]` child. Env pill uses 200px,
|
||||
Select-folder pill uses 160px. Same Tailwind class signature; we
|
||||
anchor on structure, not classes.
|
||||
- 80 `button[aria-haspopup=menu]` total on a Code-tab page; only the
|
||||
2 with the truncate fingerprint are pills, the other 78 are sidebar
|
||||
"More options" buttons.
|
||||
|
||||
Pattern proven: discovery-by-shape in the lib layer, page-object
|
||||
classes per major UI surface, specs use the lib. This doc covers
|
||||
how to extend that pattern across the rest of claude.ai.
|
||||
|
||||
## Strategy: three layers
|
||||
|
||||
**Layer 1 — atoms.** Generic helpers around stable structural
|
||||
patterns. Live in `lib/claudeai.ts`. Built once, reused everywhere.
|
||||
Examples already there: compact-pill, df-pill, menu, dialog mock.
|
||||
|
||||
**Layer 2 — page objects.** Domain classes per major UI surface
|
||||
(CodeTab, ChatTab, Settings, etc.). Compose atoms. Built per test
|
||||
demand — premature otherwise. CodeTab is the template.
|
||||
|
||||
**Layer 3 — discovery tooling.** Standalone scripts that connect to
|
||||
a running debugger and let humans + agents explore the renderer.
|
||||
`probe.ts` is the seed; this doc grows it into a small CLI.
|
||||
|
||||
The thing to avoid: comprehensively mapping the UI upfront. Even
|
||||
with a recording tool, that burns time on surfaces no test will
|
||||
exercise for months. Lazy + bookmark-the-shape wins.
|
||||
|
||||
## Phase 1 — Tooling foundation
|
||||
|
||||
**Goal:** turn `probe.ts` into a proper exploration CLI under
|
||||
`tools/test-harness/explore/`, with snapshot + diff capability that
|
||||
catches UI drift before tests do.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `tools/test-harness/explore/explore.ts` — entry point with
|
||||
subcommands.
|
||||
- `tools/test-harness/explore/snapshot.ts` — capture renderer state.
|
||||
- `tools/test-harness/explore/diff.ts` — compare two snapshots.
|
||||
- `tools/test-harness/explore/find.ts` — search for elements.
|
||||
- `docs/testing/ui-snapshots/` — directory for captured snapshots
|
||||
(gitignore the file contents but commit the directory + a README).
|
||||
- `tools/test-harness/package.json` — add scripts:
|
||||
`npm run explore`, `npm run explore:snapshot <name>`, etc.
|
||||
|
||||
**Subcommand spec:**
|
||||
|
||||
```
|
||||
npx tsx explore/explore.ts # full snapshot to stdout
|
||||
npx tsx explore/explore.ts pills # df-pills + compact-pills + state
|
||||
npx tsx explore/explore.ts menu # currently-open menu structure
|
||||
npx tsx explore/explore.ts snapshot <name> # write to docs/testing/ui-snapshots/<name>.json
|
||||
npx tsx explore/explore.ts diff <a> <b> # diff two snapshots — flags renamed/removed
|
||||
npx tsx explore/explore.ts find <regex> # search renderer for matching text/aria-label
|
||||
```
|
||||
|
||||
Snapshot shape (per file):
|
||||
|
||||
```json
|
||||
{
|
||||
"capturedAt": "2026-05-02T17:30:00Z",
|
||||
"claudeAiUrl": "https://claude.ai/epitaxy",
|
||||
"appVersion": "1.1.7714",
|
||||
"dfPills": [...],
|
||||
"compactPills": [...],
|
||||
"ariaLabeledButtons": [...],
|
||||
"openMenu": null,
|
||||
"modals": [...]
|
||||
}
|
||||
```
|
||||
|
||||
`diff` should flag: removed elements (selector → no match), changed
|
||||
text/aria-label, new elements (informational, not a failure). Output
|
||||
human-readable + a `--json` flag for machine consumption.
|
||||
|
||||
**How to dispatch this work:**
|
||||
|
||||
Single agent, `general-purpose`. Brief:
|
||||
|
||||
> Build the explore CLI under `tools/test-harness/explore/`. Read
|
||||
> `tools/test-harness/probe.ts` as the seed implementation. Match the
|
||||
> existing project style (tabs, multi-line `//` why-blocks, terse).
|
||||
> Reuse `src/lib/inspector.ts` (`InspectorClient.connect(9229)`) for
|
||||
> the debugger connection. Subcommands as specified in
|
||||
> `docs/testing/claudeai-ui-mapping-plan.md` Phase 1. Do not delete
|
||||
> probe.ts — leave it as a one-off; it can be removed in a follow-up.
|
||||
> Typecheck with `npx tsc --noEmit` (no test runs). Add npm scripts
|
||||
> to `package.json`. Add a thin README in
|
||||
> `docs/testing/ui-snapshots/README.md` explaining how to capture +
|
||||
> compare snapshots.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- `npx tsx explore/explore.ts pills` against a running debugger lists
|
||||
the 3 df-pills and 2 compact-pills (or whatever's on screen).
|
||||
- `explore/explore.ts snapshot baseline-code-tab` writes a JSON file.
|
||||
- `explore/explore.ts diff baseline-code-tab baseline-code-tab`
|
||||
reports zero diffs.
|
||||
- Typecheck green.
|
||||
|
||||
## Phase 2 — UI map document
|
||||
|
||||
**Goal:** maintain a living markdown index of every reachable UI
|
||||
surface, the navigation path to reach it, and which Layer-2 class
|
||||
covers it (or `TODO` if none yet).
|
||||
|
||||
**Deliverable:** `docs/testing/claudeai-ui-map.md`.
|
||||
|
||||
**Initial content** (populate from what's known today, leave gaps
|
||||
marked TODO):
|
||||
|
||||
```markdown
|
||||
# claude.ai UI Map
|
||||
|
||||
Source of truth for "where does each UI surface live, and which
|
||||
test-harness abstraction covers it." Update as new abstractions are
|
||||
added.
|
||||
|
||||
## Top-level routes
|
||||
|
||||
- `/new` — chat composer page (default landing for signed-in users)
|
||||
- `/chat/<uuid>` — open chat session
|
||||
- `/epitaxy` — Code tab landing
|
||||
- `/projects/<id>` — project view
|
||||
- `/login`, `/auth/*` — pre-login routes (test harness skips here)
|
||||
|
||||
## Surfaces by tab
|
||||
|
||||
### Chat (df-pill "Chat", route /new)
|
||||
- Composer textarea — TODO `ChatTab.composer()`
|
||||
- "+" submenu (Add files / Add to project / Skills / Connectors / ...)
|
||||
— TODO `ChatTab.openAttachMenu()`
|
||||
- Model selector — TODO
|
||||
- Stop / regenerate — TODO
|
||||
|
||||
### Cowork (df-pill "Cowork")
|
||||
- Workspace list — TODO
|
||||
- Environment switcher — TODO
|
||||
|
||||
### Code (df-pill "Code", route /epitaxy)
|
||||
- Env pill (Local / Cloud / SSH) — `lib/claudeai.ts:CodeTab.openEnvPill()` ✓
|
||||
- Select folder pill — `lib/claudeai.ts:CodeTab` (used internally by
|
||||
`openFolderPicker`) ✓
|
||||
- Folder picker dialog — `lib/claudeai.ts:installOpenDialogMock` ✓
|
||||
- File tree (left panel) — TODO
|
||||
- Editor pane — TODO
|
||||
|
||||
## Surfaces independent of tab
|
||||
|
||||
### Sidebar
|
||||
- Search — TODO `SidebarNav.search()`
|
||||
- Recent conversations — TODO `SidebarNav.openRecent(idx | uuid)`
|
||||
- "More options" per row — TODO
|
||||
- New session button — TODO
|
||||
|
||||
### Native dialogs
|
||||
- File / folder picker — `lib/claudeai.ts:installOpenDialogMock` ✓
|
||||
- Message box / confirm — TODO `installShowMessageBoxMock`
|
||||
- Save dialog — TODO `installShowSaveDialogMock`
|
||||
|
||||
### Menus / popovers
|
||||
- Generic menu open + click — `lib/claudeai.ts:openPill` /
|
||||
`clickMenuItem` ✓
|
||||
- Modal — TODO `Modal.dismiss() / Modal.confirm()`
|
||||
- Toast / status — TODO `waitForToast(regex)`
|
||||
|
||||
### Settings
|
||||
- Hotkey rebind — TODO
|
||||
- Theme toggle — TODO
|
||||
- Account / sign-out — TODO
|
||||
|
||||
## Atoms inventory
|
||||
|
||||
Stable structural patterns the lib already anchors on:
|
||||
|
||||
| Atom | Fingerprint | Helper |
|
||||
|---|---|---|
|
||||
| df-pill | `button[aria-label][class*="df-pill"]` | `activateTab(name)` |
|
||||
| compact-pill | `button[aria-haspopup=menu] > span.truncate.max-w-[*]` | `findCompactPills`, `openPill` |
|
||||
| menu / menuitem | `[role=menu] [role=menuitem*]` | `clickMenuItem(regex)` |
|
||||
|
||||
Atoms not yet abstracted (when a third test needs the same shape,
|
||||
promote to a top-level helper):
|
||||
|
||||
| Atom | Probable fingerprint | Status |
|
||||
|---|---|---|
|
||||
| modal | `[role=dialog]` | not seen yet |
|
||||
| toast | `[role=status][aria-live]` | not seen yet |
|
||||
| sidebar nav row | `[class*="df-row"] [aria-label]` | seen, not abstracted |
|
||||
| chat composer | textarea/contenteditable in composer container | not abstracted |
|
||||
```
|
||||
|
||||
**How to dispatch this work:**
|
||||
|
||||
A claude-code-guide or general-purpose agent can write the initial
|
||||
file. Single message:
|
||||
|
||||
> Create `docs/testing/claudeai-ui-map.md` matching the structure in
|
||||
> `docs/testing/claudeai-ui-mapping-plan.md` Phase 2. Pull TODO
|
||||
> entries from the planned ChatTab/Settings/etc. surfaces. Mark
|
||||
> existing helpers from `tools/test-harness/src/lib/claudeai.ts`
|
||||
> with ✓ and the file:line. Don't run any tests.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- File exists with all top-level routes documented.
|
||||
- Every existing `lib/claudeai.ts` export is referenced ✓.
|
||||
- Every planned surface from this plan has a TODO entry.
|
||||
|
||||
## Phase 3 — Page objects per test demand
|
||||
|
||||
**Goal:** add new Layer-2 classes (ChatTab, Settings, etc.) when the
|
||||
first test needs them. Don't speculate.
|
||||
|
||||
**Template:** `tools/test-harness/src/lib/claudeai.ts:CodeTab`. Match
|
||||
its shape:
|
||||
|
||||
- Instance class taking `inspector: InspectorClient` in constructor.
|
||||
- Public methods are either single-step (`openEnvPill`,
|
||||
`selectLocal`) or multi-step convenience (`openFolderPicker`).
|
||||
- Discovery by shape, not Tailwind classes.
|
||||
- Multi-line `//` why-block at top of class explaining what UI
|
||||
surface it covers and the discovery strategy.
|
||||
- Failures throw with enough context for the spec to attach to
|
||||
`testInfo.attach()`.
|
||||
|
||||
**Workflow per new page object:**
|
||||
|
||||
1. Identify which test motivates the new class. Don't build
|
||||
speculatively.
|
||||
2. Run `explore.ts snapshot <name>` against a live debugger on the
|
||||
target UI surface. Commit the snapshot under
|
||||
`docs/testing/ui-snapshots/`.
|
||||
3. Inspect the snapshot — pick stable structural fingerprints, not
|
||||
Tailwind classes.
|
||||
4. Write the class in `lib/claudeai.ts`. If the file gets large
|
||||
(>1500 lines), split per-tab into separate files
|
||||
(`lib/claudeai/code-tab.ts`, `lib/claudeai/chat-tab.ts`, with
|
||||
`lib/claudeai.ts` as the barrel).
|
||||
5. Update `docs/testing/claudeai-ui-map.md` — replace the TODO with
|
||||
the class name + ✓.
|
||||
6. Add the spec that uses it.
|
||||
7. Run typecheck. Don't run tests until everything's wired.
|
||||
|
||||
**Don't pull out yet:**
|
||||
|
||||
- Single-consumer methods. If only one spec calls
|
||||
`Settings.toggleDarkMode()`, the inline implementation is fine.
|
||||
Promote to its own method when a second consumer arrives.
|
||||
- Generic primitives that haven't repeated three times. Three is
|
||||
the threshold for "this is an atom" — two could still be
|
||||
coincidence.
|
||||
|
||||
## Phase 4 — Atom promotion
|
||||
|
||||
**Goal:** keep the atom layer (Layer 1) growing in step with the
|
||||
page-object layer (Layer 2).
|
||||
|
||||
**Rule:** when a discovery pattern (CSS selector + JS predicate)
|
||||
appears in 3 different page objects, promote it to a top-level
|
||||
helper in `lib/claudeai.ts`.
|
||||
|
||||
**Examples of likely promotions in the next 6 months:**
|
||||
|
||||
- `findModal()` / `dismissModal()` — every page object that opens a
|
||||
confirmation modal will need this.
|
||||
- `waitForToast(regex, timeout)` — error and success toasts are
|
||||
pervasive.
|
||||
- `installShowMessageBoxMock(inspector, response)` — for native
|
||||
confirm dialogs.
|
||||
- `clickNavRow(label)` — sidebar interactions.
|
||||
|
||||
**Process:**
|
||||
|
||||
1. Notice the third occurrence of the same pattern.
|
||||
2. Move the inline implementation up to a top-level export.
|
||||
3. Replace the three call sites with calls to the new export.
|
||||
4. Add an entry to the atoms inventory in `claudeai-ui-map.md`.
|
||||
|
||||
## Phase 5 — Drift detection
|
||||
|
||||
**Goal:** catch UI changes that break selectors *before* a sweep
|
||||
fails — fast, automatic, runs on every harness invocation.
|
||||
|
||||
**Deliverable:** `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`.
|
||||
|
||||
**Design:**
|
||||
|
||||
- Loads each `*.json` file from `docs/testing/ui-snapshots/`.
|
||||
- Connects to a running app via the existing `launchClaude` +
|
||||
`attachInspector` flow (NOT against an externally-running app —
|
||||
the harness must be self-contained).
|
||||
- For each snapshot, navigates to the captured URL (if not already
|
||||
there), then asserts each captured selector still resolves to an
|
||||
element with the same text/aria-label.
|
||||
- Failures are *attachments*, not full failures — the spec passes
|
||||
if ≥80% of snapshots match, surfaces the diffs as warnings. Hard
|
||||
threshold can be tightened later. Goal is "tell me what drifted,"
|
||||
not "block CI on every minor renderer change."
|
||||
|
||||
**How to dispatch:**
|
||||
|
||||
Single agent, after Phases 1–2 are done. Brief:
|
||||
|
||||
> Create `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`
|
||||
> per the design in `docs/testing/claudeai-ui-mapping-plan.md`
|
||||
> Phase 5. Read each `*.json` under `docs/testing/ui-snapshots/`,
|
||||
> drive the renderer to the captured URL, assert each captured
|
||||
> element selector still matches. Surface diffs via
|
||||
> `testInfo.attach`. Pass if ≥80% match. Severity Should, surface
|
||||
> "claude.ai UI drift detection". Typecheck only.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Runs cleanly against current renderer state (all snapshots match).
|
||||
- Returns ≤200ms per snapshot.
|
||||
- Skip with a clear message when no signed-in host config available
|
||||
(most snapshots will be of post-login surfaces).
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. **Phase 1 (tooling)** — ~2 hours, single agent. Foundation for
|
||||
everything else.
|
||||
2. **Phase 2 (UI map doc)** — ~30 min, single agent. Cheap,
|
||||
self-documenting.
|
||||
3. **Phase 3 (page objects)** — incremental, per test need.
|
||||
4. **Phase 4 (atom promotion)** — opportunistic, no scheduled work.
|
||||
5. **Phase 5 (drift detection)** — once Phase 1 is done and a few
|
||||
snapshots exist.
|
||||
|
||||
Phases 1 and 2 are independent and can run in parallel.
|
||||
|
||||
## Today's starting state (reference)
|
||||
|
||||
What's already in place as of session-end:
|
||||
|
||||
```
|
||||
tools/test-harness/
|
||||
├── probe.ts # one-off probe (Phase 1 seed)
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── claudeai.ts # CodeTab + atoms (NEW today)
|
||||
│ │ ├── electron.ts # SIGINT cleanup, lastExitInfo
|
||||
│ │ ├── inspector.ts # idempotent close()
|
||||
│ │ ├── quickentry.ts # disk-read getStoredPosition
|
||||
│ │ └── ... (unchanged)
|
||||
│ └── runners/
|
||||
│ ├── H01_cdp_gate_canary.spec.ts # NEW
|
||||
│ ├── H02_frame_fix_wrapper_present.spec.ts # NEW
|
||||
│ ├── H03_patch_fingerprints.spec.ts # NEW
|
||||
│ ├── H04_cowork_daemon_lifecycle.spec.ts # NEW
|
||||
│ ├── T17_folder_picker.spec.ts # refactored to lib/claudeai.ts
|
||||
│ ├── _investigate_t17_urls.spec.ts # one-off, can be deleted
|
||||
│ └── ... (T01/T03/T04, S09/S12, S29-S37)
|
||||
├── orchestrator/sweep.sh # multi-suite JUnit parser
|
||||
└── playwright.config.ts # CI-gated retries + forbidOnly
|
||||
```
|
||||
|
||||
**Pending cleanup** (covered in a final commit, not part of this plan):
|
||||
|
||||
- Delete `_investigate_t17_urls.spec.ts` — investigation served.
|
||||
- Delete `probe.ts` once `explore/` lands and supersedes it.
|
||||
- Update `tools/test-harness/README.md` Status table — T17 from
|
||||
"selector-tuning pending" to passing on KDE-W.
|
||||
|
||||
**Useful commands for a fresh session:**
|
||||
|
||||
```sh
|
||||
cd /home/aaddrick/source/claude-desktop-debian/tools/test-harness
|
||||
|
||||
# Typecheck (must pass after every edit)
|
||||
npx tsc --noEmit
|
||||
|
||||
# Run a single spec
|
||||
ROW=KDE-W CLAUDE_TEST_USE_HOST_CONFIG=1 npx playwright test \
|
||||
src/runners/T17_folder_picker.spec.ts --reporter=list
|
||||
|
||||
# Full sweep
|
||||
ROW=KDE-W CLAUDE_TEST_USE_HOST_CONFIG=1 ./orchestrator/sweep.sh
|
||||
|
||||
# Probe a running app (requires main process debugger enabled)
|
||||
npx tsx probe.ts
|
||||
|
||||
# Kill stale instances before launch
|
||||
pkill -9 -f claude-desktop; pkill -9 -f mount_claude
|
||||
```
|
||||
|
||||
**Before starting Phase 1:** open Claude Desktop, enable
|
||||
`Developer → Enable Main Process Debugger` from the menu, navigate
|
||||
to a known UI state. Then run `npx tsx probe.ts` to confirm the
|
||||
inspector is reachable on port 9229.
|
||||
490
docs/testing/fingerprint-v7-plan.md
Normal file
490
docs/testing/fingerprint-v7-plan.md
Normal file
@@ -0,0 +1,490 @@
|
||||
# Fingerprint v7 Plan — Contextual, Account-Portable Identification
|
||||
|
||||
This is an executable plan for the v6 → v7 migration of the inventory
|
||||
fingerprint shape used by `tools/test-harness/explore/walker.ts` and
|
||||
`tools/test-harness/src/runners/U01_ui_visibility.spec.ts`. It can be
|
||||
picked up by a fresh session — start at "Phase 1" and walk down.
|
||||
|
||||
## Where we are
|
||||
|
||||
`docs/testing/ui-inventory.json` v6 (captured 2026-05-03 against app
|
||||
1.5354.0, 383 entries) records each interactive element with a
|
||||
fingerprint of this shape:
|
||||
|
||||
```ts
|
||||
fingerprint: {
|
||||
selector: 'button[aria-label="Search"]',
|
||||
ariaLabel: 'Search',
|
||||
role: null,
|
||||
tagName: 'BUTTON',
|
||||
textContent: null,
|
||||
}
|
||||
```
|
||||
|
||||
`U01` resolves entries by handing the `selector` field to Playwright.
|
||||
The current scheme has three load-bearing failure modes:
|
||||
|
||||
1. **Account-specific names baked into selectors and IDs.** Entries
|
||||
like `root.button.awaaddrick-max` (the user's plan badge,
|
||||
`button:has-text("AWAaddrick·Max")`) hardcode the walker-author's
|
||||
username + plan tier. Any contributor running U01 against their
|
||||
own auth fails this entry on selector match — the element is
|
||||
structurally present, just labeled differently.
|
||||
2. **Instance text in selectors of "stable" entries.** Search-result
|
||||
options, recent-conversations buttons, and pinned conversations
|
||||
carry titles like "Fine-tuning diffusion models with reinforcement
|
||||
learning" in their selectors. These are inherently per-account; the
|
||||
`kind: instance` taxonomy already exists to handle them, but the
|
||||
selector still encodes the literal title, so the v6 capture
|
||||
couldn't actually leverage `instance` semantics.
|
||||
3. **Selector brittleness under cosmetic redesigns.** `button:has-text(...)`
|
||||
selectors break under any label change. `button[aria-label="..."]`
|
||||
selectors break under any aria-label rewrite (which the upstream
|
||||
team does for accessibility audits without warning). Neither
|
||||
strategy carries enough redundancy to recover when one signal drifts.
|
||||
|
||||
The reconciliation doc (`ui-inventory-reconciliation.md`) flags these
|
||||
as "Walker coverage gap" and "Account-state-dependent" categories,
|
||||
and the U01 brief lists per-user inventory regeneration as "a
|
||||
separate workstream." This is that workstream.
|
||||
|
||||
## Design goals
|
||||
|
||||
In priority order:
|
||||
|
||||
1. **Account-portable.** A v7 inventory walked against User A's
|
||||
account matches against User B's renderer for any entry whose
|
||||
target element is structurally present in both accounts. Entries
|
||||
that genuinely don't exist in B's account fall back to the existing
|
||||
"skip if absent" semantics (`kind: instance` + ancestor-presence
|
||||
check).
|
||||
2. **Resilient to cosmetic drift.** Label changes, aria-label
|
||||
rewrites, minified-class churn, and CSS rewrites must not
|
||||
invalidate the fingerprint when the element's semantic role and
|
||||
structural position survive.
|
||||
3. **Surface drift before failure.** Soft drift (primary aria-path
|
||||
missed, relaxed-scope match recovered) attaches a warning to the
|
||||
test rather than passing silently. Hard drift (no strategy
|
||||
resolves) fails as today. The sweep gains a third state:
|
||||
`passed-with-drift`.
|
||||
4. **Atomic cutover, not gradual migration.** v7 walker, v7 inventory
|
||||
schema, and v7 resolver land together. The committed v6 inventory
|
||||
gets invalidated the moment v7 walker ships; no parallel-emit
|
||||
compatibility window, no `legacy` selector fallback in the
|
||||
resolver. Two systems are worse than one.
|
||||
|
||||
Non-goals:
|
||||
|
||||
- Pixel-level visual diff. Separate concern; H05 is the right shape.
|
||||
- AI / embedding-based matching. Out of scope for a Linux repackager.
|
||||
- Behavioral fingerprints (click-and-verify-effect). Too expensive at
|
||||
383 entries.
|
||||
|
||||
## v7 schema
|
||||
|
||||
```ts
|
||||
interface FingerprintV7 {
|
||||
// Primary: accessibility-tree path from nearest landmark down to
|
||||
// the leaf. Each step carries (role, optional name).
|
||||
ariaPath: AriaStep[];
|
||||
|
||||
// The element itself. Drops `name` entirely when role + ariaPath
|
||||
// suffice for uniqueness on the captured surface.
|
||||
leaf: {
|
||||
role: string; // "button", "link", "menuitem", ...
|
||||
name: NameMatcher | null;
|
||||
siblingIndex: SiblingIndex | null;
|
||||
};
|
||||
|
||||
// Stability classification — drives how strictly the resolver
|
||||
// matches. See "Kind-strictness matrix" below. Distinct from the
|
||||
// existing `kind` field (persistent / structural / menu / instance)
|
||||
// which captures *lifecycle*, not *match strictness*.
|
||||
classification: 'stable' | 'positional' | 'instance';
|
||||
}
|
||||
|
||||
interface AriaStep {
|
||||
role: string; // landmark / region / grouping role
|
||||
name: NameMatcher | null; // optional — only included when needed
|
||||
}
|
||||
|
||||
type NameMatcher =
|
||||
| { kind: 'literal'; value: string } // "Search", "Cowork"
|
||||
| { kind: 'pattern'; regex: string }; // "\\w+·(Free|Pro|Max|...)"
|
||||
|
||||
interface SiblingIndex {
|
||||
role: string; // role of siblings being indexed
|
||||
position: number; // 0-based
|
||||
total: number; // total siblings of that role at capture
|
||||
}
|
||||
```
|
||||
|
||||
## Capture algorithm
|
||||
|
||||
Run during walker.ts's element emission, after the surface has settled.
|
||||
|
||||
```text
|
||||
captureFingerprint(element, surface):
|
||||
ariaPath = walkLandmarkAncestors(element)
|
||||
// Stop at <body>; emit a step for each role in
|
||||
// {banner, main, navigation, region, complementary,
|
||||
// contentinfo, search, form, toolbar, menu, menubar,
|
||||
// listbox, list, dialog, tablist, tabpanel, group}
|
||||
// with grouping role plus optional accessible name.
|
||||
|
||||
role = element.role
|
||||
name = element.accessibleName
|
||||
|
||||
// Step 1: try uniqueness without the name.
|
||||
matches = surface.queryAccessibleTree({
|
||||
ariaPath,
|
||||
leaf: { role }
|
||||
})
|
||||
if matches.length == 1:
|
||||
return { ariaPath, leaf: { role, name: null, siblingIndex: null },
|
||||
classification: 'stable' }
|
||||
|
||||
// Step 2: still too broad — try the name as a discriminator,
|
||||
// shaping it if it looks instance-specific.
|
||||
classification = classifyName(name, surface)
|
||||
if classification != 'instance':
|
||||
nameMatcher = (classification == 'positional')
|
||||
? null
|
||||
: (looksInstanceShaped(name)
|
||||
? { kind: 'pattern', regex: shapeOfName(name) }
|
||||
: { kind: 'literal', value: name })
|
||||
matches = surface.queryAccessibleTree({
|
||||
ariaPath, leaf: { role, name: nameMatcher }
|
||||
})
|
||||
if matches.length == 1:
|
||||
return { ariaPath, leaf: { role, name: nameMatcher,
|
||||
siblingIndex: null },
|
||||
classification }
|
||||
|
||||
// Step 3: still ambiguous — fall through to sibling position.
|
||||
siblings = element.parent.childrenWithRole(role)
|
||||
if siblings.length > 1:
|
||||
siblingIndex = {
|
||||
role,
|
||||
position: siblings.indexOf(element),
|
||||
total: siblings.length
|
||||
}
|
||||
return { ariaPath, leaf: { role, name: null, siblingIndex },
|
||||
classification: 'positional' }
|
||||
|
||||
// Step 4: instance — assert ≥1 match within ariaPath.
|
||||
return { ariaPath, leaf: { role, name: null, siblingIndex: null },
|
||||
classification: 'instance' }
|
||||
```
|
||||
|
||||
`queryAccessibleTree` should hit `Accessibility.getFullAXTree` over
|
||||
CDP, not the DOM. The accessibility tree is what screen readers see
|
||||
and what the platform APIs query — it's the substrate that aria
|
||||
roles and accessible names actually live in.
|
||||
|
||||
## Name classifier
|
||||
|
||||
`classifyName(name, surface)` decides whether a name is `stable`,
|
||||
`instance`, or `positional` (no usable name). Heuristics in priority
|
||||
order:
|
||||
|
||||
```text
|
||||
1. Empty / whitespace name → 'positional'
|
||||
2. Element is a list-row child → 'instance' (handled by ancestor
|
||||
role: option/listitem inside listbox/list)
|
||||
3. Name matches a known
|
||||
instance-shape regex → 'instance' (record as pattern)
|
||||
4. Name is in the corpus of
|
||||
"stable UI vocabulary" → 'stable'
|
||||
5. Default → 'stable' but flag for review
|
||||
```
|
||||
|
||||
### Known instance-shape regexes
|
||||
|
||||
| Regex | Example match | Shape recorded |
|
||||
|---|---|---|
|
||||
| `/^.+·(Free\|Pro\|Max\|Team\|Enterprise)$/` | `AWAaddrick·Max` | `\\w+·<PLAN>` |
|
||||
| `/^Opus \d/` `/^Sonnet \d/` `/^Haiku \d/` | `Opus 4.7Adaptive` | model-name passthrough (stable across users, just versioned) |
|
||||
| `/\d{1,3}%$/` | `Usage: plan 11%` | `Usage: plan \d+%` |
|
||||
| `/Today\|Yesterday\|\d+ (day\|hour\|minute)s? ago/` | `Today+12` | `<RELATIVE-DATE>(\\+\d+)?` |
|
||||
| `/^\d+\.\d+ \w+/` | `1.5 GB` | `\d+\.\d+ \w+` |
|
||||
| `/@\w+/` | `@aaddrick` | `@\w+` (treat as user-handle) |
|
||||
| `/[A-Z][a-z]+ [A-Z][a-z]+ [a-z]/` (3+ word title-case) | `Fine-tuning diffusion models...` | treat as `'instance'`, no pattern |
|
||||
|
||||
These regexes live in a registry that's part of the v7 capture
|
||||
config. Adding a new shape is a one-file change; the registry should
|
||||
be ordered (first match wins) so specific patterns take precedence
|
||||
over general ones.
|
||||
|
||||
### Building the stable UI vocabulary
|
||||
|
||||
After the walker finishes the BFS, run a second pass:
|
||||
|
||||
1. Collect every `accessibleName` from every captured element.
|
||||
2. Bucket by `kind` (existing taxonomy).
|
||||
3. Names appearing in 3+ entries with `kind: persistent` or
|
||||
`kind: structural`, across 2+ surfaces, are **stable**.
|
||||
4. Names appearing in only 1 entry with `kind: persistent`/`structural`
|
||||
are **suspect** — flag for human triage during reconciliation.
|
||||
5. Names in `kind: instance` entries are excluded from the corpus
|
||||
entirely.
|
||||
|
||||
Commit the resulting vocabulary list to
|
||||
`docs/testing/ui-vocabulary.json` so future walks can use it without
|
||||
re-deriving. Refresh the vocabulary on each major upstream release.
|
||||
|
||||
## Kind-strictness matrix
|
||||
|
||||
The existing `kind` field (`persistent` / `structural` / `menu` /
|
||||
`instance`) tunes how strictly the resolver matches at runtime,
|
||||
independently from the capture-time `classification`:
|
||||
|
||||
| kind | aria-path required | name required | siblingIndex strict | assertion |
|
||||
|---|---|---|---|---|
|
||||
| `persistent` | yes (deepest scope) | matcher must hit if present | yes | exactly 1 match |
|
||||
| `structural` | yes (or 1 step shallower) | matcher OR position | flexible (±1 ok) | exactly 1 match |
|
||||
| `menu` | yes, scoped to transient menu surface | literal text fallback ok | n/a | ≥1 match |
|
||||
| `instance` | yes (closest list/listbox ancestor) | ignored | ignored | ≥1 match within scope |
|
||||
|
||||
Examples:
|
||||
|
||||
- `root.button.search` → `kind: persistent`, `classification: stable`,
|
||||
`name: null` (unique by ariaPath alone). Strict 1-match assertion.
|
||||
- `root.button.awaaddrick-max` → `kind: persistent`, `classification: stable`,
|
||||
`name: { kind: 'pattern', regex: '\\w+·(Free|Pro|Max|...)' }`.
|
||||
Plan-shape pattern; user-portable.
|
||||
- `root.button.search.option.untitled-conversationtoday+12` →
|
||||
`kind: instance`, `classification: instance`, no name, scoped to
|
||||
search-results listbox. Assert ≥1 option in listbox.
|
||||
- `root.button.fine-tuning-diffusion-models-with-reinforcement-learning` →
|
||||
`kind: instance`, scoped to pinned-conversations list. Assert ≥1
|
||||
button in pinned list.
|
||||
|
||||
## Resolver / fallback chain
|
||||
|
||||
In `findByFingerprint`:
|
||||
|
||||
```text
|
||||
resolve(fp):
|
||||
// Strategy 1 — primary: full aria-tree path
|
||||
result = tryAriaTreeMatch(fp.ariaPath, fp.leaf, fp.kind)
|
||||
if result.matched: return { found: true, strategy: 'aria-tree' }
|
||||
|
||||
// Strategy 2 — relaxed aria scope (drop deepest landmark step
|
||||
// in the path; keep the rest). Catches the common case where the
|
||||
// upstream team adds or removes one container layer.
|
||||
if fp.ariaPath.length > 1:
|
||||
result = tryAriaTreeMatch(fp.ariaPath.slice(0, -1), fp.leaf, fp.kind)
|
||||
if result.matched: return {
|
||||
found: true, strategy: 'aria-tree-relaxed', drift: 'scope-shifted'
|
||||
}
|
||||
|
||||
return { found: false, strategy: null }
|
||||
```
|
||||
|
||||
When `drift` is set, attach a soft warning to the Playwright test
|
||||
without failing it:
|
||||
|
||||
```ts
|
||||
testInfo.attach('drift-warning', {
|
||||
body: JSON.stringify({
|
||||
entryId: entry.id,
|
||||
expected: fp.ariaPath,
|
||||
matchedVia: result.strategy,
|
||||
drift: result.drift,
|
||||
note: 'primary aria-tree match failed; recovered via fallback. ' +
|
||||
'Re-walk inventory before drift compounds.',
|
||||
}, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
```
|
||||
|
||||
CI exposes `drift-warning` as a separate counter alongside pass /
|
||||
fail. Sweep summary becomes `383 passed, 12 with drift, 0 failed`.
|
||||
|
||||
## Migration plan
|
||||
|
||||
The cutover is atomic — no parallel-emit window. Walker, schema, and
|
||||
resolver all flip from v6 to v7 in the same merge. The committed v6
|
||||
inventory becomes invalid; first action after merge is a re-walk.
|
||||
|
||||
### Phase 1 — vocabulary scaffold (pre-walker)
|
||||
|
||||
The name classifier needs a stable-UI vocabulary corpus to
|
||||
disambiguate suspect names from known-stable copy. Build it from the
|
||||
existing v6 inventory before the walker rewrite:
|
||||
|
||||
1. Iterate `docs/testing/ui-inventory.json` v6.
|
||||
2. Names appearing in 3+ entries with `kind: persistent` or
|
||||
`kind: structural`, across 2+ surfaces, are **stable**.
|
||||
3. Names matching any registry regex (plan badge, model version,
|
||||
percentage, relative date, user handle) are **instance-shaped**.
|
||||
4. Names appearing in only 1 entry, not matching a regex, not in
|
||||
`kind: instance` — flag for human triage.
|
||||
5. Commit the resulting corpus to `docs/testing/ui-vocabulary.json`.
|
||||
|
||||
The corpus survives the walker rewrite — it's keyed on names, not on
|
||||
v6 schema specifics.
|
||||
|
||||
### Phase 2 — walker rewrite
|
||||
|
||||
1. Add `Accessibility.getFullAXTree` query to walker's surface-settle
|
||||
step (or AX subtree at target node if full-tree latency is
|
||||
unacceptable; see open questions).
|
||||
2. Implement `walkLandmarkAncestors`, `queryAccessibleTree`,
|
||||
`captureFingerprint` per the algorithm above.
|
||||
3. Implement the name classifier consuming `ui-vocabulary.json` and
|
||||
the instance-shape registry.
|
||||
4. Replace v6 fingerprint emit with v7. Inventory schema header bumps
|
||||
to `walkerVersion: 7`; v6 readers will fail loudly rather than
|
||||
silently mis-resolve.
|
||||
5. Walker passes that fail to compute a v7 fingerprint (AX query
|
||||
error, accessible-name-computation failure) emit the entry with
|
||||
`classification: 'positional'` and `name: null`, scoped to its
|
||||
ariaPath. Uncaptured fingerprints are not silently dropped — they
|
||||
become positional entries with explicit looseness.
|
||||
|
||||
Acceptance: a walk against the v6-author's account produces v7
|
||||
fingerprints for ≥98% of the surfaces v6 captured. ≥80% have
|
||||
`classification: 'stable'`; the rest split between `'positional'` and
|
||||
`'instance'`.
|
||||
|
||||
#### Live-walk shakedown (post-Phase 2)
|
||||
|
||||
The first end-to-end walks against the running renderer surfaced five
|
||||
real bugs the synthetic selfTest couldn't see. All landed in
|
||||
`walker.ts` / `name-classifier.ts` / `inspector.ts`:
|
||||
|
||||
1. **AX-tree settle gate.** `Accessibility.enable` populates the tree
|
||||
asynchronously; the existing `waitForStable` (1.5s ceiling on
|
||||
DOM-mutation quiescence) returned long before claude.ai's React
|
||||
tree mounted. Seed snapshots came back with 4 AX nodes (just the
|
||||
`RootWebArea` + a generic shell) and the walker emitted zero
|
||||
entries. Fix: `waitForAxTreeStable(inspector, { minNodes: 20 })`
|
||||
polls `getFullAXTree` until two consecutive reads return the same
|
||||
node count. Called once before the seed snapshot and once after
|
||||
each `navigateTo` in `redrivePath`. Baked into every
|
||||
`snapshotSurface` call too (with `minNodes: 1`) so post-click
|
||||
reads don't race the React update.
|
||||
2. **`reloadPage` in `redrivePath`.** `navigateTo(url)` short-circuits
|
||||
when `currentUrl === url`, but every BFS pop re-navigates to
|
||||
`startUrl`, so any state a prior drill left behind (open dialog,
|
||||
expanded sidebar, scrolled focus) carried into the next redrive
|
||||
and contaminated `clickById`'s snapshot. Replaced the redrive's
|
||||
initial `navigateTo` with `location.reload()` to discard the
|
||||
React tree.
|
||||
3. **List-row sibling-count heuristic.** The plan's `isListRowChild`
|
||||
check requires `option/listitem` inside `listbox/list`. claude.ai
|
||||
exposes the marketplace dialog as `dialog > button[]` with no
|
||||
list role at all (~80 cards) and the cowork sidebar as
|
||||
`complementary > button[]` (72 sessions). Without a heuristic,
|
||||
each row literal-matches by name and emits as a separate stable
|
||||
entry. Extension: `LIST_ROW_ROLES` includes `button`,
|
||||
`LIST_ANCESTOR_ROLES` includes `group`, AND `siblingTotal >= 15`
|
||||
on its own qualifies regardless of ancestor role. Step 3
|
||||
(positional fallback) also gates on `!isListRowChild` so list
|
||||
rows fall through to step 4's `instance` collapse instead of
|
||||
fragmenting into per-index positionals.
|
||||
4. **Two new instance shapes** in `name-classifier.ts`:
|
||||
`cowork-session` matches status-prefixed session titles
|
||||
(`^(Idle|Ready|Working|Awaiting input|Pull request merged|Done|Failed|Cancelled)\s`)
|
||||
and `row-more-options` matches per-row triggers
|
||||
(`^More options for `). Both ordered before `long-title` so the
|
||||
pattern wins over the no-pattern instance fallback.
|
||||
5. **Lookup-failure threshold bump** 25 → 75. Sidebar virtualization
|
||||
means the AX tree exposes a slightly different subset of cowork
|
||||
sessions on each fresh load; redrives accumulate
|
||||
"no element matches" misses in a row that aren't a real wedge.
|
||||
The timeout counter (5 strikes) still gates against actual
|
||||
renderer hangs.
|
||||
|
||||
Result on the AX migration's first clean walk
|
||||
(`startUrl: claude.ai/epitaxy`, account: aaddrick, app 1.5354.0):
|
||||
**90 entries** (37 persistent / 37 structural / 8 dialog / 8
|
||||
instance), 6 denylisted, 23 non-fatal lookup misses. The marketplace
|
||||
dialog folded to a single `button-instance+704`; the cowork sidebar
|
||||
to `button-instance+72`; search history to `option-instance+25`.
|
||||
Acceptance criteria from §Phase 2 met (≥98% structural overlap is
|
||||
trivially true on a re-walk; ≥80% stable hit at 75/90 ≈ 83%).
|
||||
|
||||
### Phase 3 — resolver rewrite (U01 + walker.ts findByFingerprint)
|
||||
|
||||
1. Replace `findByFingerprint` body with the two-strategy chain
|
||||
(primary aria-tree, relaxed-scope fallback). Drop the v6
|
||||
selector code path entirely.
|
||||
2. `gen-render-specs.ts` regenerates U01 from the v7 inventory; per-
|
||||
entry test bodies consume `entry.fingerprint` (now v7-shaped)
|
||||
directly.
|
||||
3. Add the `drift-warning` attachment shape to U01's test runner.
|
||||
4. Run U01 against the v7 inventory captured in Phase 2; baseline
|
||||
drift counts.
|
||||
|
||||
Acceptance: U01 against a fresh walker pass produces 0 drift
|
||||
warnings on the same account, fails 0 entries. Drift warnings only
|
||||
appear when actually-drifted elements are encountered.
|
||||
|
||||
### Phase 4 — account-portability validation
|
||||
|
||||
1. A second contributor walks their own v7 inventory.
|
||||
2. Diff against the v6-author's v7 inventory: structural overlap
|
||||
should be ≥80% on `kind: persistent` and `kind: structural`
|
||||
entries (the cross-user-stable subset).
|
||||
3. Run the v6-author's inventory's U01 against the second
|
||||
contributor's renderer (with `seedFromHost` lifting their auth).
|
||||
4. Expect ≥80% pass on the cross-user-stable subset; `kind: instance`
|
||||
entries pass via the ancestor-presence check.
|
||||
|
||||
This is the actual goal. If account-portability hits, the inventory
|
||||
is no longer a "my-account snapshot" but a true render contract.
|
||||
|
||||
## Open questions
|
||||
|
||||
### Resolved
|
||||
|
||||
- **CDP `Accessibility.getFullAXTree` cost.** Not a bottleneck. The
|
||||
signed-in `claude.ai/epitaxy` surface returns a 817-node tree;
|
||||
`waitForAxTreeStable` settles in <1s once Chromium has populated
|
||||
it. The cold-load gate dominates total latency, not per-call
|
||||
overhead. Plan B (subtree queries at the target node) is unused.
|
||||
- **Role overrides.** Confirmed working. `Skip to content` on
|
||||
claude.ai is captured as `link` (its AX-computed role) regardless
|
||||
of the underlying tag — a class of mismatch the v6 DOM walker
|
||||
silently got wrong.
|
||||
- **`account-bound` kind.** Not needed. The combination of
|
||||
shape-patterned name matchers (plan badge, cowork session) +
|
||||
the sibling-count list heuristic + persistent collapse handles
|
||||
every account-shaped element observed in the first clean walk.
|
||||
Re-evaluate if a future surface exposes account state without
|
||||
one of those signals.
|
||||
|
||||
### Open
|
||||
|
||||
- **Accessible-name computation parity.** Chrome's AX-tree-computed
|
||||
name should match what Playwright's `getByRole({ name })` matches
|
||||
at resolution time, but they're independent implementations of
|
||||
the ARIA name-computation spec. Validate at Phase 3 acceptance
|
||||
with a sample of 50 entries — capture vs resolve should agree.
|
||||
- **Stale vocabulary across releases.** When upstream renames
|
||||
"Cowork" to "Workspaces" (hypothetical), the corpus needs to
|
||||
update. Should vocabulary be re-derived automatically on each walk
|
||||
(cheap, drift-following) or pinned to a committed version (stable,
|
||||
manual updates)? Provisionally: re-derive on walk, commit the
|
||||
derived corpus alongside the inventory so reconciliation can diff
|
||||
vocabulary changes.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `tools/test-harness/explore/walker.ts` — capture site
|
||||
- `tools/test-harness/explore/walk-isolated.ts` — driver that runs
|
||||
the walk inside the test-harness `launchClaude` + `seedFromHost`
|
||||
isolation path (use this rather than `explore walk` to avoid
|
||||
mutating the host profile)
|
||||
- `tools/test-harness/explore/gen-render-specs.ts` — emits U01 from
|
||||
inventory; needs to consume v7 fingerprints
|
||||
- `tools/test-harness/src/runners/U01_ui_visibility.spec.ts` —
|
||||
resolver consumer
|
||||
- `tools/test-harness/src/lib/inspector.ts` — `getAccessibleTree`
|
||||
+ `clickByBackendNodeId` for the AX-driven capture/click pair
|
||||
- `docs/testing/ui-inventory-reconciliation.md` — current v6 reconciliation
|
||||
- `docs/testing/claudeai-ui-mapping-plan.md` — broader UI mapping
|
||||
strategy this fits inside
|
||||
@@ -50,6 +50,14 @@ Status legend: `✓` pass · `✗` fail · `🔧` mitigated · `?` untested · `
|
||||
| [T38](./cases/code-tab-handoff.md#t38--continue-in-ide) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T39](./cases/code-tab-handoff.md#t39--desktop-cli-handoff-graceful-na) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
|
||||
## UI visibility (U-series)
|
||||
|
||||
Auto-generated render attestation: each entry in [`ui-inventory.json`](./ui-inventory.json) is asserted to mount with its recorded fingerprint on each platform. The single matrix cell aggregates every inventory entry — pass means every entry rendered, fail means at least one didn't (per-entry diagnostics in the JUnit attachments). Regenerate the spec with `npm run gen:render-specs` after re-walking. See [`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md) for the discovery + walker design.
|
||||
|
||||
| Test | KDE-W | KDE-X | GNOME | Ubu | Sway | i3 | Niri | Hypr-O | Hypr-N |
|
||||
|------|-------|-------|-------|-----|------|----|------|--------|--------|
|
||||
| [U01](../tools/test-harness/src/runners/U01_ui_visibility.spec.ts) — UI visibility | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
|
||||
## Environment-specific status
|
||||
|
||||
### Ubuntu / DEB
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# Quick Entry — Upstream Contract + Test Index
|
||||
# Quick Entry Closeout — Test Plan
|
||||
|
||||
Reference doc for the Quick Entry surface. Two halves:
|
||||
Focused sweep plan for closing the three open Quick Entry issues:
|
||||
|
||||
- [§ Upstream design intent](#upstream-design-intent) documents what upstream Quick Entry promises vs. doesn't, with code anchors into `build-reference/app-extracted/.vite/build/index.js`. Treat as the authoritative answer when triaging whether a Quick Entry behavior is a Linux compat regression (our problem) or upstream-by-design (not our problem).
|
||||
- [§ Test list](#test-list) enumerates the QE-N items as conceptual checks and maps each to the concrete S-N / T-N case that backs it. Spec headnotes (S09, S12, S31, S37) cite specific QE-N IDs by anchor; [§ Scaffold integration](#scaffold-integration) is the authoritative QE-N → S-N table.
|
||||
- [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393) — Submit doesn't open the main window (Ubuntu 24.04 GNOME and friends). Mitigated by [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)'s KDE-only gate; root cause is `BrowserWindow.isFocused()` returning stale-true on Linux Electron.
|
||||
- [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) — Shortcut doesn't fire from unfocused state on Fedora 43 GNOME. mutter no longer honours XWayland-side key grabs. Fix path: wire `--enable-features=GlobalShortcutsPortal` into the launcher on GNOME Wayland.
|
||||
- [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370) — Opaque square frame behind the transparent Quick Entry popup on KDE Wayland. Bisected to Electron 41.0.4 (electron/electron#50213); upstream regression. Workarounds in `frame-fix-wrapper.js` not yet attempted.
|
||||
|
||||
The QE-N items originated in the close-out sweep for [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393), [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), and [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370). The sweep has run; what remains is the upstream-contract reference + the test-index mapping.
|
||||
This doc is a **sweep plan**, not a test catalog. Test bodies and diagnostics live in [`cases/`](./cases/); the live status dashboard lives in [`matrix.md`](./matrix.md). The 21 `QE-*` items below map to existing `T*` / `S*` IDs where possible, and call out gaps to add as new `S*` cases.
|
||||
|
||||
## Goal
|
||||
|
||||
Pass all `QE-*` items in [§ Test list](#test-list) on every row in [§ Mandatory matrix](#mandatory-matrix). When that holds, all three issues are closeable (or, for #370, demonstrably blocked on upstream Electron with reproducible evidence).
|
||||
|
||||
## Upstream design intent
|
||||
|
||||
@@ -72,9 +77,9 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-14 | Should | Inspect popup background | Transparent; no opaque square frame visible behind the rounded UI. **Note:** upstream already sets `transparent: true` and `backgroundColor: "#00000000"` (`:515380, :515383`), so the #370 triage-bot suggestion to "try setting backgroundColor to transparent" is moot — those are already in place. The Electron 41.0.4 regression is at the CSD/shadow rendering layer below those flags, not at the option-passing layer. | [S10](./cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) |
|
||||
| QE-15 | Smoke | Inspect popup chrome | No titlebar, no close/min/max buttons (frameless) | — |
|
||||
| QE-16 | Smoke | Inspect popup edges | Drop shadow + rounded corners render (compositor-dependent — note where missing) | — |
|
||||
| QE-17 | Smoke | Open popup, then click on another window | Popup stays above (always-on-top) | — |
|
||||
| QE-15 | Smoke | Inspect popup chrome | No titlebar, no close/min/max buttons (frameless) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-16 | Smoke | Inspect popup edges | Drop shadow + rounded corners render (compositor-dependent — note where missing) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-17 | Smoke | Open popup, then click on another window | Popup stays above (always-on-top) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-18 | Should | `electron --version` against the running app's bundled binary; record version in matrix | When > 41.0.4 ships and #370 still reproduces, the upstream-regression hypothesis is wrong | [S33](./cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version) |
|
||||
|
||||
### Patch-application sanity — regression prevention
|
||||
@@ -87,7 +92,7 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
|
||||
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-21 | Smoke | In popup: `Esc` dismisses; click-outside dismisses; `Shift+Enter` inserts newline; `Enter` submits | All four behave as labelled. **Implementation notes for diagnostics:** click-outside is wired in the **main process** via the popup's `blur` handler (`:515465`). `Esc` / `Enter` / `Shift+Enter` are **renderer-side** (not visible in `index.js`); they go through IPC to `requestDismiss()` (`:515409`) and `requestDismissWithPayload()`. If a dismiss key fails, isolate which side is broken before reporting. | — |
|
||||
| QE-21 | Smoke | In popup: `Esc` dismisses; click-outside dismisses; `Shift+Enter` inserts newline; `Enter` submits | All four behave as labelled. **Implementation notes for diagnostics:** click-outside is wired in the **main process** via the popup's `blur` handler (`:515465`). `Esc` / `Enter` / `Shift+Enter` are **renderer-side** (not visible in `index.js`); they go through IPC to `requestDismiss()` (`:515409`) and `requestDismissWithPayload()`. If a dismiss key fails, isolate which side is broken before reporting. | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
|
||||
### Popup placement & lifecycle — upstream contract sanity
|
||||
|
||||
@@ -99,9 +104,91 @@ These verify upstream-promised behaviors that aren't directly broken by #393/#40
|
||||
| QE-23 | Smoke | **Multi-monitor required.** With an external monitor connected, invoke Quick Entry on the external monitor — let the position be saved (trigger QE-22's persistence path). Disconnect the external monitor (libvirt: `virsh detach-device` for the second display, or unplug the host monitor passing through). Invoke Quick Entry. | Popup falls back to the primary display via `cHn()` (`:515502`). Does **not** appear at off-screen coordinates. Skip this row in single-monitor VMs. | [S36](./cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone) |
|
||||
| QE-24 | Should | Launch app, focus main window, then **destroy** the main window without quitting the app. On this project the X button hide-to-tray override means the standard close path won't destroy `ut`; force the destroy via a) DevTools console (`Cmd+Opt+I` / `Ctrl+Shift+I` → `require('electron').remote.getCurrentWindow().destroy()` if exposed), or b) accept that this case is unreachable on Linux without a code change and skip. After destroy, invoke Quick Entry, type, submit. | Popup remains functional (lazy-recreation on shortcut press; the `!ut \|\| ut.isDestroyed()` guard at `:515595` skips the show/focus block but does not crash). New chat creation may not have a window to surface in — if app remains running with no main window, this is the "popup outlives main" path upstream guarantees. **If unreachable on Linux, mark this row N/A and document why.** | [S37](./cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy) |
|
||||
|
||||
## Mandatory matrix
|
||||
|
||||
The five rows below are the must-pass set to close all three issues. Display server is the **session selected at login** — KDE and GNOME both let you choose Wayland vs Xorg from the greeter.
|
||||
|
||||
| Row | Distro | DE | Display server | Closes / verifies | Reporter |
|
||||
|-----|--------|----|--------------:|-------------------|----------|
|
||||
| **GNOME-W** | Fedora 43 Workstation | GNOME 49.x | Wayland | #404 (S11/S12), #393 (QE-11/QE-12) | @gianluca-peri (#404), @Andrej730 (#393 root cause) |
|
||||
| **Ubu-W** | Ubuntu 24.04 LTS | GNOME (Ubuntu) | Wayland | #393 close-out (post-#406 gate). Also catches the `XDG_CURRENT_DESKTOP=ubuntu:GNOME` quirk (S02) | @Andrej730 |
|
||||
| **KDE-W** | Fedora 43 KDE *or* Nobara 43 KDE | Plasma 6 | Wayland | #370 (S10), QE-19 patch sanity, daily-driver regression baseline | @noctuum (#370), aaddrick |
|
||||
| **GNOME-X** | Ubuntu 24.04 (GNOME on Xorg session at greeter) | GNOME | Xorg | Differentiates whether #404 is mutter-as-compositor or mutter-XWayland-grabs specifically. **Note:** Fedora 43 GNOME may not ship an X11 session anymore (GNOME 49 deprecation); use Ubuntu's GNOME-on-Xorg session instead. | — |
|
||||
| **KDE-X** | Fedora 43 KDE (Plasma X11 session at greeter) | Plasma 6 | Xorg | Catches kwin-X11 specifics; regression baseline for the historic working path | — |
|
||||
|
||||
## Strongly recommended
|
||||
|
||||
Catches generalization gaps but not blocking close-out.
|
||||
|
||||
| Row | Distro | DE | Display server | Why |
|
||||
|-----|--------|----|--------------:|------|
|
||||
| **COSMIC** | popOS 24.04 (COSMIC alpha) | COSMIC | Wayland | @davidsmorais reported #393 there; not covered by KDE or GNOME branches |
|
||||
| **Ubu-X** | Ubuntu 24.04 (GNOME on Xorg) | GNOME | Xorg | Already counted under GNOME-X above. Listed here too because the Ubuntu install base is large — counts as its own row in the dashboard |
|
||||
|
||||
## Optional
|
||||
|
||||
Tracked under different bugs ([S06](./cases/shortcuts-and-input.md#s06--url-handler-doesnt-segfault-on-native-wayland), [S14](./cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri)) — skip unless closing those in the same sweep.
|
||||
|
||||
| Row | DE | Tracked under |
|
||||
|-----|----|--------------:|
|
||||
| Sway | wlroots | S06 |
|
||||
| Niri | wlroots | S14 |
|
||||
| Hypr-N (Omarchy) | wlroots | per @typedrat |
|
||||
| Hypr-O | Hyprland Xorg | per @typedrat |
|
||||
| i3 | Xorg | matrix |
|
||||
|
||||
## VM inventory
|
||||
|
||||
Existing host: `~/vms/` (libvirt, qcow2 images on a separate root-owned dir). Per-VM creation scripts in `~/vms/scripts/`. Per-VM test protocol in [`~/vms/README.md`](file:///home/aaddrick/vms/README.md).
|
||||
|
||||
### Have
|
||||
|
||||
| Row | VM image | Status |
|
||||
|-----|----------|--------|
|
||||
| GNOME-W | `claude-fedora43-gnome.qcow2` | Ready |
|
||||
| Ubu-W | `claude-ubuntu-2404.qcow2` | Ready |
|
||||
| KDE-W | `claude-fedora43-kde.qcow2` | Ready (Nobara KDE on the bare-metal host is the alternative) |
|
||||
| GNOME-X | `claude-ubuntu-2404.qcow2` | Ready (use the GNOME-on-Xorg session at the greeter — same VM as Ubu-W) |
|
||||
| KDE-X | `claude-fedora43-kde.qcow2` | Ready (use the Plasma X11 session at the greeter — same VM as KDE-W) |
|
||||
|
||||
### Need to add for full mandatory + recommended coverage
|
||||
|
||||
| Row | What | Why |
|
||||
|-----|------|-----|
|
||||
| **COSMIC** | popOS 24.04 (COSMIC alpha) ISO + `~/vms/scripts/create-popos-cosmic.sh` | Davidsmorais's #393 environment; otherwise unrepresented |
|
||||
|
||||
### Need to add only if closing optional rows in the same sweep
|
||||
|
||||
| Row | What | Use existing | Why |
|
||||
|-----|------|--------------|-----|
|
||||
| Niri | Fedora-Niri-Live ISO + `~/vms/scripts/create-fedora-niri.sh` | — | S14 (`BindShortcuts` error 5) |
|
||||
| Hypr-N | Possibly already covered by `claude-omarchy` | `claude-omarchy.qcow2` | Omarchy is a Hypr-N variant; may not exercise stock Hyprland |
|
||||
| Sway | `claude-fedora43-sway.qcow2` | Existing | S06 URL handler segfault |
|
||||
| i3 | `claude-fedora43-i3.qcow2` | Existing | Coverage only |
|
||||
|
||||
## Minimum viable kill-set
|
||||
|
||||
If the goal is the smallest pass that justifies closing all three issues:
|
||||
|
||||
- **GNOME-W** — must pass QE-2/3/4/6/7/8/9/11 → closes #404, half of #393.
|
||||
- **Ubu-W** — must pass QE-7/8/9/11 → closes other half of #393.
|
||||
- **KDE-W** — must pass QE-7/8/9 + QE-14 + QE-19 → closes #370 (or punts upstream with QE-18 evidence) and confirms the gated patch path still works.
|
||||
|
||||
(QE-20 has been folded into QE-19 — the patch ships in every build, so a single bundled-JS check covers both KDE and non-KDE rows.)
|
||||
|
||||
Three VMs, ~21 items per row, one full sweep ≈ 90 minutes if the visual checks are batched.
|
||||
|
||||
## Per-row pass criteria
|
||||
|
||||
| Issue | Closeable when |
|
||||
|-------|----------------|
|
||||
| #393 | QE-7 through QE-12 pass on **GNOME-W**, **Ubu-W**, and **KDE-W**. QE-19 confirms the patch was applied at build (KDE gate string present). If QE-11 fails on GNOME-W, the KDE-only gate is preserved as a permanent fix; otherwise the patch can be widened. |
|
||||
| #404 | QE-2 and QE-3 pass on **GNOME-W**. QE-6 confirms the launcher actually appended `--enable-features=GlobalShortcutsPortal` on GNOME Wayland (S12). |
|
||||
| #370 | QE-14 passes on **KDE-W**. **OR** QE-18 records an Electron version > 41.0.4 in the bundled binary and QE-14 still fails — at that point the upstream-regression hypothesis is wrong and we re-investigate. |
|
||||
|
||||
## Scaffold integration
|
||||
|
||||
The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases in [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md):
|
||||
This sweep is fully wired into the existing test scaffold. The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases in [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md):
|
||||
|
||||
| Case | Title | Backs |
|
||||
|------|-------|-------|
|
||||
@@ -115,4 +202,24 @@ The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases
|
||||
| [S36](./cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone) | Popup falls back to primary display when saved monitor is gone | QE-23 |
|
||||
| [S37](./cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy) | Popup remains functional after main window destroy | QE-24 |
|
||||
|
||||
QE-13, QE-15, QE-16, QE-17, and QE-21 are visual / input checks with no formal S-ID — run them by eye against [§ Upstream design intent](#upstream-design-intent).
|
||||
UI-element-level checks for QE-14 through QE-17 and QE-21 live in [`ui/quick-entry.md`](./ui/quick-entry.md), which has been refined against the upstream evidence captured in [§ Upstream design intent](#upstream-design-intent).
|
||||
|
||||
(QE-13, QE-21 don't need their own S-IDs — they're documentation items / already covered by `ui/quick-entry.md`.)
|
||||
|
||||
## Sweep mechanics
|
||||
|
||||
Per-row procedure (one full pass):
|
||||
|
||||
1. Boot VM. Confirm session at greeter matches the row (Wayland vs Xorg, correct DE).
|
||||
2. Install the latest build:
|
||||
- DEB: `sudo apt install ./claude-desktop_*.deb`
|
||||
- RPM: `sudo dnf install ./claude-desktop-*.rpm`
|
||||
3. Capture environment baseline: `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP`, `gnome-shell --version` or `kwin --version`, `electron --version` (for QE-18).
|
||||
4. Launch app. Wait for main window. Run QE-21 input smoke first to catch obvious breakage early.
|
||||
5. Run shortcut tests (QE-1 → QE-6) in order. Each run, scrape `~/.cache/claude-desktop-debian/launcher.log` and `pgrep -af claude-desktop` argv.
|
||||
6. Run submit tests (QE-7 → QE-13). For each window-state precondition, set the state, then trigger Quick Entry, then submit.
|
||||
7. Run visual checks (QE-14 → QE-18). Screenshot QE-14 to attach to #370 if still failing.
|
||||
8. Run patch sanity (QE-19 / QE-20).
|
||||
9. Update [`matrix.md`](./matrix.md) status cells. Save logs under a row-tagged subdirectory: `~/vms/collected/<row>-<date>/`.
|
||||
|
||||
For the deeper #393 bisect (isolating which half of PR #390 regresses GNOME), see the two-variant build instructions in [`~/vms/README.md`](file:///home/aaddrick/vms/README.md) — build a blur-only and a vis-only variant, run QE-7 through QE-11 on each on **Ubu-W** and **GNOME-W**, gate the offending half rather than the whole patch.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Last updated: 2026-05-03*
|
||||
|
||||
How to run a test sweep, capture diagnostics, file failures, and update [`matrix.md`](./matrix.md). For the test specs themselves, see [`cases/`](./cases/). For the automation harness, see [`automation.md`](./automation.md) and [`tools/test-harness/`](../../tools/test-harness/). For the grounding sweep workflow (verify case docs against the live build), see [Grounding sweep](#grounding-sweep) below.
|
||||
How to run a test sweep, capture diagnostics, file failures, and update [`matrix.md`](./matrix.md). For the test specs themselves, see [`cases/`](./cases/) and [`ui/`](./ui/). For the automation harness, see [`automation.md`](./automation.md) and [`tools/test-harness/`](../../tools/test-harness/). For the grounding sweep workflow (verify case docs against the live build), see [Grounding sweep](#grounding-sweep) below.
|
||||
|
||||
## When to sweep
|
||||
|
||||
@@ -315,6 +315,9 @@ When a test drifts, edit Steps/Expected in place. When a feature is
|
||||
gone from the build, prepend
|
||||
`> **⚠ Missing in build X.Y.Z** — <note>. Re-verify after next
|
||||
upstream bump.` under the test heading.
|
||||
[`cases-grounding-prompt.md`](./cases-grounding-prompt.md) is the
|
||||
fan-out prompt the last sweep used — paste verbatim into a fresh
|
||||
session to repeat the workflow.
|
||||
|
||||
### Runtime pass
|
||||
|
||||
|
||||
238
docs/testing/runner-implementation-followup-prompt.md
Normal file
238
docs/testing/runner-implementation-followup-prompt.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# test-harness runner implementation — session 17 prompt
|
||||
|
||||
This file is meant to be **copied verbatim into a fresh Claude Code
|
||||
session** as the initial user message. Don't paraphrase it; the
|
||||
orchestration depends on the exact directives below.
|
||||
|
||||
> **ORCHESTRATION STOPPED AFTER SESSION 16.** This prompt is rotated
|
||||
> for completeness only. **Session 17 will NOT run automatically** —
|
||||
> the autonomous orchestration was halted at the end of session 16
|
||||
> after coverage stalled at 74/76 (97%) for four consecutive sessions
|
||||
> (13, 14, 15, 16). To resume, the user must manually trigger another
|
||||
> orchestration run AND meet at least one of these preconditions:
|
||||
>
|
||||
> 1. **Real signed-in Claude Desktop running with `--inspect=9229`**
|
||||
> on the dev box (debugger-attached, signed in, NOT a leaked test
|
||||
> isolation). This unblocks Categories A (operon-mode probe) and
|
||||
> B (Tier 3 read-only reframes that need auth-bearing renderer
|
||||
> state).
|
||||
> 2. **A real claude.ai account fixture for write-side state.** The
|
||||
> remaining 2 specs (matrix coverage 74/76 → 76/76) need real
|
||||
> write-side state (e.g. an installed plugin to exercise
|
||||
> `LocalPlugins.listSkillFiles`, or a deep-linked deferred install
|
||||
> intent for T11). The Tier 3 destructive constraint
|
||||
> (`Don't run destructive Tier 3 write-side tests`) explicitly
|
||||
> forbids the harness constructing this state itself.
|
||||
> 3. **Renderer-drift event** that requires re-anchoring page-objects
|
||||
> (e.g. claude.ai redesign breaks `findCompactPills`,
|
||||
> `clickMenuItem`, etc.). Triggers a defensive-migration session.
|
||||
> 4. **New IPC surface** added by upstream that the harness should
|
||||
> cover (e.g. a new `claude.web` interface, a new eipc method
|
||||
> that's case-doc-anchored).
|
||||
>
|
||||
> If none of those preconditions hold, the orchestration should NOT
|
||||
> resume — further sessions will produce documentation-only or
|
||||
> marginal output. The structural ceiling of the harness without
|
||||
> real-account fixtures is 74/76 (97%); we're already there.
|
||||
|
||||
You're picking up after session 16 of the test-harness runner
|
||||
implementation work. Session 16 was the final session of the
|
||||
sessions-13-to-16 orchestration run and produced: T17 verification
|
||||
(session-15 structural fix VERIFIED — bare 60s timeout gone, new
|
||||
failure mode at `openFolderPicker` post-`selectLocal` classified as
|
||||
renderer-state-dependent and deferred), schema-rev for
|
||||
`listRemotePluginsPage` / `listSkillFiles` (both schemas resolved by
|
||||
bundle inspection — neither shipped as a Tier 2 invocation because
|
||||
`listRemotePluginsPage` is not anchored in any case doc, and
|
||||
`listSkillFiles` needs Tier 3 destructive setup). NO coverage gain.
|
||||
Plan-doc updated. Followup-prompt rotated with the STOP flag (this
|
||||
document).
|
||||
|
||||
The plan doc at
|
||||
[`docs/testing/runner-implementation-plan.md`](runner-implementation-plan.md)
|
||||
captures the tier classification and execution-time reclassifications.
|
||||
Its "Status (post-execution)" section is the source of truth for
|
||||
what's done and what's deferred — read **session 16** first, then
|
||||
**session 15**, **session 14**, **session 13**, **session 12**,
|
||||
**session 11**, **session 10**, **session 9**, **session 8**,
|
||||
**session 7**, **session 6**, **session 5**, **session 4**, **session
|
||||
3**, **session 2**, then **session 1** sub-sections.
|
||||
|
||||
This session is a continuation, not a restart. Start by reading the
|
||||
plan doc's status sections AND verifying at least one of the
|
||||
preconditions above holds. If none hold, STOP and report; don't try
|
||||
to fan out.
|
||||
|
||||
### Session 16 final findings (key context for any session-17 attempt)
|
||||
|
||||
1. **T17's session-15 structural fix VERIFIED.** Bare 60s timeout is
|
||||
gone. `seedFromHost` clones the host's signed-in config,
|
||||
`waitForReady('userLoaded')` resolves to a post-login URL
|
||||
(`https://claude.ai/epitaxy` on the dev box), the dialog mock
|
||||
installs, and `CodeTab.activate({ timeout: 15_000 })` (session 14
|
||||
migration) succeeds first try.
|
||||
2. **T17's NEW failure mode is renderer-state-dependent, not AX.**
|
||||
After `selectLocal()` clicks the Local menuitem, the Select-folder
|
||||
pill never appears within 4s. The URL during the run was
|
||||
`/epitaxy` — the user's workspace route. The folder-picker UI
|
||||
may only render on `/new` (or a fresh project), not on a workspace
|
||||
already containing files. To unblock: navigate to `/new`
|
||||
post-userLoaded BEFORE `openFolderPicker()`. NOT shipped session
|
||||
16 — needs a careful navigation primitive that doesn't break
|
||||
existing seedFromHost specs.
|
||||
3. **`openPill` / `clickMenuItem` migration STILL parked.** Session
|
||||
16's T17 trace confirmed the env-pill open + Local click both
|
||||
succeeded, ruling out the AX-polling-loop hypothesis once and for
|
||||
all. Don't migrate those speculatively.
|
||||
4. **Schema-rev resolved both deferred validators.**
|
||||
`CustomPlugins.listRemotePluginsPage(limit: number, offset:
|
||||
number)`. `LocalPlugins.listSkillFiles(pluginId: string,
|
||||
skillName: string, pluginContext?: opaque)`. Neither shipped as a
|
||||
Tier 2 invocation: `listRemotePluginsPage` is not anchored in any
|
||||
case doc; `listSkillFiles` needs Tier 3 destructive setup.
|
||||
5. **Coverage stalled at 74/76 (97%) for 4 consecutive sessions.**
|
||||
Sessions 13-16 net deliverables: 1 primitive, 1 AX migration, 1
|
||||
structural fix, 1 verification + 1 schema-rev investigation.
|
||||
Without real-account fixtures, the harness's structural ceiling
|
||||
is 74/76. The remaining 2 specs need real-account write-side
|
||||
state.
|
||||
|
||||
### What a future session 17 might attempt (only if preconditions hold)
|
||||
|
||||
If precondition 1 (real signed-in debugger-attached Claude) holds:
|
||||
|
||||
- **Operon-mode probe** (Category A from sessions 13-16). Run
|
||||
`eipc-registry-probe.ts` against the user's Claude with operon mode
|
||||
toggled on/off, capture the diff in registered channels. May
|
||||
surface a new case-doc-coverable handler.
|
||||
- **Schema-rev smoke-test** for the session-16-resolved schemas
|
||||
against the live debugger. `listRemotePluginsPage(limit: 10,
|
||||
offset: 0)` should return an array shape; `listSkillFiles('some-
|
||||
installed-plugin', 'some-skill')` would test the LocalPlugins
|
||||
handler's auth path.
|
||||
|
||||
If precondition 2 (real-account write-side fixture) holds:
|
||||
|
||||
- **T11 runtime invocation.** With an installed plugin in
|
||||
`~/.claude/plugins/`, the post-install state can be probed via
|
||||
`listSkillFiles` and the slash-menu skills would assert the
|
||||
case-doc claim "skills appear in the slash menu" (T11 step 3).
|
||||
- **T17 navigation fix.** Add a `/new` navigation primitive to
|
||||
`claudeai.ts`'s `CodeTab` so `openFolderPicker` works on a fresh
|
||||
project route. Verify T17 reaches the dialog mock fired assertion.
|
||||
|
||||
If precondition 3 or 4 holds:
|
||||
|
||||
- **Defensive page-object refactor.** Re-snapshot the AX tree at the
|
||||
Customize panel and Plugin browser modal, refresh case-doc
|
||||
inventory anchors, migrate any decayed selectors.
|
||||
|
||||
### Termination signal interpretation
|
||||
|
||||
If session 17 is triggered without any precondition met, the right
|
||||
move is the same as session 16's STOP recommendation: write a one-
|
||||
paragraph "preconditions not met, no work shipped" plan-doc update
|
||||
and terminate. Don't burn a session on documentation-only output.
|
||||
|
||||
### Constraints to respect (unchanged from sessions 1-16)
|
||||
|
||||
- Use `seedFromHost: true` for any auth-required spec — never
|
||||
`CLAUDE_TEST_USE_HOST_CONFIG=1` / `isolation: null` (legacy shape
|
||||
removed in session 15).
|
||||
- eipc handlers register on `webContents.ipc._invokeHandlers`, NOT
|
||||
global `ipcMain._invokeHandlers`. Use `lib/eipc.ts`.
|
||||
- For arg validator schema-rev: smoke-test first, fall back to
|
||||
bundle-grep on the rejection literal.
|
||||
- For AX-tree consumers: use `lib/ax.ts` (`snapshotAx` /
|
||||
`waitForAxNode` / `waitForAxNodes`).
|
||||
- For call-site migrations to `waitForAxNode`: keep per-spec retry
|
||||
budgets matching existing tuning.
|
||||
- `lib/input.ts` is X11-only. `lib/input-niri.ts` is Niri-only. CDP
|
||||
auth gate is alive (runtime SIGUSR1 attach, never Playwright
|
||||
`_electron.launch()`). BrowserWindow Proxy gotcha — use
|
||||
`webContents.getAllWebContents()`. `skipUnlessRow()` always first.
|
||||
- No fixed sleeps. `retryUntil` from `lib/retry.ts`, Playwright
|
||||
auto-wait, or `waitForAxNode` from `lib/ax.ts`.
|
||||
- Diagnostics on every run via `testInfo.attach()`. Tag with
|
||||
`severity:` and `surface:` annotations.
|
||||
- Tabs in TS, ~80-char wrap.
|
||||
- Don't break existing runners. H01-H05 are the canaries.
|
||||
- `npm run typecheck` must stay clean.
|
||||
- Don't run destructive Tier 3 write-side tests.
|
||||
|
||||
### Authoritative reference
|
||||
|
||||
Read these in order before fanning out:
|
||||
|
||||
- [`docs/testing/runner-implementation-plan.md`](runner-implementation-plan.md)
|
||||
— tier classification + status sections.
|
||||
- [`tools/test-harness/README.md`](../../tools/test-harness/README.md)
|
||||
— runner conventions, the 74-spec inventory, primitives in
|
||||
`lib/`, isolation defaults.
|
||||
- [`docs/testing/cases/README.md`](cases/README.md) — case-doc
|
||||
structure and the four anchor scopes.
|
||||
- [`tools/test-harness/src/lib/`](../../tools/test-harness/src/lib/)
|
||||
— the existing primitives.
|
||||
- [`tools/test-harness/src/runners/`](../../tools/test-harness/src/runners/)
|
||||
— every existing spec is a template.
|
||||
|
||||
### Phase 0 — calibration (mandatory before fanning out)
|
||||
|
||||
1. `cd tools/test-harness && npm run typecheck` — should pass.
|
||||
2. Check debugger ATTACHMENT QUALITY (not just port). `ss -tln |
|
||||
grep ':9229'`. If port open, probe webContents via `evalInMain`:
|
||||
|
||||
```ts
|
||||
import { InspectorClient } from './src/lib/inspector.js';
|
||||
const client = await InspectorClient.connect(9229);
|
||||
const wcs = await client.evalInMain<unknown>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
return webContents.getAllWebContents().map((w) => ({
|
||||
id: w.id, url: w.getURL(), title: w.getTitle(),
|
||||
}));
|
||||
`);
|
||||
console.log(wcs); client.close();
|
||||
```
|
||||
|
||||
If every URL is `/login` / `find_in_page` / `main_window`, treat
|
||||
as soft-blocked for auth-required investigations.
|
||||
3. Disambiguate running Claude processes. `pgrep -af
|
||||
"ozone-platform=x11.*app.asar"`; for each, inspect cmdline for
|
||||
`user-data-dir`. Real Claude has
|
||||
`~/.config/Claude` (or no user-data-dir flag); leaked test
|
||||
isolations have `/tmp/claude-test-*`.
|
||||
4. **Verify at least one precondition for resuming the orchestration
|
||||
holds.** If none hold, write a "no preconditions met" plan-doc
|
||||
update and STOP. Don't fan out.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- For the bundle-grep schema-rev pattern (sessions 9, 11, 12, 16
|
||||
precedents):
|
||||
|
||||
```bash
|
||||
cd tools/test-harness && node -e "
|
||||
const {extractFile} = require('@electron/asar');
|
||||
const buf = extractFile(
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar',
|
||||
'.vite/build/index.js'
|
||||
);
|
||||
const s = buf.toString('utf8');
|
||||
const idx = s.indexOf('<rejection-literal>');
|
||||
console.log(s.slice(Math.max(0, idx - 1500), idx + 500));
|
||||
"
|
||||
```
|
||||
|
||||
- For seedFromHost specs: host MUST have a signed-in Claude.
|
||||
`seedFromHost`'s host-claude-kill semantics will tear down any
|
||||
running Claude process — flag clearly in the report before
|
||||
invoking when the user's real Claude is running.
|
||||
|
||||
- For AX-tree polling: `lib/ax.ts`'s `waitForAxNode` /
|
||||
`waitForAxNodes` for predicate-based polling.
|
||||
|
||||
- The eipc-registry probe (`tools/test-harness/eipc-registry-probe.ts`)
|
||||
is the dedicated tool for inspecting per-wc IPC handler state.
|
||||
|
||||
Begin with Phase 0. Don't fan out until at least one of the
|
||||
preconditions for resuming the orchestration is verified to hold.
|
||||
2176
docs/testing/runner-implementation-plan.md
Normal file
2176
docs/testing/runner-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
597
docs/testing/ui-inventory-reconciliation.md
Normal file
597
docs/testing/ui-inventory-reconciliation.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# claude.ai UI Inventory Reconciliation
|
||||
|
||||
*Generated against [`ui-inventory.json`](./ui-inventory.json) v6 (captured 2026-05-03, app version 1.5354.0, 383 entries).*
|
||||
*Reconciled 2026-05-02.*
|
||||
|
||||
This file diffs the human-written claims in [`ui/`](./ui/) against the
|
||||
machine-captured ground-truth in [`ui-inventory.json`](./ui-inventory.json).
|
||||
|
||||
It is one-shot output meant to drive human cleanup of `ui/*.md` — re-run
|
||||
the reconciliation script (TODO: not yet built) after major walker passes.
|
||||
|
||||
## Reading this document
|
||||
|
||||
Three categories of finding per surface:
|
||||
|
||||
- **In docs but not in renderer** — the doc names an element that has no
|
||||
corresponding inventory entry. Possible causes (don't read this as "doc
|
||||
is wrong"; the walker covers a subset of reality):
|
||||
- **OS / window-manager element** — title bar, close/min/max buttons,
|
||||
drop shadow, resize edges. These are drawn by the compositor, not by
|
||||
claude.ai's renderer; the walker can't see them.
|
||||
- **Out of renderer scope** — tray menu, libnotify notifications, IME
|
||||
composition popups, Quick Entry popup window. These are main-process
|
||||
or DE-level surfaces that don't exist in the claude.ai DOM.
|
||||
- **Walker coverage gap** — Settings overlay, dialogs, deep Code-tab
|
||||
panes (terminal, file pane, diff). The walker drilled some surfaces
|
||||
but not others; absence here is "not yet observed" not "not present."
|
||||
- **Account-state-dependent** — features that don't appear on this
|
||||
user's plan (e.g. SSH connections panel, managed-settings rows,
|
||||
specific Code-tab pane types).
|
||||
- **Speculative** — doc was written from upstream behavior, not from a
|
||||
Linux build. May not actually render.
|
||||
- **In renderer but not in docs** — inventory captured an element that no
|
||||
doc row mentions. Either the doc is incomplete for that surface, or the
|
||||
element is tangential (search-results recency rows, instance-suffix
|
||||
duplicates with `#2`/`+5` markers).
|
||||
- **Fingerprint potentially drifted** — doc and inventory agree on the
|
||||
element but the doc's selector hint disagrees with the inventory's
|
||||
`fingerprint.selector`. Most `ui/*.md` rows use prose ("Top-left of
|
||||
topbar") rather than CSS selectors, so this category is small.
|
||||
|
||||
Human triage is what closes any of these. Don't auto-edit `ui/*.md`.
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Inventory entries (total) | 383 |
|
||||
| Inventory entries by kind | persistent 65 / structural 276 / menu 33 / instance 9 |
|
||||
| Inventory entries marked `denylisted: true` | 9 (Send×4, Install×4, Remove×1) |
|
||||
| `ui/*.md` files reconciled | 11 (10 surface files + README) |
|
||||
| `ui/*.md` rows reconciled (rough — multi-element rows complicate the count) | ~210 element rows across all 10 surface files |
|
||||
| Rows with confirmed inventory match | ~70 (~33%) |
|
||||
| Rows flagged "in docs but not in renderer" | ~140 (~67%) — heavily skewed by OS-frame, tray, notifications, deep Code panes, Settings, Quick Entry being out-of-renderer or under-walked |
|
||||
| Inventory entries with no `ui/*.md` mention | ~190 (~50%) — heavily skewed by per-conversation/per-skill/per-prompt-card structural rows that the docs treat as categories rather than enumerating |
|
||||
| Doc rows with explicit selectors that drift from inventory | 0 verified — `ui/*.md` rows almost never carry CSS selectors |
|
||||
|
||||
Match counts are approximate. `ui/*.md` rows often describe categories
|
||||
("Recent conversations," "Per-history-entry hover") that map to many
|
||||
inventory entries; the inventory in turn enumerates structural elements
|
||||
the docs intentionally don't list (every project skill button, every
|
||||
search result option). The reconciliation is a triage signal, not a
|
||||
metric.
|
||||
|
||||
## Per-surface breakdown
|
||||
|
||||
### `ui/window-chrome-and-tabs.md`
|
||||
|
||||
**Inventory surfaces likely covered:** none directly — OS window frame is
|
||||
drawn by the compositor; the in-app topbar elements live under `root` as
|
||||
`root.button.menu`, `root.button.collapse-sidebar`, `root.button.search`,
|
||||
`root.button.back`, `root.button.forward`. The "tab strip" maps to
|
||||
`root.button.chat`, `root.button.cowork`, `root.button.code`.
|
||||
|
||||
**Doc rows reconciled:** ~22
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Title bar | OS / window-manager |
|
||||
| Close button (X) | OS / window-manager |
|
||||
| Minimize button | OS / window-manager |
|
||||
| Maximize / restore button | OS / window-manager |
|
||||
| Resize edges | OS / window-manager |
|
||||
| Window menu (right-click titlebar) | OS / window-manager |
|
||||
| Cowork ghost icon | Walker captures `root.button.cowork` (the tab) but not the ghost-icon visual within the topbar shim |
|
||||
| Drag region (gaps between buttons) | Renders as empty space — not an actionable element |
|
||||
| Active tab indicator | Visual styling, not an actionable element |
|
||||
| Tab badges (unread / Dispatch) | None observed; user state at capture had no badges |
|
||||
| About dialog | Walker did not surface a dialog; About is reachable only from app/tray menu, both out of renderer scope |
|
||||
| App menu (macOS-style) | Doc itself notes this is N/A on Linux |
|
||||
| Update prompt | Conditional, not present at capture |
|
||||
| Crash report dialog | Conditional, not present at capture |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.menu` ("Menu", `aria-label="Menu"`) | This is the doc's "Hamburger menu" — renamed |
|
||||
| `root.button.collapse-sidebar` ("Collapse sidebar") | Doc has "Sidebar toggle"; arguably the same |
|
||||
| `root.button.search` ("Search") | Doc's "Search icon"; same |
|
||||
| `root.button.back` / `root.button.forward` | Doc's back/forward arrows; same |
|
||||
| `root.a.skip-to-content` ("Skip to content") | A11y skip link; not in doc |
|
||||
| `root.button.new-chat-n` ("New chat⌘N") | Topbar new-chat button; not in doc |
|
||||
| `root.button.pinned`, `root.button.recents`, `root.button.projects`, `root.button.artifacts`, `root.button.customize` | Sidebar nav buttons; doc covers some of these in `sidebar.md` not here |
|
||||
| `root.button.awaaddrick-max` ("AWAaddrick·Max") | User/plan badge in topbar; not in doc |
|
||||
| `root.button.get-apps-and-extensions` | Topbar shortcut to apps page; not in doc |
|
||||
| `root.tab.write` / `root.tab.learn` / `root.tab.code` / `root.tab.from-calendar` / `root.tab.from-gmail` | Quick-prompt-template tabs in the prompt area; doc covers Write/Learn/Code as Chat/Cowork/Code tabs but the inventory's `root.tab.code` is distinct from `root.button.code` |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None — doc rows for this surface use Location prose only.
|
||||
|
||||
#### Notable cross-cut
|
||||
|
||||
The doc's "Chat / Cowork / Code" tab strip maps cleanly to
|
||||
`root.button.chat`, `root.button.cowork`, `root.button.code`. But the
|
||||
inventory also has `root.tab.code` (a `[role="tab"]`, not a button) which
|
||||
is a separate element — the prompt-area template strip — that the doc
|
||||
conflates with the main Chat/Cowork/Code switcher. Worth a human note.
|
||||
|
||||
---
|
||||
|
||||
### `ui/tray.md`
|
||||
|
||||
**Inventory surfaces covered:** none — the tray is a main-process Electron
|
||||
`Tray` object on the system SNI bus, not part of claude.ai's DOM.
|
||||
|
||||
**Doc rows reconciled:** ~17
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every row, by design. Categories:
|
||||
|
||||
- Tray icon (light / dark theme) — main-process `Tray.setImage()`
|
||||
- Right-click menu items (Show/Hide, Quick Entry, Open at Login,
|
||||
Settings, About, Quit) — main-process `Menu.buildFromTemplate()`
|
||||
- Left-click / double-click / middle-click behaviors — main-process
|
||||
event handlers
|
||||
- Tooltip on hover, position, icon resolution, theme switch — SNI
|
||||
daemon and DE behavior
|
||||
|
||||
This entire file is correctly out of renderer scope; the walker is doing
|
||||
the right thing by not capturing any of it.
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
N/A — surface mismatch.
|
||||
|
||||
---
|
||||
|
||||
### `ui/sidebar.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root` (sidebar lives in the root
|
||||
chrome on claude.ai). Note: the doc opens "Code Tab Sidebar" but the
|
||||
sidebar in the captured renderer is the global claude.ai sidebar, not a
|
||||
Code-tab-specific one. The Code-tab-specific session list is captured
|
||||
separately under `root.button.code.button.new-session-n` (60 entries).
|
||||
|
||||
**Doc rows reconciled:** ~18
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Filter: status / project / environment | Walker did not drill the filter dropdown |
|
||||
| Group-by control | Same — within Code-tab session list |
|
||||
| Session status indicator (idle/running/...) | Visual decoration on row, not an actionable element |
|
||||
| Project / branch label | Same |
|
||||
| Diff stats badge `+12 -1` | Conditional — no session at capture had pending diffs |
|
||||
| Dispatch badge | Conditional — no Dispatch-spawned session at capture |
|
||||
| Scheduled badge | Conditional — same |
|
||||
| Hover archive icon | Hover-revealed; walker captures static state |
|
||||
| Right-click context menu (Rename / Archive / etc.) | Walker does not synthesise right-clicks |
|
||||
| Sidebar resize handle | Visual / draggable, not an aria-labeled element |
|
||||
| Sidebar collapse toggle | Inventory has `root.button.collapse-sidebar` but doc treats it as a Code-tab element rather than chrome |
|
||||
| Scrollbar | OS / theme-rendered |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` cycling | Keyboard shortcut, not a UI element |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.fine-tuning-diffusion-models-with-reinforcement-learning` | A pinned recent conversation — sidebar content |
|
||||
| `root.button.more-options-for-fine-tuning-diffusion-models-with-reinforce` | Per-row menu trigger — doc mentions "right-click context menu" but inventory shows it's a discoverable button |
|
||||
| `root.button.how-to-use-claude` + `root.button.more-options-for-how-to-use-claude` | Same pattern |
|
||||
| `root.button.code.button.routines` | "Routines" link in Code-tab nav — doc's "Routines link" is here |
|
||||
| `root.button.code.button.more-navigation-items` | Likely the doc's "Customize / Routines" expander — not enumerated |
|
||||
| `root.button.code.button.filter` | The doc's "Filter: status" probably maps here |
|
||||
| `root.button.code.button.appearance` | Not in doc |
|
||||
| `root.button.code.button.show-5-more` | Pagination; not in doc |
|
||||
| `root.button.code.button.open-session-*` (5 entries) | Each is a single session row in the Code-tab list — the doc's "Per-session row" category |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None — doc rows for this surface use Location prose only.
|
||||
|
||||
---
|
||||
|
||||
### `ui/prompt-area.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root` (top-level prompt area
|
||||
buttons), `root.button.add-files-connectors-and-more` (the `+` menu),
|
||||
`root.button.model-opus-4-7-adaptive` (model picker), and several deep
|
||||
sub-surfaces.
|
||||
|
||||
**Doc rows reconciled:** ~28
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Input field | The contenteditable / textarea itself isn't captured (no aria-label) |
|
||||
| Placeholder text | Not an interactive element |
|
||||
| Cursor caret / multi-line autosize / word wrap | Behavior, not element |
|
||||
| Paste plain text / paste image | Behavior |
|
||||
| `Enter` to send / `Shift+Enter` / `Esc` | Keyboard behavior |
|
||||
| IME composition | Not a renderer element |
|
||||
| Attachment button (left of input) | Not surfaced — possibly bundled into `root.button.add-files-connectors-and-more` |
|
||||
| File-attached chip | Conditional — no attachment at capture |
|
||||
| Multiple attachments / image preview / PDF preview | Conditional |
|
||||
| Drag-drop overlay | Conditional, only renders during drag |
|
||||
| `@filename` autocomplete | Conditional, only renders when typing `@` |
|
||||
| `+` button | Likely IS the `root.button.add-files-connectors-and-more` button — see below |
|
||||
| Slash menu (all rows: Built-in / Project skills / User skills / Plugin skills / filter / selection / `Esc`) | Walker did not type `/` to trigger the slash menu; no inventory entries |
|
||||
| Effort picker (`Cmd+Shift+E`) | Possibly inside `root.button.code.button.opus-4-7-1m-extra-high` — uncertain |
|
||||
| Stop button (replaces Send while responding) | Conditional — no in-flight response at capture |
|
||||
| Usage ring | Possibly `root.button.code.button.usage-plan-11` ("Usage: plan 11%") |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.press-and-hold-to-record` ("Press and hold to record") | Voice / dictation button in prompt area — doc has no voice input row |
|
||||
| `root.button.code.button.dictation-settings` | Dictation settings button |
|
||||
| `root.button.code.button.transcript-view-mode` | Transcript view toggle in prompt area |
|
||||
| `root.button.code.button.scroll-to-bottom` | Scroll-to-bottom affordance |
|
||||
| `root.button.code.button.accept-edits` | Permission-mode-related quick action |
|
||||
| `root.button.code.button.add` ("Add") | Likely the doc's `+` button, with a different label |
|
||||
| `root.button.code.button.usage-plan-11` ("Usage: plan 11%") | Probably the doc's "Usage ring" |
|
||||
| `root.button.code.button.opus-4-7-1m-extra-high` ("Opus 4.7 1M· Extra high") | Probably the doc's "Effort picker" |
|
||||
| All `root.button.add-files-connectors-and-more.menuitem.*` entries (Add files or photos / Add to project / Skills / Connectors / Plugins / Research / Web search / Use style) | The `+` menu contents — doc has Slash commands / Skills / Connectors / Plugins / Add plugin; inventory surfaces additional items the doc misses (Add files or photos, Add to project, Web search, Use style) |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.use-style.*` (8 entries: Normal / Learning / Concise / Explanatory / Formal / Create & edit styles / Research mode) | Style picker is a whole sub-surface the doc doesn't mention |
|
||||
| `root.button.model-opus-4-7-adaptive.menuitemradio.*` (Opus / Sonnet / Haiku / Adaptive thinking / More models) | Doc says "Sonnet, Opus, Haiku" — inventory adds Adaptive thinking + More models |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
| Doc claim | Inventory says |
|
||||
|-----------|----------------|
|
||||
| `+` button → opens menu of "Slash commands / Skills / Connectors / Plugins / Add plugin" | The corresponding inventory button is labeled "Add files, connectors, and more" with `aria-label="Add files, connectors, and more"`. Menu contents don't include "Slash commands" or "Add plugin" sub-entry — doc menu structure is partly speculative |
|
||||
|
||||
---
|
||||
|
||||
### `ui/code-tab-panes.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root.button.code` (23 entries),
|
||||
`root.button.code.button.new-session-n` (60 entries) — but no per-pane
|
||||
sub-surfaces (no diff pane, no terminal pane, no preview pane, no file
|
||||
pane).
|
||||
|
||||
**Doc rows reconciled:** ~50
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Almost every Code-tab pane row is missing from the inventory. The walker
|
||||
landed in the Code-tab "New session" shell but did not open or drill any
|
||||
of the panes. Categories:
|
||||
|
||||
| Pane | Doc rows missing | Reason |
|
||||
|------|------------------|--------|
|
||||
| Pane chrome (header, drag/resize handles, close button, Views menu) | 5 rows | Walker coverage gap — no pane was open |
|
||||
| Diff pane | 9 rows (file list, diff content, line click, Cmd+Enter, Accept/Reject, Review code) | Walker coverage gap |
|
||||
| Preview pane | 11 rows | Walker coverage gap |
|
||||
| Terminal pane | 7 rows | Walker coverage gap (also: only renders for Local sessions) |
|
||||
| File pane | 7 rows | Walker coverage gap |
|
||||
| Tasks / subagent pane | 5 rows | Walker coverage gap |
|
||||
| Side chat overlay | 3 rows (trigger / content / close) | `root.button.code.button.close-side-chat` IS captured — the close button — but content isn't drilled |
|
||||
| CI status bar | 5 rows | Conditional — no PR open at capture |
|
||||
| View modes (Normal/Verbose/Summary) | 3 rows | Possibly behind `root.button.code.button.transcript-view-mode` — single inventory entry vs. 3 doc rows |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.code.button.local` ("Local") | Environment switcher chip — not in doc |
|
||||
| `root.button.code.button.select-folder` ("Select folder…") | Folder-picker entry — doc references this only via T17 cross-reference |
|
||||
| `root.button.code.button.send` (and `#2`, both denylisted) | Send button — doc has it under prompt-area, not panes |
|
||||
| `root.button.code.button.transcript-view-mode` | The doc's "Transcript view dropdown" — single inventory entry |
|
||||
| `root.button.code.button.opus-4-7-1m-extra-high` | Model selector inside Code-tab session shell |
|
||||
| `root.button.code.button.usage-plan-11` | Usage ring inside Code-tab session shell |
|
||||
| `root.button.code.button.accept-edits` ("Accept edits") | Permission-mode quick action — not in doc |
|
||||
| All 60 `root.button.code.button.new-session-n.button.open-session-*` and per-session entries | Doc covers the session list in `sidebar.md`, not here, so this isn't really a gap for `code-tab-panes.md` |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None — doc is prose-only.
|
||||
|
||||
---
|
||||
|
||||
### `ui/settings.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root.button.settings` (only 1
|
||||
entry — "Settings" button itself), `root.button.awaaddrick-max.menuitem.settingsctrl`
|
||||
(the menu-item route to Settings, label "SettingsCtrl,").
|
||||
|
||||
**Doc rows reconciled:** ~28
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
The Settings page itself is essentially un-walked. Settings opens as an
|
||||
overlay/modal which the walker treated as a single button rather than
|
||||
drilling into. Every row in the doc beyond "Settings window opens" lacks
|
||||
a matching inventory entry:
|
||||
|
||||
| Doc section | Rows missing | Reason |
|
||||
|-------------|--------------|--------|
|
||||
| Settings root (close button, sidebar nav) | 3 rows | Walker coverage gap |
|
||||
| Desktop app → General (Computer use, Keep computer awake, Denied apps, Unhide apps, Theme picker) | 5 rows | Walker coverage gap; some rows account-state-dependent |
|
||||
| Desktop app → Account (name/email, plan badge, Sign out) | 3 rows | Walker coverage gap |
|
||||
| Claude Code (Worktree location, Branch prefix, Auto-archive toggle, Persist preview, Preview toggle, Bypass-permissions toggle, Auto mode availability) | 7 rows | Walker coverage gap |
|
||||
| Connectors page (list, per-connector entry, Manage, Disconnect, Add connector) | 5 rows | Walker coverage gap; partially covered by the in-session connectors menu |
|
||||
| SSH connections (list, Add SSH connection button, per-connection entry) | 3 rows | Walker coverage gap; account-state-dependent |
|
||||
| Keyboard shortcuts (list, value, Reset, Quick Entry shortcut) | 4 rows | Walker coverage gap |
|
||||
| Local environment editor (open, Add variable, Remove variable, Apply to dev servers) | 4 rows | Walker coverage gap; account-state-dependent |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.settings` ("Settings", `aria-label="Settings"`) | The button that opens Settings — confirmed in chrome |
|
||||
| `root.button.awaaddrick-max.menuitem.settingsctrl` ("SettingsCtrl,") | Settings menu item under the user/plan menu — alternate path |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None.
|
||||
|
||||
#### Walker coverage note
|
||||
|
||||
Settings is a known walker coverage gap (see preamble). This doc is
|
||||
substantively un-reconciled until a Settings drill pass lands.
|
||||
|
||||
---
|
||||
|
||||
### `ui/routines-page.md`
|
||||
|
||||
**Inventory surfaces likely covered:** none directly. Routines are
|
||||
reachable via `root.button.code.button.routines`, but the page itself
|
||||
isn't drilled.
|
||||
|
||||
**Doc rows reconciled:** ~26
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every doc row except the "Routines page link" itself is unmatched — the
|
||||
walker captured the entry point but did not open the Routines page.
|
||||
|
||||
| Doc section | Rows missing | Reason |
|
||||
|-------------|--------------|--------|
|
||||
| Routines list (header, New routine button, list, per-routine row, Run-now icon, Pause/resume, click row) | 7 rows | Walker coverage gap |
|
||||
| New routine form Local (Name, Description, Instructions, permission-mode picker, model picker, Working folder, Worktree toggle, Schedule preset, Time picker, Day picker, Save, Cancel, Folder-trust prompt) | 13 rows | Walker coverage gap |
|
||||
| New routine form Remote (Trigger type, Connectors picker, Network access controls) | 3 rows | Walker coverage gap; doc itself is partly speculative ("Per upstream docs") |
|
||||
| Routine detail (Run now, Active/Paused toggle, Edit, Delete, Review history, hover tooltip, Show more, Always allowed, Revoke approval) | 9 rows | Walker coverage gap |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.code.button.routines` ("Routines") | The entry-point link — doc's "Routines page link" |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
### `ui/connectors-and-plugins.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root.button.add-files-connectors-and-more.menuitem.connectors`
|
||||
(the in-session connector picker, 5 entries), plus the deeper per-connector
|
||||
sub-surfaces under `.connectors.menuitemcheckbox.gmail.*` (15 entries).
|
||||
Plugin browser surfaces (`root.button.back.*`) cover Skills, Connectors,
|
||||
Add plugin, Typescript lsp, Php lsp, Playwright, Connectors, etc.
|
||||
|
||||
**Doc rows reconciled:** ~24
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Connectors menu — "Per-connector row" with status indicator | Inventory has Gmail and Google Calendar but not status decorations |
|
||||
| Empty state | Conditional — user has connectors configured |
|
||||
| Connector catalog (modal body, per-connector tile with logo/description) | Walker coverage gap — the Add-connector flow opens a modal that wasn't drilled |
|
||||
| OAuth in-app overlay | Conditional, not present at capture |
|
||||
| Permission consent screen | External (provider's UI) |
|
||||
| Callback completion | Behavior, not an element |
|
||||
| Custom connector entry point | Walker coverage gap |
|
||||
| Plugin browser modal (browser modal, marketplace selector, per-plugin tile, scope selector, install progress, success state, error state) | Walker captured plugin surfaces under `root.button.back.*` (Add plugin, Typescript lsp, Php lsp, Playwright) but not the modal anatomy |
|
||||
| Manage plugins (installed list, per-plugin row, Enable toggle, Plugin skills sub-list) | Walker coverage gap — no Manage-plugins surface drilled |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors` ("Connectors", in-session menu) | Doc covers this — the in-session Connectors menu |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.gmail` ("Gmail") | Per-connector row — doc "Per-connector row" category |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.google-calendar` ("Google Calendar") | Per-connector row — same |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.manage-connectors` ("Manage connectors") | Doc's "Manage connectors entry" |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.add-connector` ("Add connector") | Doc has "Add connector button" in Settings; inventory shows it also exists in the in-session menu |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.tool-accessload-tools-when-needed` ("Tool accessLoad tools when needed") | Per-connector tool-access setting — not in doc |
|
||||
| `root.button.back.a.skills` ("Skills") | Plugin browser — Skills tab |
|
||||
| `root.button.back.a.connectors` / `root.button.back.a.connectors#2` (both "Connectors") | Plugin browser — Connectors tab (instance suffix `#2` indicates duplicate detection) |
|
||||
| `root.button.back.button.add-plugin` ("Add plugin") | Plugin browser — Add plugin button |
|
||||
| `root.button.back.a.typescript-lsp` / `root.button.back.a.php-lsp` / `root.button.back.a.playwright` | Installed plugins — doc treats this as "Manage plugins → Per-plugin row," walker captures the actual plugin names |
|
||||
| `root.button.back.button.connect-your-appslet-claude-read-and-write-to-the-tools-you-` ("Connect your appsLet Claude read...") | Plugin browser landing pane CTA — not in doc |
|
||||
| `root.button.back.a.create-new-skillsteach-claude-your-processes-team-norms-and-` ("Create new skillsTeach Claude your processes, team norms, and expertise.") | Skills-creation CTA — not in doc |
|
||||
| `root.button.back.button.browse-pluginsadd-pre-built-knowledge-for-your-field` ("Browse pluginsAdd pre-built knowledge for your field.") | Browse-plugins CTA — not in doc |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.gmail.button.develop-storytelling-frameworks` and 9 similar `.option`/`.button` pairs | Connector-suggested prompt cards. Walker captured these as a side-effect of drilling Gmail — they aren't a doc-targeted UI element |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
| Doc claim | Inventory says |
|
||||
|-----------|----------------|
|
||||
| `+` → **Connectors** opens "Connectors menu" | Inventory: button is "Add files, connectors, and more" not "+"; menu item is "Connectors". Functionally the same surface |
|
||||
|
||||
---
|
||||
|
||||
### `ui/quick-entry.md`
|
||||
|
||||
**Inventory surfaces covered:** none — Quick Entry is a separate
|
||||
`BrowserWindow` constructed in the main process (`index.js:515375`), not
|
||||
part of claude.ai's renderer. The walker started at `https://claude.ai/new`
|
||||
which never reaches it.
|
||||
|
||||
**Doc rows reconciled:** ~17
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every row, by design. Categories:
|
||||
|
||||
- Window appearance (frame, background, rounded corners, drop shadow,
|
||||
position, always-on-top, lifecycle, persistence after main destroy) —
|
||||
main-process BrowserWindow construction
|
||||
- Input area (text input, placeholder, multi-line, Enter/Shift+Enter,
|
||||
Esc, click-outside, paste, IME) — popup renderer (separate from
|
||||
claude.ai)
|
||||
- Submit feedback (transition, loading, error) — popup renderer + IPC
|
||||
bridge
|
||||
|
||||
This entire file is correctly out of renderer scope. Doc rows are
|
||||
already heavily annotated with `index.js:515xxx` references to upstream
|
||||
main-process source — that's the right substrate.
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
N/A — surface mismatch.
|
||||
|
||||
---
|
||||
|
||||
### `ui/notifications.md`
|
||||
|
||||
**Inventory surfaces covered:** none — notifications fire via libnotify
|
||||
on the `org.freedesktop.Notifications` DBus path; they are not DOM
|
||||
elements.
|
||||
|
||||
**Doc rows reconciled:** ~17
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every row, by design. Categories:
|
||||
|
||||
- Notification sources (Scheduled fires, Catch-up, CI status, PR merged,
|
||||
Dispatch handoff, Permission prompt) — main-process emitters
|
||||
- Per-notification anatomy (App identity, icon, title, body, actions,
|
||||
click target) — DBus payload
|
||||
- Per-DE rendering (KDE/GNOME/Mako/Dunst/swaync/Niri) — daemon behavior
|
||||
- Notification persistence (history, DND) — daemon behavior
|
||||
|
||||
This entire file is correctly out of renderer scope.
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
N/A — surface mismatch.
|
||||
|
||||
---
|
||||
|
||||
## Top-level findings
|
||||
|
||||
### Coverage by source-of-truth axis
|
||||
|
||||
- **OS-level / window-manager elements** (window-chrome rows for
|
||||
title bar, close/min/max, resize edges, drop shadow) — never going to
|
||||
appear in the renderer inventory. ~10 doc rows.
|
||||
- **Main-process Electron windows** (Quick Entry popup, About dialog,
|
||||
crash dialog, file pickers) — never going to appear in the renderer
|
||||
inventory. ~25 doc rows.
|
||||
- **Tray menu** (Show/Hide, Quick Entry, Settings, About, Quit, Open
|
||||
at Login) — main-process `Menu.buildFromTemplate()`. ~12 doc rows.
|
||||
- **libnotify notifications** — DBus, not DOM. ~17 doc rows.
|
||||
- **Walker coverage gaps** (Settings overlay, Routines page, plugin
|
||||
browser modal, all Code-tab panes, dialogs, slash menu, drag-drop
|
||||
overlay) — would appear if the walker drilled them. ~70 doc rows.
|
||||
- **Account-state-dependent surfaces** (CI bar, Dispatch badges, file
|
||||
attachments, SSH connections panel) — would appear in some sessions
|
||||
but didn't at capture. ~15 doc rows.
|
||||
- **Conditional / hover / behavior** (right-click context menus, hover
|
||||
archive icons, drag-drop overlays, tooltips) — wouldn't appear in a
|
||||
static walker pass even if the surface was visited. ~10 doc rows.
|
||||
|
||||
The combined explanation: roughly half of the "in docs but not in
|
||||
renderer" mismatches are unfixable (different source of truth), and
|
||||
roughly half are walker coverage gaps that future passes can close.
|
||||
|
||||
### Top 3 surfaces with the most "in docs but not in renderer" mismatches
|
||||
|
||||
These are likely candidates for speculative claims OR for un-walked
|
||||
surfaces. Treat as triage queue:
|
||||
|
||||
1. **`ui/code-tab-panes.md`** — ~50 unmatched rows. Almost entirely
|
||||
walker-coverage gap (the walker landed in the Code-tab shell but
|
||||
opened no panes). Until the walker drills diff/preview/terminal/file/
|
||||
tasks panes, this doc is un-reconcilable.
|
||||
2. **`ui/settings.md`** — ~28 unmatched rows. Settings opens as an
|
||||
overlay; walker captured only the Settings entry-point button. Needs
|
||||
targeted drill.
|
||||
3. **`ui/routines-page.md`** — ~26 unmatched rows. Same shape as
|
||||
Settings — entry-point captured, page contents unwalked.
|
||||
|
||||
### Top 3 surfaces with the most "in renderer but not in docs" surplus
|
||||
|
||||
These docs are most-incomplete relative to ground truth:
|
||||
|
||||
1. **`ui/sidebar.md`** — Inventory has 60+ Code-tab session-list entries
|
||||
under `root.button.code.button.new-session-n`. Doc treats sessions as
|
||||
a single category row. This is intentional doc behavior, but it means
|
||||
the doc doesn't help when reasoning about the actual structural
|
||||
buttons (Filter, Appearance, Routines, More navigation items, Show 5
|
||||
more, etc.) that the walker found.
|
||||
2. **`ui/prompt-area.md`** — Inventory has the entire Use-style picker
|
||||
sub-tree (Normal / Learning / Concise / Explanatory / Formal / Create
|
||||
& edit styles + 5 preset cards), the Press-and-hold-to-record voice
|
||||
button, dictation settings, transcript view mode, scroll-to-bottom,
|
||||
and the model picker's "Adaptive thinking" / "More models" entries —
|
||||
none of which the doc enumerates.
|
||||
3. **`ui/connectors-and-plugins.md`** — Inventory has the entire plugin
|
||||
browser sub-tree (`root.button.back.*` — 12 entries: Skills, Add
|
||||
plugin, Typescript lsp, Php lsp, Playwright, Browse plugins, Create
|
||||
new skills, Connect your apps, Connectors×2, Back to Claude, Select
|
||||
a folder), and connector-suggested prompt cards (10 entries under
|
||||
`.gmail.button.*`). Doc treats these surfaces at a higher level of
|
||||
abstraction.
|
||||
|
||||
## Acknowledged gaps in inventory itself
|
||||
|
||||
Not all inventory absences are doc errors. Known walker gaps as of v6:
|
||||
|
||||
- **Settings page deep content** — only the entry-point button
|
||||
(`root.button.settings`) and the menu shortcut
|
||||
(`...menuitem.settingsctrl`) captured. Settings opens as an overlay
|
||||
the walker did not drill.
|
||||
- **Dialogs** — 0 captured. claude.ai may not use `[role=dialog]` for
|
||||
most modals, or the walker's drill paths didn't reach them.
|
||||
- **Code tab panes** — only the Code-tab session shell was drilled;
|
||||
diff, preview, terminal, file, tasks, subagent, plan, side chat, CI
|
||||
bar are uncaptured.
|
||||
- **Routines page** — only the entry-point link was captured.
|
||||
- **Plugin browser modal anatomy** — surrounding list captured, the
|
||||
per-plugin install modal wasn't.
|
||||
- **Slash menu** — walker did not type `/` to trigger.
|
||||
- **Hover/right-click/drag-only affordances** — static walker; no
|
||||
context menus or drag-drop overlays.
|
||||
- **Quick Entry / Tray / Notifications** — out of renderer scope.
|
||||
|
||||
These are walker tickets, not bugs against the v6 capture.
|
||||
|
||||
## Triage suggestions for `ui/*.md` cleanup
|
||||
|
||||
Aimed at humans editing the docs. Ordered by impact:
|
||||
|
||||
1. **Mark out-of-renderer surfaces explicitly.** `ui/tray.md`,
|
||||
`ui/quick-entry.md`, `ui/notifications.md`, and the OS-frame section
|
||||
of `ui/window-chrome-and-tabs.md` already reference main-process
|
||||
source and DE behavior — add a header note that this surface
|
||||
intentionally doesn't appear in `ui-inventory.json`.
|
||||
2. **Annotate walker-coverage-gap surfaces.** `ui/code-tab-panes.md`,
|
||||
`ui/settings.md`, `ui/routines-page.md` — header note that the
|
||||
inventory does not yet drill these surfaces; rows reflect upstream
|
||||
behavior and are unverified in the renderer.
|
||||
3. **Add missing topbar/prompt-area elements** to `ui/window-chrome-and-tabs.md`
|
||||
and `ui/prompt-area.md` from the "In renderer but not in docs" lists.
|
||||
4. **Decide the doc/inventory boundary for sidebar session lists.** Doc
|
||||
treats sessions as a category; inventory enumerates each. Pick one
|
||||
shape and document it.
|
||||
5. **Flag speculative Linux-conditional rows** — `ui/settings.md` SSH
|
||||
connections, "Denied apps" / "Unhide apps when Claude finishes" for
|
||||
Computer Use — mark as "may not render on Linux; verify before
|
||||
assuming."
|
||||
3761
docs/testing/ui-inventory.json
Normal file
3761
docs/testing/ui-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
12
docs/testing/ui-inventory.meta.json
Normal file
12
docs/testing/ui-inventory.meta.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"capturedAt": "2026-05-03T07:13:20.024Z",
|
||||
"appVersion": "1.5354.0",
|
||||
"walkerVersion": "7",
|
||||
"startUrl": "https://claude.ai/epitaxy",
|
||||
"totalElements": 90,
|
||||
"deniedActions": 6,
|
||||
"partial": false,
|
||||
"isolation": "launchClaude (test-harness path)",
|
||||
"seededFromHost": true,
|
||||
"allowlistEntries": []
|
||||
}
|
||||
0
docs/testing/ui-snapshots/.gitkeep
Normal file
0
docs/testing/ui-snapshots/.gitkeep
Normal file
76
docs/testing/ui-snapshots/README.md
Normal file
76
docs/testing/ui-snapshots/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# UI snapshots
|
||||
|
||||
Captured renderer state for the `claude.ai` web view, taken via the
|
||||
`explore` CLI in [`tools/test-harness/explore/`](../../../tools/test-harness/explore/).
|
||||
Use these to detect upstream UI drift before it breaks the harness.
|
||||
|
||||
The snapshot JSON files themselves are gitignored
|
||||
(`docs/testing/ui-snapshots/*.json`) — they're noisy diffs and
|
||||
specific to the moment of capture. This directory is checked in so the
|
||||
path exists; the README + `.gitkeep` are the only tracked files.
|
||||
|
||||
## Capture
|
||||
|
||||
Requires a running `claude-desktop` build with the main-process
|
||||
debugger attached on port 9229 (Developer menu → Enable Main Process
|
||||
Debugger). Then, from `tools/test-harness/`:
|
||||
|
||||
```sh
|
||||
npx tsx explore/explore.ts snapshot baseline-code-tab
|
||||
# → wrote /…/docs/testing/ui-snapshots/baseline-code-tab.json
|
||||
```
|
||||
|
||||
Snapshot names are restricted to `[a-zA-Z0-9._-]`.
|
||||
|
||||
## Compare
|
||||
|
||||
```sh
|
||||
npx tsx explore/explore.ts diff baseline-code-tab after-feature-x
|
||||
```
|
||||
|
||||
Add `--json` for machine-readable output. Add `--exit-on-diff` to fail
|
||||
the process (exit code 3) when there are any entries — useful inside a
|
||||
CI guard.
|
||||
|
||||
`diff` arguments accept either a bare name (looked up in this dir,
|
||||
`.json` appended) or an explicit path.
|
||||
|
||||
### What counts as a diff
|
||||
|
||||
| Kind | Meaning |
|
||||
|-----------|---------------------------------------------------------|
|
||||
| `removed` | Element keyed in A absent from B (drift signal). |
|
||||
| `changed` | Same key, different visible text or structural detail. |
|
||||
| `added` | New key in B (informational only — surface gained). |
|
||||
|
||||
## Snapshot shape
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"capturedAt": "2026-05-02T17:30:00Z",
|
||||
"claudeAiUrl": "https://claude.ai/…",
|
||||
"appVersion": "1.1.7714", // from app.getVersion(), null on failure
|
||||
"pageState": { "url", "title", "readyState" },
|
||||
"dfPills": [ /* Chat / Cowork / Code top-level tabs */ ],
|
||||
"compactPills": [ /* env pill, Select-folder pill, … */ ],
|
||||
"ariaLabeledButtons":[ /* every <button[aria-label]>, capped at 200 */ ],
|
||||
"openMenu": { "ariaLabelledBy", "ariaLabel", "items": [...] },
|
||||
"modals": [ /* role=dialog with heading + buttons */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Discovery is by **structural shape**, never by minified Tailwind class
|
||||
names. See the why-block at the top of
|
||||
[`tools/test-harness/explore/snapshot.ts`](../../../tools/test-harness/explore/snapshot.ts)
|
||||
for the rationale.
|
||||
|
||||
## Other subcommands
|
||||
|
||||
```sh
|
||||
npx tsx explore/explore.ts # full snapshot to stdout
|
||||
npx tsx explore/explore.ts pills # df-pills + compact-pills + state
|
||||
npx tsx explore/explore.ts menu # currently-open menu (or null)
|
||||
npx tsx explore/explore.ts find <re> # regex search over text + aria-label
|
||||
```
|
||||
|
||||
`find` regex is case-insensitive by default.
|
||||
360
docs/testing/ui-vocabulary.json
Normal file
360
docs/testing/ui-vocabulary.json
Normal file
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"derivedAt": "2026-05-03T02:51:23.409Z",
|
||||
"sourceInventory": {
|
||||
"capturedAt": "2026-05-03T00:21:38.299Z",
|
||||
"appVersion": "1.5354.0",
|
||||
"walkerVersion": "6",
|
||||
"totalElements": 383
|
||||
},
|
||||
"stable": [
|
||||
"Accept edits",
|
||||
"Add",
|
||||
"Add connector",
|
||||
"Add files",
|
||||
"Add files or photosCtrl+U",
|
||||
"Add files, connectors, and more",
|
||||
"Add from GitHub",
|
||||
"Add to project",
|
||||
"All projects",
|
||||
"Appearance",
|
||||
"Ask",
|
||||
"Back",
|
||||
"Back to Claude",
|
||||
"Chat",
|
||||
"Clear active",
|
||||
"Close",
|
||||
"Close side chat",
|
||||
"Close suggestions",
|
||||
"Code",
|
||||
"Completed: See Claude workTry a quick task — Claude does it, you watch",
|
||||
"ConcisePreset",
|
||||
"Connectors",
|
||||
"Conversation ID reference",
|
||||
"Copy invite",
|
||||
"Cowork",
|
||||
"Create custom style",
|
||||
"Create engaging headlines",
|
||||
"Create presentation scripts",
|
||||
"Develop content templates",
|
||||
"Develop storytelling frameworks",
|
||||
"Dictation settings",
|
||||
"Dismiss checklist",
|
||||
"Dismiss guest pass",
|
||||
"Draft PR visibility on GitHub",
|
||||
"ELKO HRN-33 and HRN-31 manuals",
|
||||
"Edit Instructions",
|
||||
"Electron apps Linux users desperately want but can't have\nDespite Electron's cross-platform promise, several high-profil",
|
||||
"Expand sidebar",
|
||||
"ExplanatoryPreset",
|
||||
"Feedback submission",
|
||||
"Filter",
|
||||
"Fine-tuning diffusion models with reinforcement learning",
|
||||
"FormalPreset",
|
||||
"Forward",
|
||||
"From Calendar",
|
||||
"From Gmail",
|
||||
"Get apps and extensions",
|
||||
"Gmail",
|
||||
"Google Calendar",
|
||||
"How to use ClaudeAaddrick Williams",
|
||||
"Install",
|
||||
"Invalid session description",
|
||||
"Lamination plate position offsetsAaddrick Williams",
|
||||
"Learn",
|
||||
"Learn about styles",
|
||||
"Learn how to use Cowork safely",
|
||||
"Learn more about styles",
|
||||
"Learning",
|
||||
"LearningPreset",
|
||||
"Local",
|
||||
"Manage connectors",
|
||||
"Menu",
|
||||
"Model: Legacy Model",
|
||||
"Model: Opus 4.7 Adaptive",
|
||||
"Model: Sonnet 4.6 Adaptive",
|
||||
"More navigation items",
|
||||
"More options",
|
||||
"More options for Fine-tuning diffusion models with reinforcement learning",
|
||||
"More options for How to use Claude",
|
||||
"New artifact",
|
||||
"New project",
|
||||
"Open session Audit for elementary-data supply chain vulnerability",
|
||||
"Open session Find contact method for Claude Desktop issue",
|
||||
"Open session Plan automated testing strategy for desktop app",
|
||||
"Open session Test DNS query for Claude desktop package",
|
||||
"Open session for PR #552",
|
||||
"Pair your phoneSend tasks from your phone for Claude to run here",
|
||||
"Pin project",
|
||||
"Pinned",
|
||||
"Plugins",
|
||||
"Press and hold to record",
|
||||
"Recents",
|
||||
"Research",
|
||||
"Research mode",
|
||||
"Schedule a recurring taskGreat for reminders, reports, or regular check-ins",
|
||||
"Scroll to bottom",
|
||||
"Search",
|
||||
"Search projects",
|
||||
"Select folder…",
|
||||
"Send",
|
||||
"Settings",
|
||||
"Show 5 more",
|
||||
"Show more",
|
||||
"Skills",
|
||||
"Skip to content",
|
||||
"Sort by",
|
||||
"Start a task in Cowork",
|
||||
"Style: Formal",
|
||||
"Terms apply",
|
||||
"Test",
|
||||
"Testing and Quality Assurance",
|
||||
"Tool accessLoad tools when needed",
|
||||
"Transcript view mode",
|
||||
"Untitled",
|
||||
"Use style",
|
||||
"View all",
|
||||
"Web search",
|
||||
"West Central Schools provincial takeover investigation",
|
||||
"Work in a project",
|
||||
"Write",
|
||||
"Write something in the voice of my favorite historical figure",
|
||||
"Your artifactsYour artifacts",
|
||||
"about_tab.py, py, 60 lines",
|
||||
"New chat⌘N",
|
||||
"New session⌘N",
|
||||
"New task⌘N",
|
||||
"Artifacts",
|
||||
"Live artifacts",
|
||||
"Scheduled",
|
||||
"DispatchBeta",
|
||||
"Routines",
|
||||
"How to use Claude",
|
||||
"Projects",
|
||||
"Customize"
|
||||
],
|
||||
"instanceShapes": [
|
||||
{
|
||||
"id": "plan-badge",
|
||||
"regex": "^.+·(Free|Pro|Max|Team|Enterprise)[-\\s]*$",
|
||||
"flags": "u",
|
||||
"pattern": "\\w+·(Free|Pro|Max|Team|Enterprise)",
|
||||
"matchedNames": [
|
||||
"AWAaddrick·Max"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "opus-version",
|
||||
"regex": "^Opus \\d",
|
||||
"flags": "",
|
||||
"pattern": "^Opus \\d",
|
||||
"matchedNames": [
|
||||
"Opus 4.7 1M· Extra high",
|
||||
"Opus 4.7Most capable for ambitious work"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sonnet-version",
|
||||
"regex": "^Sonnet \\d",
|
||||
"flags": "",
|
||||
"pattern": "^Sonnet \\d",
|
||||
"matchedNames": [
|
||||
"Sonnet 4.6Most efficient for everyday tasks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "haiku-version",
|
||||
"regex": "^Haiku \\d",
|
||||
"flags": "",
|
||||
"pattern": "^Haiku \\d",
|
||||
"matchedNames": [
|
||||
"Haiku 4.5Fastest for quick answers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "percentage",
|
||||
"regex": "\\d{1,3}%$",
|
||||
"flags": "",
|
||||
"pattern": "\\d{1,3}%",
|
||||
"matchedNames": [
|
||||
"Usage: plan 11%"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "relative-date",
|
||||
"regex": "(Today|Yesterday|\\d+\\s(day|hour|minute|second|week|month|year)s?\\sago)",
|
||||
"flags": "",
|
||||
"pattern": "(Today|Yesterday|\\d+\\s(day|hour|minute|second|week|month|year)s?\\sago)(\\+\\d+)?",
|
||||
"matchedNames": [
|
||||
"Claude Desktop Debian1 year ago",
|
||||
"Draft PR visibility on GitHubYesterday",
|
||||
"ELKO HRN-33 and HRN-31 manualsYesterday",
|
||||
"Feedback submissionYesterday",
|
||||
"Find contact method for Claude Desktop issuePR #552 · Yesterday",
|
||||
"Review PR 555 for issue 558 fixToday",
|
||||
"Review and analyze issue 545Yesterday"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "size-with-unit",
|
||||
"regex": "^\\d+\\.\\d+\\s\\w+",
|
||||
"flags": "",
|
||||
"pattern": "^\\d+\\.\\d+\\s\\w+",
|
||||
"matchedNames": []
|
||||
},
|
||||
{
|
||||
"id": "user-handle",
|
||||
"regex": "@\\w+",
|
||||
"flags": "",
|
||||
"pattern": "@\\w+",
|
||||
"matchedNames": []
|
||||
},
|
||||
{
|
||||
"id": "long-title",
|
||||
"regex": "^[A-Z][a-z]+ [A-Z][a-z]+ [a-z]",
|
||||
"flags": "",
|
||||
"pattern": null,
|
||||
"matchedNames": [
|
||||
"Evaluate Terraform for infrastructure setup",
|
||||
"Host Obsidian library in second database"
|
||||
]
|
||||
}
|
||||
],
|
||||
"suspect": [
|
||||
"Adaptive thinkingThinks for more complex tasks",
|
||||
"Add build instructions and patch toggle option",
|
||||
"Add build instructions and quick menu patch toggle",
|
||||
"Add plugin",
|
||||
"Audit for elementary-data supply chain vulnerability",
|
||||
"Automate",
|
||||
"Browse pluginsAdd pre-built knowledge for your field.",
|
||||
"Build adversarial resume review platform MVP",
|
||||
"Change fonts to Lexend",
|
||||
"Check Quad9 DNS resolution for package domain",
|
||||
"Check flight map tile caching history",
|
||||
"Check for Trivy supply chain vulnerability",
|
||||
"Claude Desktop DebianAaddrick Williams",
|
||||
"Claude Desktop DebianEnter",
|
||||
"Claude is AI and can make mistakes. Please double-check responses.",
|
||||
"Claude prompting guide.md, md, 413 lines",
|
||||
"Clawdmartclawdmart.comClaudeCreate a shopping list, go on Chrome, and make an order",
|
||||
"Collapse sidebar",
|
||||
"Compare GPU options for gaming performance",
|
||||
"Concise",
|
||||
"Connect your appsLet Claude read and write to the tools you already use.",
|
||||
"Copy",
|
||||
"Create & edit styles",
|
||||
"Create new skillsTeach Claude your processes, team norms, and expertise.",
|
||||
"Create user documentation",
|
||||
"Customer Email",
|
||||
"Data",
|
||||
"Develop editorial guidelines",
|
||||
"Dispatch background conversation",
|
||||
"Download",
|
||||
"Draw",
|
||||
"Edit",
|
||||
"Educational Content",
|
||||
"Evaluate productization viability of methodology",
|
||||
"Explanatory",
|
||||
"Find contact method for Claude Desktop issue",
|
||||
"Fix Claude Desktop installation on Debian",
|
||||
"Formal",
|
||||
"Formulas",
|
||||
"Give negative feedback",
|
||||
"Give positive feedback",
|
||||
"Help me develop a unique voice for an audience",
|
||||
"Home",
|
||||
"How to use ClaudeAn example project that also doubles as a how-to guide for using Claude. Chat with it to learn more abo",
|
||||
"Identify tools for session start hook",
|
||||
"Insert",
|
||||
"Investigate GitHub Actions workflow failure",
|
||||
"Investigate GitHub issue 394 comment",
|
||||
"Investigate leaked crates.io API key",
|
||||
"Investigate leaked crates.io token in repository",
|
||||
"Lamination plate position offsetsAdjust existing code to just populate a table with original positions, new positions, a",
|
||||
"Marketing Blog Post",
|
||||
"More models",
|
||||
"More options for Claude Desktop Debian",
|
||||
"More options for Lamination plate position offsets",
|
||||
"My downloads folder is a mess! Can you clean it up?",
|
||||
"Normal",
|
||||
"Open",
|
||||
"Options",
|
||||
"Page Layout",
|
||||
"Php lsp",
|
||||
"Plan automated testing strategy for desktop app",
|
||||
"Playwright",
|
||||
"Product Review",
|
||||
"Read health data",
|
||||
"Retry",
|
||||
"Review",
|
||||
"Review PR 555 for issue 558 fix",
|
||||
"Review and address issue 88",
|
||||
"Review and analyze issue 545",
|
||||
"Review and close stale issues",
|
||||
"Review and investigate GitHub issue 445",
|
||||
"Review issue 156",
|
||||
"Review issue 172 and document related history",
|
||||
"Review issue 373",
|
||||
"Review last three repository commits",
|
||||
"Review path resolution issues and pull requests",
|
||||
"Review project issues and pull requests",
|
||||
"Review recent comments, issues, and pull requests",
|
||||
"Select a folder",
|
||||
"Share chat",
|
||||
"Short Story",
|
||||
"Start a new project",
|
||||
"Start return",
|
||||
"Style: Concise",
|
||||
"Style: Explanatory",
|
||||
"Style: Learning",
|
||||
"Test DNS lookup with Quad9 resolver",
|
||||
"Test DNS query for Claude desktop package",
|
||||
"Test path resolution",
|
||||
"Test startsession hook functionality",
|
||||
"Troubleshoot modem downstream connection issue",
|
||||
"Turn these receipts into an expense report",
|
||||
"Typescript lsp",
|
||||
"Unpin project",
|
||||
"Untitled, rename chat",
|
||||
"View",
|
||||
"Write case studies",
|
||||
"Write speech drafts",
|
||||
"analyze_project.py, py, 220 lines",
|
||||
"base_half_sheet.py, py, 32 lines",
|
||||
"changelog_viewer_component.py, py, 113 lines",
|
||||
"colors.py, py, 103 lines",
|
||||
"compensation.py, py, 50 lines",
|
||||
"components.py, py, 118 lines",
|
||||
"components.py, py, 119 lines",
|
||||
"config_reader.py, py, 120 lines",
|
||||
"contraction_tab.py, py, 105 lines",
|
||||
"contraction_tab.py, py, 82 lines",
|
||||
"conversions.py, py, 28 lines",
|
||||
"data_parser.py, py, 87 lines",
|
||||
"dialogs.py, py, 34 lines",
|
||||
"file_operations.py, py, 43 lines",
|
||||
"log.py, py, 140 lines",
|
||||
"log.py, py, 236 lines",
|
||||
"machines.ini, ini, 2 lines",
|
||||
"main.py, py, 203 lines",
|
||||
"main.py, py, 264 lines",
|
||||
"output_tab.py, py, 191 lines",
|
||||
"output_tab.py, py, 246 lines",
|
||||
"process_request.py, py, 632 lines",
|
||||
"processing_format.ini, ini, 2 lines",
|
||||
"setup_tab.py, py, 120 lines",
|
||||
"setup_tab.py, py, 177 lines",
|
||||
"sheet_dimensions.ini, ini, 3 lines",
|
||||
"version 0.1.0.md, md, 42 lines",
|
||||
"version 0.1.1.md, md, 31 lines",
|
||||
"version 0.1.2.md, md, 18 lines",
|
||||
"View all plans",
|
||||
"Get apps and extensions",
|
||||
"Gift Claude",
|
||||
"Language",
|
||||
"Get help",
|
||||
"Learn more",
|
||||
"Log out",
|
||||
"SettingsCtrl,"
|
||||
]
|
||||
}
|
||||
78
docs/testing/ui/README.md
Normal file
78
docs/testing/ui/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# UI Element Inventory
|
||||
|
||||
This directory holds per-surface UI checklists. Where [`../cases/`](../cases/) tests verify *behavior end-to-end*, files here verify *every UI element renders and responds* on Linux.
|
||||
|
||||
## Why a separate directory
|
||||
|
||||
A functional test like [T17 — Folder picker opens](../cases/code-tab-foundations.md#t17--folder-picker-opens) verifies the folder picker works. A UI checklist asks the smaller, more granular questions:
|
||||
|
||||
- Is the **Select folder** button visually present?
|
||||
- Does its hover state render?
|
||||
- Is the icon next to it the correct shape on a HiDPI screen?
|
||||
- Does it tab-focus correctly?
|
||||
- Does it have an accessible name (a11y)?
|
||||
|
||||
Functional tests catch "the feature broke." UI checklists catch "the feature works but looks wrong." Both matter on Linux because Electron under different DEs / display servers / GTK theme combinations produces visual artifacts that aren't behavioral failures.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Surface | Notes |
|
||||
|------|---------|-------|
|
||||
| [`window-chrome-and-tabs.md`](./window-chrome-and-tabs.md) | OS window frame + hybrid in-app topbar + Chat/Cowork/Code tabs | Crosses with [T04](../cases/tray-and-window-chrome.md#t04--window-decorations-draw), [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) |
|
||||
| [`tray.md`](./tray.md) | System tray icon + menu + theme variants | Crosses with [T03](../cases/tray-and-window-chrome.md#t03--tray-icon-present), [S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update) |
|
||||
| [`sidebar.md`](./sidebar.md) | Session sidebar in Code tab | Crosses with [T29](../cases/code-tab-workflow.md#t29--worktree-isolation), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
|
||||
| [`prompt-area.md`](./prompt-area.md) | Code-tab prompt input area | Crosses with [T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt), [T32](../cases/code-tab-workflow.md#t32--slash-command-menu) |
|
||||
| [`code-tab-panes.md`](./code-tab-panes.md) | Diff, preview, terminal, file, tasks, subagent, plan, side-chat | Crosses with [T19](../cases/code-tab-foundations.md#t19--integrated-terminal), [T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves), [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane), [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh), [T31](../cases/code-tab-workflow.md#t31--side-chat-opens) |
|
||||
| [`settings.md`](./settings.md) | All Settings pages | Crosses with [S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend), [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) |
|
||||
| [`routines-page.md`](./routines-page.md) | Routines list + new-routine form + detail page | Crosses with [T26](../cases/routines.md#t26--routines-page-renders), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies) |
|
||||
| [`connectors-and-plugins.md`](./connectors-and-plugins.md) | Connector picker, connector list, plugin browser, plugin manager | Crosses with [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser), [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
|
||||
| [`quick-entry.md`](./quick-entry.md) | Quick Entry popup window | Crosses with [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) |
|
||||
| [`notifications.md`](./notifications.md) | libnotify rendering for all notification sources | Crosses with [T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
|
||||
|
||||
## Standard checklist row
|
||||
|
||||
Each UI file uses tables of the form:
|
||||
|
||||
| Element | Selector / location | Expected | Notes |
|
||||
|---------|---------------------|----------|-------|
|
||||
| Close button | Top-right of titlebar | Renders, hover state visible, click hides to tray (see T08) | KDE-W: ✓ |
|
||||
|
||||
Columns:
|
||||
|
||||
- **Element** — human-readable name.
|
||||
- **Selector / location** — DOM selector if known, otherwise plain-language pointer ("right-click menu, second item from top"). The selector column is what becomes a Playwright/CDP assertion when automation lands.
|
||||
- **Expected** — what the user should see / what should happen on click. Concise.
|
||||
- **Notes** — known issues, environment caveats, screenshot links.
|
||||
|
||||
## Sweep workflow
|
||||
|
||||
A UI sweep on a row:
|
||||
|
||||
1. Take a baseline screenshot of each surface (`scrot`, `gnome-screenshot`, `grim`, `flameshot`).
|
||||
2. Walk each table top-to-bottom. For each row, look at the element, click/hover/tab to it, compare against Expected.
|
||||
3. Mark anomalies in the **Notes** column or file an issue if the deviation is environment-specific.
|
||||
4. Save screenshots of any failure to a dated folder; reference them inline.
|
||||
|
||||
UI rows don't have stable IDs (`T##` / `S##`) — they're append-only checkpoints. When something becomes a regression candidate worth tracking long-term, promote it to a functional test in [`../cases/`](../cases/).
|
||||
|
||||
## Automation roadmap
|
||||
|
||||
Each UI checklist row is a candidate Playwright (via [Electron driver](https://playwright.dev/docs/api/class-electron)) or `xdotool` assertion:
|
||||
|
||||
```typescript
|
||||
// Playwright shape
|
||||
await page.locator('[data-testid="close-button"]').click()
|
||||
await expect(window).toBeHidden()
|
||||
```
|
||||
|
||||
Or for pure visual diffing:
|
||||
|
||||
```bash
|
||||
# scrot + perceptualdiff
|
||||
scrot -u baseline.png
|
||||
# ... interaction ...
|
||||
scrot -u current.png
|
||||
perceptualdiff baseline.png current.png
|
||||
```
|
||||
|
||||
The structure here is intentionally diff-friendly: rows are stable, tables are append-only, selectors live in their own column.
|
||||
114
docs/testing/ui/code-tab-panes.md
Normal file
114
docs/testing/ui/code-tab-panes.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# UI — Code Tab Panes
|
||||
|
||||
Drag-and-drop panes inside a Code-tab session: diff, preview, terminal, file editor, tasks, subagent, plan, side chat. Related functional tests: [T19](../cases/code-tab-foundations.md#t19--integrated-terminal), [T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves), [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane), [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh), [T31](../cases/code-tab-workflow.md#t31--side-chat-opens).
|
||||
|
||||
## Pane chrome (common)
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Pane header | Top of pane | Shows pane title, drag handle, close button | — |
|
||||
| Drag handle | Pane header | Drag repositions the pane in the layout | — |
|
||||
| Resize handle | Edge between panes | Drag resizes; double-click resets | — |
|
||||
| Close pane button | Pane header right | `Cmd+\` or Ctrl+\\ shortcut equivalent | — |
|
||||
| Views menu | Session toolbar | Lists all openable panes; click to add | — |
|
||||
|
||||
## Diff pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Diff stats indicator | Chat / sidebar (entry point) | Shows `+12 -1` style. Click opens diff pane | — |
|
||||
| File list | Left side of pane | Lists changed files, click to navigate | — |
|
||||
| Diff content | Right side | Side-by-side or unified diff renders cleanly | Theme-aware (dark/light) |
|
||||
| Line click → comment box | Click any line | Opens inline comment input | — |
|
||||
| Comment submit (`Cmd+Enter` / `Ctrl+Enter`) | Press the shortcut after writing | Submits all comments at once | — |
|
||||
| Accept button | Per-file or per-hunk | Applies the change to disk | — |
|
||||
| Reject button | Per-file or per-hunk | Discards the change | — |
|
||||
| **Review code** button | Top-right of pane | Triggers Claude self-review of diff | — |
|
||||
|
||||
## Preview pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Preview dropdown | Session toolbar | Lists configured servers from `.claude/launch.json` | — |
|
||||
| **Start** action | Per-server entry | Launches the dev server | — |
|
||||
| **Stop** action | Per-server entry | Stops the dev server | — |
|
||||
| **Stop all servers** | Dropdown bottom | Stops every running server | — |
|
||||
| **Edit configuration** | Dropdown bottom | Opens `.claude/launch.json` in the file pane | — |
|
||||
| **Persist sessions** toggle | Dropdown | Persists cookies / localStorage across server restarts | — |
|
||||
| Embedded browser frame | Pane content | Renders the running app | Uses Electron `<webview>` or `BrowserView` |
|
||||
| URL bar / address | Top of pane | Shows current URL; editable | — |
|
||||
| Reload button | Top of pane | Reloads the embedded URL | — |
|
||||
| DevTools toggle | Top of pane (right) | Opens Electron DevTools for the embedded view | — |
|
||||
| Auto-verify screenshots | When Claude verifies a change | Brief overlay shows screenshot being captured | — |
|
||||
|
||||
## Terminal pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Terminal pane | Opened via `Ctrl+`` or Views menu | Bash/zsh/fish session in the working directory ([T19](../cases/code-tab-foundations.md#t19--integrated-terminal)) | Local sessions only |
|
||||
| Cursor | Inside terminal | Blinks; cursor shape per shell | — |
|
||||
| Resize | Drag pane edges | Terminal cols/rows update; `tput cols` reflects new width | SIGWINCH should fire |
|
||||
| Scrollback | Type many lines | Scrollable history; mouse scroll wheel works | — |
|
||||
| Color rendering | Run `ls --color=auto`, `tput colors` | 256-color or truecolor support; theme-aware | — |
|
||||
| Copy / paste | Select + `Ctrl+Shift+C` / `Ctrl+Shift+V` | Standard terminal-emulator shortcuts | — |
|
||||
| Working directory inheritance | Open pane in a session | Opens at the session's project folder | Confirm with `pwd` |
|
||||
|
||||
## File pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| File pane | Opened by clicking a file path | Shows file content, syntax-highlighted | — |
|
||||
| Save button | Pane toolbar | Writes current content to disk | — |
|
||||
| Path label | Pane header | Click copies absolute path | — |
|
||||
| On-disk-changed warning | If file changed externally after open | Banner with Override / Discard options ([T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves)) | — |
|
||||
| Discard button | When edits unsaved | Reverts to disk content | — |
|
||||
| Cursor / selection | Inside content | Renders correctly; multi-cursor not supported | — |
|
||||
| Find / replace | `Ctrl+F` | Opens find-in-file overlay | Verify scoped to current pane only |
|
||||
|
||||
## Tasks pane / subagent pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Tasks pane | Opened via Views menu | Lists subagents, background shell commands, workflows | — |
|
||||
| Task entry click | Click any task | Opens the subagent pane with output | — |
|
||||
| Stop task button | Per-task | Sends interrupt signal | — |
|
||||
| Task status indicator | Per-task | Running / Completed / Failed | — |
|
||||
| Output stream | Inside subagent pane | Live-updating stdout/stderr | — |
|
||||
|
||||
## Side chat overlay
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Side chat trigger | `Ctrl+;` or `/btw` in main prompt | Opens overlay attached to current session ([T31](../cases/code-tab-workflow.md#t31--side-chat-opens)) | — |
|
||||
| Side chat content | Overlay body | Reads main thread context; replies stay in side chat | — |
|
||||
| Close button | Overlay top-right | Closes side chat, returns focus to main session | — |
|
||||
|
||||
## CI status bar
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| CI status row | Below prompt area when PR open | Shows current check states | Crosses with [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) |
|
||||
| **Auto-fix** toggle | Top of CI bar | Toggles automatic check-failure fixes | — |
|
||||
| **Auto-merge** toggle | Top of CI bar | Toggles auto-merge on green | Requires GitHub repo setting |
|
||||
| Per-check entries | Each CI check | Shows pass / fail / pending state | Click to see logs |
|
||||
| CI completion notification | When all checks resolve | Desktop notification posted ([T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire)) | — |
|
||||
|
||||
## View modes
|
||||
|
||||
| Mode | Trigger | Expected | Notes |
|
||||
|------|---------|----------|-------|
|
||||
| Normal | Default; cycle via `Ctrl+O` | Tool calls collapsed into summaries, full text responses | — |
|
||||
| Verbose | Cycle via `Ctrl+O` | Every tool call, file read, intermediate step | Use for debugging |
|
||||
| Summary | Cycle via `Ctrl+O` | Only Claude's final responses + changes | Use when scanning many sessions |
|
||||
| Transcript view dropdown | Next to send button | Same as `Ctrl+O` | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Pane drag doesn't snap to layout zones | Layout engine state corruption; restart session | — |
|
||||
| Terminal cursor doesn't blink | `xterm-256color` not propagated; `TERM` env wrong | `echo $TERM` inside the pane |
|
||||
| File pane "Save" silently no-ops | Read-only filesystem ([S28](../cases/extensibility.md#s28--worktree-creation-surfaces-clear-error-on-read-only-mounts)); permissions wrong | `stat <file>` for ownership |
|
||||
| Preview pane embedded browser blank | Dev server didn't bind expected port; `autoPort` config | Check launcher log; `lsof -i :<port>` |
|
||||
| Auto-verify screenshots fail | Headless screenshot in embedded view broken on Wayland | Test on X11 row; report to upstream |
|
||||
| CI bar shows stale state | `gh` polling interval; rate-limited | `gh api rate_limit`; manual `gh pr checks <num>` |
|
||||
70
docs/testing/ui/connectors-and-plugins.md
Normal file
70
docs/testing/ui/connectors-and-plugins.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# UI — Connectors & Plugins
|
||||
|
||||
Connector picker, connectors list, plugin browser, plugin manager. Related functional tests: [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser), [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip), [S27](../cases/extensibility.md#s27--plugins-install-per-user-not-into-system-paths).
|
||||
|
||||
## Connector picker (in-session)
|
||||
|
||||
Triggered by `+` → **Connectors** in the prompt area.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Connectors menu | Opened from `+` button | Lists configured connectors + "Manage connectors" entry | — |
|
||||
| Per-connector row | Menu item | Name, status indicator (connected / not configured), action button | — |
|
||||
| **Manage connectors** entry | Bottom of menu | Opens Settings → Connectors | Crosses with [`settings.md`](./settings.md#connectors) |
|
||||
| Empty state | When no connectors configured | Helpful prompt with "Add connector" call to action | — |
|
||||
|
||||
## Connectors list (Settings → Connectors)
|
||||
|
||||
See [`settings.md`](./settings.md#connectors) for the surface.
|
||||
|
||||
## Add-connector flow
|
||||
|
||||
Triggered from the connector picker or Settings.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Connector catalog | Modal body | Searchable list (Slack, GitHub, Linear, Notion, Google Calendar, etc.) | — |
|
||||
| Per-connector tile | Catalog entry | Logo, name, short description | — |
|
||||
| **Connect** button | Per tile | Initiates OAuth flow ([T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip)) | Click → `xdg-open` to provider |
|
||||
| OAuth in-app overlay (if used) | Replaces system browser handoff in some flows | Embedded login pane | — |
|
||||
| Permission consent screen | OAuth provider side | Provider's UI; not under our control | — |
|
||||
| Callback completion | After OAuth completes | Returns to Claude Desktop, connector now in list | If the URL scheme handler is broken, user is stranded in browser |
|
||||
| Custom connector entry point | Catalog bottom | "Add custom connector via remote MCP" link | — |
|
||||
|
||||
## Plugin browser
|
||||
|
||||
Triggered by `+` → **Plugins** → **Add plugin**, or from sidebar **Customize** → **Plugins**.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Plugin browser modal | Opened from menu | Searchable marketplace catalog | — |
|
||||
| Marketplace selector | Top of modal | Default: Anthropic official; user-configured marketplaces also visible | — |
|
||||
| Per-plugin tile | Catalog body | Name, author, description, install count | — |
|
||||
| **Install** button | Per tile | Click installs to `~/.claude/plugins/` ([T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [S27](../cases/extensibility.md#s27--plugins-install-per-user-not-into-system-paths)) | — |
|
||||
| Plugin scope selector | Per install | User / Project / Local-only | — |
|
||||
| Install progress indicator | During install | Spinner + "Installing X..." text | — |
|
||||
| Install success state | After install | Confirmation; plugin now in **Manage plugins** | — |
|
||||
| Install error state | On failure | Error message identifying the cause (network, signature, conflict) | — |
|
||||
|
||||
## Manage plugins
|
||||
|
||||
Triggered by `+` → **Plugins** → **Manage plugins**.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Installed plugins list | Modal body | One row per installed plugin | — |
|
||||
| Per-plugin row | List item | Name, version, scope (User / Project / Local), enable toggle, uninstall button | — |
|
||||
| Enable toggle | Per row | Toggles plugin on/off without uninstall | — |
|
||||
| **Uninstall** button | Per row | Removes plugin files from `~/.claude/plugins/` | Confirmation expected |
|
||||
| Plugin skills sub-list | Expand row | Lists skills, agents, hooks, MCP servers, LSP configs the plugin contributes | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Connect OAuth doesn't return to app | Custom URI scheme not registered ([T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip)) | `xdg-mime query default x-scheme-handler/claude` |
|
||||
| Plugin browser empty | Marketplace fetch failed; offline | DevTools network panel |
|
||||
| Install progress stalls | Network / signature verification | Launcher log; check `~/.claude/plugins/.partial/` for incomplete downloads |
|
||||
| Plugin installed but skills don't appear | Slash menu cache stale; restart session | — |
|
||||
| Uninstall leaves files | Filesystem permissions; some plugin files owned by root | `find ~/.claude/plugins/ -not -user $USER` |
|
||||
| Connector "Connected" but tools fail | Token expired; backend refuses; needs reconnect | Disconnect → reconnect |
|
||||
59
docs/testing/ui/notifications.md
Normal file
59
docs/testing/ui/notifications.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# UI — Desktop Notifications
|
||||
|
||||
Notification rendering across DEs. The app dispatches notifications via `org.freedesktop.Notifications` (libnotify spec); each DE renders them differently. Related functional tests: [T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification).
|
||||
|
||||
## Notification sources
|
||||
|
||||
The app posts notifications for the following events. Each should fire reliably on every supported DE.
|
||||
|
||||
| Source | Trigger | Expected text | Click action | Notes |
|
||||
|--------|---------|---------------|--------------|-------|
|
||||
| Scheduled task fires | When a routine starts a run | "Scheduled task `<name>` started" or similar | Focus the new session in sidebar | Crosses with [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies) |
|
||||
| Catch-up run | When a missed run starts after wake | "Catching up on `<name>`" + missed-time hint | Focus the catch-up session | Crosses with [T28](../cases/routines.md#t28--scheduled-task-catch-up-after-suspend) |
|
||||
| CI status change | When PR's CI state resolves | "CI passed for `<branch>`" or "CI failed: `<check>`" | Focus the session with CI bar | Crosses with [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) |
|
||||
| PR merged (auto-archive trigger) | When watched PR merges | "PR `<title>` merged. Session archived" | — | Crosses with [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) |
|
||||
| Dispatch handoff | When a Dispatch task creates a Code session | "Dispatch session ready: `<task>`" | Focus the new Dispatch-badged session | Crosses with [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
|
||||
| Permission prompt awaiting approval | When a session in Ask mode needs user approval | "Claude needs your approval" | Focus the awaiting session | Sessions in Ask mode stall until answered |
|
||||
|
||||
## Per-notification anatomy
|
||||
|
||||
Each notification should include:
|
||||
|
||||
| Element | Expected | Notes |
|
||||
|---------|----------|-------|
|
||||
| App identity | "Claude" or "Claude Desktop" as the source | DE-specific (Plasma shows the app name and icon prominently) |
|
||||
| Notification icon | App icon (theme-aware) | Should match the same icon set as the tray |
|
||||
| Title | Short event headline | One line, no truncation issues for typical lengths |
|
||||
| Body | One or two short lines of context | Wrap correctly for the DE's notification width |
|
||||
| Actions (if any) | Inline buttons (e.g. "Open", "Dismiss") | Some DEs show actions, some require expand |
|
||||
| Click target | Activates the relevant session/window | — |
|
||||
|
||||
## Per-DE rendering
|
||||
|
||||
| DE / daemon | Expected render | Caveats |
|
||||
|-------------|-----------------|---------|
|
||||
| KDE Plasma | KDE notification daemon (KNotifications); appears top-right by default; inline action buttons supported | — |
|
||||
| GNOME Shell | gnome-shell built-in; appears top-center; limited action support | — |
|
||||
| Mako (wlroots) | Stacked notifications top-right by default; supports actions if config allows | — |
|
||||
| Dunst | Lightweight; respects `~/.config/dunst/dunstrc`; actions via keybinds | — |
|
||||
| swaync (Sway) | Notification center + popups | — |
|
||||
| Niri | Compositor-provided; usually a portable daemon (mako, dunst) | — |
|
||||
|
||||
## Notification persistence
|
||||
|
||||
| Element | Expected | Notes |
|
||||
|---------|----------|-------|
|
||||
| Notification history | DE-dependent (KDE has notification panel; GNOME has Calendar drawer; mako/dunst can be configured) | Don't rely on persistence — assume fire-and-forget |
|
||||
| Do-not-disturb mode | Respect DE's DND state | If user has DND on, notifications shouldn't fire — verify the daemon honors this |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Diagnose with |
|
||||
|---------|--------------|---------------|
|
||||
| No notifications appear | No daemon running; service not registered | `gdbus call --session --dest=org.freedesktop.Notifications --object-path=/org/freedesktop/Notifications --method=org.freedesktop.DBus.Introspectable.Introspect`; `notify-send "test"` from terminal |
|
||||
| Notification fires but no icon | Icon path resolution failed; theme strip | Inspect the dbus call body for `app_icon` value |
|
||||
| Click does nothing | Action handler IPC missed; window already focused | Click while main window is hidden — does it appear? |
|
||||
| Title/body cut off | DE truncation policy | Test with shorter strings to confirm content vs. layout |
|
||||
| Notifications fire even in DND | Daemon ignoring DND, or our app sets `urgency=critical` inappropriately | Check `urgency` hint in the dbus call |
|
||||
| Notification persists indefinitely | `expire_timeout=-1` (never) used inappropriately | Confirm timeout passed in the dbus call |
|
||||
| Per-source duplicates | Multiple subscribers to the same event | Diagnose by isolating one source at a time |
|
||||
76
docs/testing/ui/prompt-area.md
Normal file
76
docs/testing/ui/prompt-area.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# UI — Code Tab Prompt Area
|
||||
|
||||
The prompt input area is where users type messages, attach files, pick model and permission mode, and trigger send/stop. Related functional tests: [T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt), [T32](../cases/code-tab-workflow.md#t32--slash-command-menu).
|
||||
|
||||
## Text input
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Input field | Bottom center of session pane | Single-line on focus, expands to multi-line as user types | — |
|
||||
| Placeholder text | Empty state | Helpful hint ("Type to message Claude...") | — |
|
||||
| Cursor caret | Inside input | Blinks; visible against any background | — |
|
||||
| Multi-line autosize | Type a long message | Input grows up to a max height, then scrolls | — |
|
||||
| Word wrap | Long text | Wraps at field width without horizontal scroll | — |
|
||||
| Paste plain text | `Ctrl+V` after copying text | Inserts at cursor | — |
|
||||
| Paste image | `Ctrl+V` after copying an image | Attaches as file (see attachments below) | — |
|
||||
| `Enter` to send | Press Enter | Submits prompt | — |
|
||||
| `Shift+Enter` for newline | Press Shift+Enter | Inserts newline, doesn't submit | — |
|
||||
| `Esc` | Press Esc when prompt has content | DE-dependent; typically does nothing in input | — |
|
||||
| IME composition | Compose a CJK character | Composition UI renders correctly above the input | Fcitx5/IBus integration |
|
||||
|
||||
## Attachments
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Attachment button | Left of input (paperclip icon) | Click opens native file chooser | Wayland: portal-backed |
|
||||
| File-attached chip | Above or inside input | Shows filename + remove (X) button | — |
|
||||
| Multiple attachments | Attach 3+ files | Each shows as a separate chip; stacked if needed | — |
|
||||
| Image preview thumbnail | Image attachments | Shows small thumbnail | — |
|
||||
| PDF preview | PDF attachments | Shows generic PDF icon + filename | — |
|
||||
| Drag-drop overlay | Drag a file from file manager into the prompt | Overlay highlight indicates drop zone; release attaches ([T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt)) | — |
|
||||
| `@filename` autocomplete | Type `@` in prompt | Dropdown shows matching project files | Local and SSH only |
|
||||
|
||||
## `+` menu (skills, plugins, connectors)
|
||||
|
||||
| Element | Position in menu | Expected | Notes |
|
||||
|---------|------------------|----------|-------|
|
||||
| `+` button | Adjacent to attachment button | Click opens menu | — |
|
||||
| **Slash commands** entry | Top of menu | Opens slash command picker (same as typing `/`) | Crosses with [T32](../cases/code-tab-workflow.md#t32--slash-command-menu) |
|
||||
| **Skills** entry | Mid-menu | Opens skill browser | — |
|
||||
| **Connectors** entry | Mid-menu | Opens connector picker / status | Crosses with [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
|
||||
| **Plugins** entry | Mid-menu | Opens installed plugin list | Crosses with [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser) |
|
||||
| **Add plugin** subentry | Under Plugins | Opens plugin browser | — |
|
||||
|
||||
## Slash menu (triggered by typing `/`)
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Menu container | Above prompt input | Modal-like overlay, scrollable | — |
|
||||
| Built-in commands section | Top of list | Lists `/btw`, `/compact`, etc. | — |
|
||||
| Project skills section | Mid-list | Lists skills from `.claude/skills/` | — |
|
||||
| User skills section | Mid-list | Lists skills from `~/.claude/skills/` | — |
|
||||
| Plugin skills section | Bottom-list | Lists skills from installed plugins | — |
|
||||
| Filter by typing | Type after `/` | Narrows the list | — |
|
||||
| Selected item insertion | `Enter` or click | Inserts highlighted token in prompt | — |
|
||||
| `Esc` to dismiss | Press Esc | Closes menu, keeps `/` typed | — |
|
||||
|
||||
## Pickers next to send button
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Model picker | Right of input | Dropdown of Sonnet, Opus, Haiku (per current plan availability) | `Cmd+Shift+I` opens |
|
||||
| Permission mode picker | Right of input | Dropdown of Ask, Auto accept, Plan, Auto, Bypass | `Cmd+Shift+M` opens |
|
||||
| Effort picker (when applicable) | Right of input | Dropdown of effort levels for adaptive-reasoning models | `Cmd+Shift+E` opens |
|
||||
| Send button | Far right | Click submits prompt | — |
|
||||
| Stop button | Replaces Send while Claude responding | Click interrupts current response | `Esc` shortcut equivalent |
|
||||
| Usage ring | Adjacent to model picker | Shows context window usage + plan usage | Click for details |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Drag-drop overlay doesn't appear | Electron drag-drop event not firing on Wayland | Try X11 fallback to isolate |
|
||||
| `@filename` autocomplete returns empty | Project-folder access not granted; folder picker [T17](../cases/code-tab-foundations.md#t17--folder-picker-opens) failed silently | Verify env pill shows the right folder |
|
||||
| Slash menu shows wrong skills | Settings shared between desktop and CLI ([T36](../cases/extensibility.md#t36--hooks-fire), [T37](../cases/extensibility.md#t37--claudemd-memory-loads)) | Check `~/.claude/skills/` content vs what's listed |
|
||||
| Send button greyed out unexpectedly | Permission mode or model not loaded | Refresh; check model dropdown |
|
||||
| IME composition broken | Electron IME pipeline regression | Test with simpler Electron app |
|
||||
49
docs/testing/ui/quick-entry.md
Normal file
49
docs/testing/ui/quick-entry.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# UI — Quick Entry Popup
|
||||
|
||||
The Quick Entry popup is the global-shortcut-triggered prompt overlay. Related functional tests: [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S09](../cases/shortcuts-and-input.md#s09--quick-window-patch-runs-only-on-kde-post-406-gate), [S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame), [S29](../cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity), [S33](../cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version), [S35](../cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts), [S36](../cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone), [S37](../cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy).
|
||||
|
||||
## Window appearance
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Window frame | None (frameless popup) | No OS-titlebar; no close/min/max buttons | Upstream sets `frame: false` on the BrowserWindow (`index.js:515381`) |
|
||||
| Background | Behind prompt UI | Transparent (no opaque square frame visible) on KDE Plasma Wayland ([S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame)) | Upstream already sets both `transparent: true` and `backgroundColor: "#00000000"` (`index.js:515380, 515383`). #370 regression is below the option-passing layer (Electron 41.0.4 CSD rework). KDE-W: pending; bug if opaque |
|
||||
| Rounded corners | Outer edge of UI | Visible | Compositor must support corner rounding via shaders / clip mask |
|
||||
| Drop shadow | Around popup | macOS-only at the Electron level; on Linux/Windows depends entirely on compositor | Upstream sets `hasShadow: Zr` where `Zr === process.platform === "darwin"` (`index.js:515384`). Linux is expected to render via compositor shadow support; wlroots without server-side decorations will not show one |
|
||||
| Position | Last-saved position, keyed on monitor; falls back to primary display if monitor is gone | Popup remembers its position across invocations and across app restarts ([S35](../cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts), [S36](../cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone)) | Upstream uses `an.get("quickWindowPosition")` (`index.js:515491-515526`) keyed on monitor label + resolution. Falls back to `cHn()` (`:515502`) when the saved monitor is gone. **Upstream does NOT place on cursor display or focused-window display** — it's last-position or primary, nothing else |
|
||||
| Always-on-top | Window manager hint | Stays above other windows | Upstream sets `alwaysOnTop: true` with level `"pop-up-menu"` (`index.js:515399`). On macOS this is per-app; on Linux compositors the level hint is interpreted variably |
|
||||
| Lifecycle | Lazy-created on first shortcut press | First shortcut press constructs the BrowserWindow; subsequent presses reuse it ([S29](../cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity)) | Upstream `if (!Ko \|\| ...) Ko = new BrowserWindow(...)` near `index.js:515375`. Means popup works in tray-only state with no main window mapped |
|
||||
| Persistence after main window destroy | Popup survives `mainWindow.destroy()` | Popup remains functional; submit guards skip show/focus when `ut` is destroyed ([S37](../cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy)) | Upstream `!ut \|\| ut.isDestroyed()` guard at `index.js:515595`. Likely unreachable on this project due to hide-to-tray override of X button |
|
||||
|
||||
## Input area
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Text input field | Center of popup | Receives focus immediately on open; cursor blinks | — |
|
||||
| Placeholder text | Empty input state | Shows guidance like "Ask Claude anything..." | — |
|
||||
| Multi-line autosize | Type a long prompt | Input grows downward as text wraps; popup grows with it | — |
|
||||
| `Enter` to submit | Press Enter | Sends prompt, closes popup. Prompt must be > 2 chars trimmed (`index.js:515530, 515533`); 1-2 char prompts are silently dropped | Renderer-side keymap; reaches main process via IPC `requestDismissWithPayload()` (`:515409`) |
|
||||
| `Shift+Enter` for newline | Press Shift+Enter | Inserts newline, doesn't submit | Renderer-side |
|
||||
| `Esc` to dismiss | Press Esc | Closes popup without submitting | Renderer-side; reaches main process via IPC `requestDismiss()` (`:515409`) |
|
||||
| Click outside | Click outside the popup window | Closes popup without submitting | Wired in **main process** via the popup's `blur` handler (`Ko.on("blur", () => g3A(null))` at `index.js:515465`) |
|
||||
| Paste behavior | Paste rich text | Text-only paste; no HTML residue | — |
|
||||
| IME / dead-key composition | Type composed characters | Composition UI renders correctly above the input | Fcitx5/IBus integration is fragile under Electron |
|
||||
|
||||
## Submit feedback
|
||||
|
||||
| Element | Trigger | Expected | Notes |
|
||||
|---------|---------|----------|-------|
|
||||
| Submit transition | Press Enter | Popup closes; main window navigates to a **new** chat session ([S31](../cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state)). Quick Entry never appends to existing chats — `ynt(e)` at `index.js:515546` always creates new | Upstream calls `mainWin.show()` + `mainWin.focus()` only — no `restore()`, no workspace migration. Behavior on minimized / hidden / cross-workspace main is compositor-dependent |
|
||||
| Loading indicator | While prompt is in flight | Brief spinner or fade-out — popup should not appear frozen | — |
|
||||
| Error state | Submit when offline / API error | Inline error message; popup stays open so user can retry | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Diagnose with |
|
||||
|---------|--------------|---------------|
|
||||
| Popup doesn't appear when shortcut pressed | Global shortcut not registered ([T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S11](../cases/shortcuts-and-input.md#s11--quick-entry-shortcut-fires-from-any-focus-on-wayland-mutter-xwayland-key-grab), [S14](../cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri)) | Launcher log; portal `BindShortcuts` outcome |
|
||||
| Opaque square frame visible behind UI | Transparent background not respected ([S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame)) | KDE compositor settings; BrowserWindow `transparent: true` arg |
|
||||
| Popup appears but input doesn't auto-focus | Focus stealing prevention by compositor; race in BrowserWindow `show()` + `focus()` | Wayland focus-request semantics; mutter is most strict |
|
||||
| IME composition cursor renders in wrong place | Electron IME integration bug | Try with simpler GTK app to isolate; report upstream Electron issue if reproducible |
|
||||
| Popup persists after submit | Close-on-submit IPC missed | Launcher log; DevTools console (if reachable on the popup window) |
|
||||
| Popup appears on wrong monitor / wrong workspace | Compositor places frameless windows differently | Test with `xdotool getactivewindow` (X11) before/after |
|
||||
72
docs/testing/ui/routines-page.md
Normal file
72
docs/testing/ui/routines-page.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# UI — Routines Page
|
||||
|
||||
The Routines page hosts the list of scheduled tasks (local and remote), the new-routine form, and per-routine detail views. Related functional tests: [T26](../cases/routines.md#t26--routines-page-renders), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [T28](../cases/routines.md#t28--scheduled-task-catch-up-after-suspend).
|
||||
|
||||
## Routines list
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Routines page link | Code-tab sidebar | Click opens the page ([T26](../cases/routines.md#t26--routines-page-renders)) | — |
|
||||
| Page header | Top of page | Title "Routines" + description | — |
|
||||
| **New routine** button | Top-right of page | Click shows Local / Remote selector | — |
|
||||
| Routines list | Page body | Lists all configured routines | — |
|
||||
| Per-routine row | List item | Name, schedule summary, last-run timestamp, status indicator | — |
|
||||
| Run-now icon | Per row, hover-revealed | Click triggers immediate run ([T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies)) | — |
|
||||
| Pause / resume toggle | Per row | Pauses or resumes scheduled runs without deleting | — |
|
||||
| Click row | Per row | Opens routine detail page | — |
|
||||
|
||||
## New routine form (Local)
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Routine type selector | Top of form | Local / Remote tabs or radio | — |
|
||||
| **Name** field | Top of form | Required; converted to lowercase kebab-case for filesystem | — |
|
||||
| **Description** field | Below name | Optional one-liner shown in list | — |
|
||||
| **Instructions** textarea | Mid-form | Rich textarea for the prompt | — |
|
||||
| Permission mode picker | Within Instructions area | Same options as session: Ask, Auto accept, Plan, Auto, Bypass | — |
|
||||
| Model picker | Within Instructions area | Sonnet, Opus, Haiku per plan | — |
|
||||
| **Working folder** picker | Below Instructions | Required; opens native file chooser | If folder not yet trusted, app prompts to trust |
|
||||
| **Worktree** toggle | Below folder | When ON, each run gets its own isolated worktree | — |
|
||||
| **Schedule** preset | Bottom of form | Manual / Hourly / Daily / Weekdays / Weekly | — |
|
||||
| Time picker | Visible for Daily, Weekdays, Weekly | Defaults to 9:00 AM local | — |
|
||||
| Day picker | Visible for Weekly only | Day-of-week selector | — |
|
||||
| **Save** button | Bottom-right | Disabled until required fields filled | — |
|
||||
| **Cancel** button | Bottom-left | Discards form, returns to list | — |
|
||||
| Folder-trust prompt | Triggered when folder not trusted | Modal asking to trust the selected folder | Required before save |
|
||||
|
||||
## New routine form (Remote)
|
||||
|
||||
Per upstream docs, remote routines run on Anthropic-managed cloud infrastructure. The form has additional fields for connectors and trigger types (cron, API, GitHub event). On Linux, the Remote tab should function identically to other platforms.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Trigger type selector | Top of form | Schedule / API call / GitHub event | — |
|
||||
| Connectors picker | Per-routine basis (remote) | Configures connectors at routine creation | — |
|
||||
| Network access controls | If applicable | Tied to cloud environment config | — |
|
||||
|
||||
## Routine detail page
|
||||
|
||||
Per upstream docs.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Run now** button | Top of page | Starts the task immediately | — |
|
||||
| Status toggle (Active / Paused) | Top of page | Pauses or resumes without deleting | — |
|
||||
| **Edit** button | Top of page | Opens the same form populated with current values | — |
|
||||
| **Delete** button | Top of page (or footer) | Removes routine; archives all sessions it created | Confirmation dialog expected |
|
||||
| **Review history** section | Page body | Lists every past run with timestamp and status | — |
|
||||
| Per-history-entry hover | Hover skipped runs | Tooltip explains why skipped (asleep, prior run still running, other concurrent task) | — |
|
||||
| **Show more** button | Bottom of history | Loads older entries | — |
|
||||
| **Always allowed** panel | Page body | Lists tools auto-approved for this routine | — |
|
||||
| Revoke approval | Per-tool entry | Removes the auto-approval | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Folder-trust modal doesn't appear | Trust state cached incorrectly | Clear `~/.claude/trusted-folders` (or equivalent) and retry |
|
||||
| Save button never enables | Required fields validation regression | DevTools console |
|
||||
| Time picker truncates / clips | Modal sizing on small viewports | Resize Settings window to reproduce |
|
||||
| History tooltips don't render | Tooltip component regression | — |
|
||||
| Run-now does nothing | Task runner thread not started | Launcher log; `pgrep -af claude` for runner subprocess |
|
||||
| Routines page blank | Code-tab failure ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) cascading | Confirm Code tab itself loads first |
|
||||
87
docs/testing/ui/settings.md
Normal file
87
docs/testing/ui/settings.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# UI — Settings
|
||||
|
||||
The Settings window holds Desktop app preferences, Claude Code settings, connector management, and account controls. Related functional tests: [S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend), [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge).
|
||||
|
||||
## Settings root
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Settings window | Opened via app menu, tray menu, or in-app shortcut | Window opens with sidebar nav and content area | — |
|
||||
| Window close button | Top-right (or top-left on GNOME) | Closes settings; main app continues running | — |
|
||||
| Sidebar nav | Left of window | Lists every settings page | — |
|
||||
|
||||
## Desktop app → General
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Computer use** toggle | Top of page | Either absent on Linux, or rendered disabled with a "not supported on Linux" hint ([S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux)) | Critical: must not appear functional |
|
||||
| **Keep computer awake** toggle | Mid-page | Toggles `systemd-inhibit --what=idle:sleep` lock ([S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend)) | Verify with `systemd-inhibit --list` |
|
||||
| **Denied apps** list | Computer-use related | Likely absent on Linux (computer use unsupported) | — |
|
||||
| **Unhide apps when Claude finishes** toggle | Computer-use related | Likely absent on Linux | — |
|
||||
| Theme picker (if exposed) | Mid-page | System / Light / Dark | Tray icon should respond ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) |
|
||||
|
||||
## Desktop app → Account
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Account name / email | Top of page | Reflects signed-in identity | — |
|
||||
| Plan badge | Below name | Shows Pro / Max / Team / Enterprise | — |
|
||||
| Sign out button | Bottom of page | Signs out cleanly; subsequent launches show sign-in screen | — |
|
||||
|
||||
## Claude Code
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Worktree location** | Top of page | Default: `<project-root>/.claude/worktrees/`. Editable to a custom directory | Crosses with [T29](../cases/code-tab-workflow.md#t29--worktree-isolation) |
|
||||
| **Branch prefix** | Mid-page | Optional prefix prepended to every worktree branch | — |
|
||||
| **Auto-archive after PR merge or close** toggle | Mid-page | When ON, sessions archive on PR resolution ([T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge)) | — |
|
||||
| **Persist preview sessions** toggle | Mid-page | Toggles cookies/localStorage persistence in Preview pane | Crosses with [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane) |
|
||||
| **Preview** toggle | Mid-page | When OFF, preview pane and auto-verify are disabled | — |
|
||||
| **Allow bypass permissions mode** toggle | Mid-page | When ON, exposes Bypass mode in mode picker | Enterprise admins can disable |
|
||||
| **Auto** mode availability | Mid-page | Research preview; not on Pro plans | Per upstream docs |
|
||||
|
||||
## Connectors
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Connectors list | Page content | Lists connected services with status | Crosses with [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
|
||||
| Per-connector entry | List row | Name, last-connected timestamp, manage / disconnect buttons | — |
|
||||
| **Manage** button | Per row | Opens connector-specific settings | — |
|
||||
| **Disconnect** button | Per row | Revokes access; connector becomes unusable in subsequent sessions | — |
|
||||
| **Add connector** button | Top of page | Opens the connector picker (same surface as `+ → Connectors`) | — |
|
||||
|
||||
## SSH connections
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| SSH connections list | Page content | Lists user-added + managed (read-only) connections | — |
|
||||
| **Add SSH connection** button | Top of page | Opens dialog with Name / SSH Host / SSH Port / Identity File fields | — |
|
||||
| Per-connection entry | List row | Edit / delete (user-added) or "Managed" badge (admin-distributed) | — |
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Shortcut list | Page content | Tabular list of all configurable shortcuts | — |
|
||||
| Shortcut value | Per row | Click to rebind; shows current binding | — |
|
||||
| Reset to default | Per row | Reverts to upstream default | — |
|
||||
| Quick Entry shortcut | Specifically called out | Default `Ctrl+Alt+Space`; rebind here | Crosses with [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) |
|
||||
|
||||
## Local environment editor
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Env editor open | Environment dropdown → Local → gear icon | Opens encrypted env-var editor | Crosses with [S18](../cases/platform-integration.md#s18--local-environment-editor-persists-across-reboot) |
|
||||
| Add variable | In editor | Name + value fields; save | — |
|
||||
| Remove variable | Per row | Deletes the variable | — |
|
||||
| **Apply to dev servers** indicator | Near save | Confirms vars also reach preview servers | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Computer-use toggle visible and toggleable on Linux | [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux) regression | File a bug; users will be misled |
|
||||
| Keep-computer-awake toggle has no effect | `systemd-inhibit` integration not wired ([S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend)) | Verify lock list before/after |
|
||||
| Worktree location field rejects valid paths | Path validation too strict; absolute vs `~`-prefixed | Check both forms |
|
||||
| SSH connection list missing managed entries | Managed-settings file not loaded; admin distribution failed | Confirm file exists at expected path |
|
||||
| Env editor not encrypting | Linux secret-store not wired ([S18](../cases/platform-integration.md#s18--local-environment-editor-persists-across-reboot)) | `secret-tool search`; `kwallet5-query` |
|
||||
55
docs/testing/ui/sidebar.md
Normal file
55
docs/testing/ui/sidebar.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# UI — Code Tab Sidebar
|
||||
|
||||
The sidebar lists Code-tab sessions, lets you filter, group, archive, and rename. Related functional tests: [T29](../cases/code-tab-workflow.md#t29--worktree-isolation), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification).
|
||||
|
||||
## Top controls
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **+ New session** button | Top of sidebar | Click opens a new session against the currently selected env. `Ctrl+N` shortcut equivalent | — |
|
||||
| **Routines** link | Top of sidebar | Click opens the Routines page ([T26](../cases/routines.md#t26--routines-page-renders)) | — |
|
||||
| **Customize** link | Top of sidebar | Click opens connectors / skills / plugins manager | — |
|
||||
| Filter: status | Top of session list | Dropdown / tabs filter by Active / Archived / All | — |
|
||||
| Filter: project | Top of session list | Dropdown filters by project (multi-select) | — |
|
||||
| Filter: environment | Top of session list | Dropdown filters by Local / Remote / SSH / All | — |
|
||||
| Group-by control | Top of session list | Toggle between flat list and grouped-by-project | — |
|
||||
|
||||
## Session row
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Session title | Row content | Shows session name (auto-generated or user-renamed) | Click row → switches to that session |
|
||||
| Session status indicator | Left of title or as colored dot | Reflects state: idle, running, awaiting-approval, errored, archived | — |
|
||||
| Project / branch label | Below title | Shows project folder name + branch | — |
|
||||
| Diff stats badge (e.g. `+12 -1`) | Right of title | Visible when session has uncommitted changes | Click → opens diff view |
|
||||
| **Dispatch** badge | Top-right of row | Visible on Dispatch-spawned sessions ([S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification)) | — |
|
||||
| **Scheduled** badge | Top-right of row | Visible on scheduled-task-spawned sessions ([T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies)) | Sessions group under "Scheduled" header |
|
||||
| Hover archive icon | Right side, on row hover | Click archives the session and removes its worktree | — |
|
||||
| Right-click context menu | Right-click on row | Standard menu: Rename, Archive, Open in Files, Copy path | — |
|
||||
| Active session highlight | Selected row | Visually distinct from inactive rows | — |
|
||||
|
||||
## Sidebar layout
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Sidebar resize handle | Right edge of sidebar | Drag to resize; double-click to reset width | — |
|
||||
| Sidebar collapse toggle | Top of sidebar (hamburger or arrow) | Collapse to icons-only or hide entirely | Crosses with topbar hamburger |
|
||||
| Scrollbar | Right edge when content exceeds height | Renders, drags work | Theme-aware |
|
||||
|
||||
## Cycling shortcuts
|
||||
|
||||
| Shortcut | Expected | Notes |
|
||||
|----------|----------|-------|
|
||||
| `Ctrl+Tab` | Cycle to next session | Per upstream docs |
|
||||
| `Ctrl+Shift+Tab` | Cycle to previous session | Per upstream docs |
|
||||
| `Cmd+Shift+]` / `Cmd+Shift+[` | Same as above on macOS | N/A on Linux unless rebound |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Sidebar doesn't render | Code tab failed to load ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) | Check DevTools console |
|
||||
| Sessions appear but clicking does nothing | IPC between sidebar and session pane broken | Launcher log, DevTools console |
|
||||
| Hover archive icon never appears | CSS hover state mis-applied; touch device might be assumed | Inspect element; check pointer events |
|
||||
| Dispatch / Scheduled badges missing | Feature flag or state not reaching the renderer | Check session metadata in launcher log |
|
||||
| Auto-archive doesn't fire | Session-archive logic bug ([T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge)) | Confirm setting enabled; check PR state via `gh pr view` |
|
||||
44
docs/testing/ui/tray.md
Normal file
44
docs/testing/ui/tray.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# UI — System Tray
|
||||
|
||||
Tray icon, menu, and theme variants. See [`../cases/tray-and-window-chrome.md`](../cases/tray-and-window-chrome.md) for related functional tests ([T03](../cases/tray-and-window-chrome.md#t03--tray-icon-present), [S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)).
|
||||
|
||||
## Tray icon
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Tray icon (light theme) | System tray / status area | Black icon (the "Template" variant) renders cleanly on a light tray | — |
|
||||
| Tray icon (dark theme) | System tray / status area | White icon (the "Template-Dark" variant) renders cleanly on a dark tray | — |
|
||||
| Theme switch | Trigger system theme change | Icon updates in place — no duplicate icons spawned ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) | KDE-W ✓ via in-place fast-path |
|
||||
| Icon resolution / sharpness | Inspect at native scale | Icon is crisp, not pixelated. Check on HiDPI screens | — |
|
||||
| Position | Tray area | Appears among other SNI/tray icons | KDE Plasma sorts alphabetically by ID; adjusting position requires user config |
|
||||
| Tooltip on hover | Hover over icon | Shows "Claude" or app name | — |
|
||||
|
||||
## Right-click menu
|
||||
|
||||
| Element | Position in menu | Expected | Notes |
|
||||
|---------|------------------|----------|-------|
|
||||
| Show / Hide window | Top item | Toggles main window visibility | Label may change between "Show" and "Hide" based on state |
|
||||
| Quick Entry | Mid-menu | Opens Quick Entry popup ([T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused)) | — |
|
||||
| Open at Login (toggle) | Mid-menu | Reflects current XDG autostart state ([T09](../cases/platform-integration.md#t09--autostart-via-xdg)) | Toggle should write `~/.config/autostart/*.desktop` |
|
||||
| Settings | Mid-menu | Opens Settings window | — |
|
||||
| About | Bottom area | Opens About dialog | — |
|
||||
| Quit | Bottom item | Fully exits the app (no hide-to-tray) | — |
|
||||
| Menu separators | Between item groups | Render cleanly | — |
|
||||
|
||||
## Left-click behavior
|
||||
|
||||
| Element | Trigger | Expected | Notes |
|
||||
|---------|---------|----------|-------|
|
||||
| Single left-click | Click tray icon once | Toggles main window visibility | KDE-W ✓ |
|
||||
| Double left-click | Click twice quickly | DE-dependent; should not spawn duplicate windows | — |
|
||||
| Middle-click | Middle mouse button on tray icon | DE-dependent (no documented behavior); should not crash | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Diagnose with |
|
||||
|---------|--------------|---------------|
|
||||
| Tray icon never appears | No SNI watcher (e.g. GNOME without AppIndicator extension); Electron fallback to legacy XEmbed not registered | `gdbus call ... org.kde.StatusNotifierWatcher` — see [runbook](../runbook.md#tray--dbus-state-kde) |
|
||||
| Two tray icons after theme switch | Tray rebuild race ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) | SNI watcher state before/after; [`docs/learnings/tray-rebuild-race.md`](../../learnings/tray-rebuild-race.md) |
|
||||
| Icon renders as a generic placeholder | Icon path resolution failed; theme mismatch | Check Electron `Tray` constructor args; check `~/.cache/claude-desktop-debian/launcher.log` |
|
||||
| Menu items don't respond | IPC bridge to tray menu broken; main process busy | Click main window — does the rest of the app respond? `pgrep -af claude`; main process state |
|
||||
| Tray icon disappears after some time | Tray daemon restarted; Claude didn't re-register | KDE Plasma: restart `plasmashell`; observe whether icon comes back without restarting Claude |
|
||||
58
docs/testing/ui/window-chrome-and-tabs.md
Normal file
58
docs/testing/ui/window-chrome-and-tabs.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# UI — Window Chrome & Tabs
|
||||
|
||||
OS-level window frame plus the in-app tab strip and (PR #538) hybrid in-app topbar. See [`../cases/tray-and-window-chrome.md`](../cases/tray-and-window-chrome.md) for related functional tests.
|
||||
|
||||
## OS window frame
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Title bar | Top of window | Drawn by DE/compositor; shows app title; right-click opens window menu | KDE-W ✓; Hypr-N ✓ |
|
||||
| Close button (X) | Top-right (or top-left on GNOME) | Renders, hover state visible, click hides-to-tray ([T08](../cases/tray-and-window-chrome.md#t08--hide-to-tray-on-close)) | — |
|
||||
| Minimize button | Adjacent to close | Renders, hover state visible, click minimizes | — |
|
||||
| Maximize / restore button | Adjacent to minimize | Renders, hover state visible, click toggles maximize | — |
|
||||
| Resize edges (left, right, top, bottom, corners) | Window perimeter | Cursor changes to resize affordance on hover; drag resizes | Wlroots compositors may not show cursor change |
|
||||
| Window menu (right-click titlebar) | Right-click anywhere on titlebar | Standard window menu (Move, Resize, Close, Always on Top, etc.) | DE-dependent |
|
||||
|
||||
## Hybrid in-app topbar (PR #538 builds)
|
||||
|
||||
Sits below the OS frame in hybrid mode. Crosses with [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) and [S13](../cases/tray-and-window-chrome.md#s13--hybrid-topbar-shim-survives-omarchys-ozone-wayland-env-exports).
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Hamburger menu | Top-left of topbar | Renders, click opens sidebar | — |
|
||||
| Sidebar toggle | Adjacent to hamburger | Renders, click collapses/expands sidebar | — |
|
||||
| Search icon | Center-left | Renders, click opens search overlay | — |
|
||||
| Back arrow | Center | Renders, greyed out when no history; click navigates back | — |
|
||||
| Forward arrow | Adjacent to back | Same as back, but for forward history | — |
|
||||
| Cowork ghost icon | Right of nav arrows | Renders, click opens Cowork tab | The icon is the canonical "is the topbar shim alive" indicator |
|
||||
| Drag region (gaps between buttons) | Empty space between elements | Drag region behaves correctly — buttons remain clickable, no implicit drag region capturing button clicks | Critical: this is the regression mode in [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) |
|
||||
|
||||
## Tab strip (Chat / Cowork / Code)
|
||||
|
||||
Sits in the topbar (hybrid) or in the OS-frame area (legacy). Top center.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Chat** tab | Left tab | Renders, click switches to Chat | — |
|
||||
| **Cowork** tab | Center tab | Renders, click switches to Cowork; ghost icon may indicate Dispatch state | — |
|
||||
| **Code** tab | Right tab | Renders, click switches to Code; on Linux, may show 403 / sign-in upsell ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) | — |
|
||||
| Active tab indicator | Underline / fill on active tab | Visually distinct from inactive tabs | — |
|
||||
| Tab badges (e.g. unread count, Dispatch badge) | Top-right of each tab | Render when applicable, dismiss when state clears | — |
|
||||
|
||||
## Other window-level UI
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| About dialog | App menu → About | Modal opens with app version, Electron version, license info; close button works | — |
|
||||
| App menu (macOS-style) | macOS only — N/A on Linux | Not present on Linux; menu items are in window menu instead | — |
|
||||
| Update prompt | Triggered by upstream update detection | On DEB/RPM, auto-update path is suppressed ([S26](../cases/distribution.md#s26--auto-update-is-disabled-when-installed-via-apt--dnf)). On AppImage, may surface a prompt | — |
|
||||
| Crash report dialog | Shown after a crash | Dialog explains what happened, offers to file an issue | Capture for Linux specifics — wording may reference macOS Console / Windows Event Viewer paths only |
|
||||
|
||||
## Display-server cross-cuts
|
||||
|
||||
| Concern | X11 | Wayland (mutter) | Wayland (KWin) | Wayland (wlroots) |
|
||||
|---------|-----|-------------------|----------------|---------------------|
|
||||
| HiDPI scaling | `--force-device-scale-factor=N` works | Auto via fractional scaling | Auto via fractional scaling | Auto where compositor supports it |
|
||||
| Drag-to-snap (Aero-style) | Works under most WMs | mutter snaps | KWin snaps | Compositor-dependent |
|
||||
| Always-on-top | Window menu | Window menu | Window menu | Compositor-dependent |
|
||||
| Cursor theme | Inherits from `gtk-cursor-theme-name` | Same | Same | Same |
|
||||
@@ -1,471 +0,0 @@
|
||||
[< Back to README](../README.md)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Built-in Diagnostics
|
||||
|
||||
Run the `--doctor` flag to check your system for common issues:
|
||||
|
||||
```bash
|
||||
# Deb install
|
||||
claude-desktop --doctor
|
||||
|
||||
# AppImage
|
||||
./claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
This runs a series of checks and prints pass/fail results with
|
||||
suggested fixes:
|
||||
|
||||
| Check | What it verifies |
|
||||
|-------|-----------------|
|
||||
| Installed version | Package version via dpkg |
|
||||
| Display server | Wayland/X11 detection and mode |
|
||||
| Input method | IBus/GTK immodule sanity (ibus-gtk3 installed, cache fresh, XWayland routing note) |
|
||||
| Electron binary | Existence and version |
|
||||
| Chrome sandbox | Correct permissions (4755/root) |
|
||||
| SingletonLock | Stale lock file detection |
|
||||
| MCP config | JSON validity and server count |
|
||||
| Node.js | Version (v20+ recommended for MCP) |
|
||||
| Desktop entry | `.desktop` file presence |
|
||||
| Disk space | Free space on config partition |
|
||||
| Log file | Log file size |
|
||||
|
||||
Example output:
|
||||
```
|
||||
Claude Desktop Diagnostics
|
||||
================================
|
||||
|
||||
[PASS] Installed version: 1.1.4498-1.3.15
|
||||
[PASS] Display server: Wayland (WAYLAND_DISPLAY=wayland-0)
|
||||
[PASS] Electron: found at /usr/lib/claude-desktop/node_modules/electron/dist/electron
|
||||
[PASS] Chrome sandbox: permissions OK
|
||||
[PASS] SingletonLock: no lock file (OK)
|
||||
[PASS] MCP config: valid JSON
|
||||
[PASS] Node.js: v22.14.0
|
||||
[PASS] Desktop entry: /usr/share/applications/claude-desktop.desktop
|
||||
[PASS] Disk space: 632284MB free
|
||||
[PASS] Log file: 1352KB
|
||||
|
||||
All checks passed.
|
||||
```
|
||||
|
||||
When opening an issue, include the output of `--doctor` to help with diagnosis.
|
||||
|
||||
## Application Logs
|
||||
|
||||
Runtime logs are available at:
|
||||
```
|
||||
~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Window Scaling Issues
|
||||
|
||||
If the window doesn't scale correctly on first launch:
|
||||
1. Right-click the Claude Desktop tray icon
|
||||
2. Select "Quit" (do not force quit)
|
||||
3. Restart the application
|
||||
|
||||
This allows the application to save display settings properly.
|
||||
|
||||
### Global Hotkey Not Working (Wayland)
|
||||
|
||||
If the global hotkey (Ctrl+Alt+Space) doesn't work, ensure you're not running in native Wayland mode:
|
||||
|
||||
1. Check your logs at `~/.cache/claude-desktop-debian/launcher.log`
|
||||
2. Look for "Using X11 backend via XWayland" - this means hotkeys should work
|
||||
3. If you see "Using native Wayland backend", unset `CLAUDE_USE_WAYLAND` or ensure it's not set to `1`
|
||||
|
||||
**Note:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal.
|
||||
|
||||
See [configuration.md](configuration.md) for more details on the `CLAUDE_USE_WAYLAND` environment variable.
|
||||
|
||||
### Keyboard Input Doesn't Work (IBus / GTK Input Method)
|
||||
|
||||
If typing into the chat does nothing, characters get swallowed, or
|
||||
dead-key sequences (e.g. ``` `e ``` → `è`) don't compose, your GTK
|
||||
input module integration with the Electron-bundled GTK is broken.
|
||||
Common symptoms:
|
||||
|
||||
- No characters appear when typing into any text field
|
||||
- The first keystroke after focus is dropped, subsequent ones work
|
||||
- CJK input methods (IBus, Fcitx) not engaging
|
||||
- Compose key / dead-key sequences silently drop
|
||||
|
||||
**First step: run `claude-desktop --doctor`.** It checks for the
|
||||
common misconfigurations and prints fix commands inline:
|
||||
|
||||
- `ibus-gtk3` package missing while `GTK_IM_MODULE=ibus`
|
||||
- GTK immodules cache stale (the active module isn't listed by
|
||||
`gtk-query-immodules-3.0`)
|
||||
- XWayland session routing IBus through XIM (lossy for some IMEs —
|
||||
set `CLAUDE_USE_WAYLAND=1` to use native Wayland IME)
|
||||
- Active value of `CLAUDE_GTK_IM_MODULE` if you've set the override
|
||||
|
||||
If `--doctor` is clean but input still misbehaves, switch the
|
||||
launcher to a different GTK input module. Set `CLAUDE_GTK_IM_MODULE`
|
||||
and Claude Desktop will propagate it as `GTK_IM_MODULE` to Electron
|
||||
at startup:
|
||||
|
||||
```bash
|
||||
# Bypass IBus entirely — uses the X Input Method (XIM) protocol
|
||||
CLAUDE_GTK_IM_MODULE=xim claude-desktop
|
||||
|
||||
# To make it persistent, export it from your shell profile:
|
||||
# echo 'export CLAUDE_GTK_IM_MODULE=xim' >> ~/.profile
|
||||
```
|
||||
|
||||
Valid values: anything your GTK installation supports (`xim`, `ibus`,
|
||||
`fcitx`, `simple`, etc.). When the override is active, the launcher
|
||||
logs a line to `~/.cache/claude-desktop-debian/launcher.log`:
|
||||
|
||||
```
|
||||
GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)
|
||||
```
|
||||
|
||||
**Trade-off:** `xim` is the lowest-common-denominator input module
|
||||
and does not support advanced IME features like CJK candidate
|
||||
windows or rich compose-key sequences. Only reach for it if your
|
||||
real input method (IBus/Fcitx) is broken; if you depend on CJK or
|
||||
compose, prefer fixing the IBus/Fcitx integration instead.
|
||||
|
||||
### Repeated Electron Crashes / GPU Process FATAL ([#583](https://github.com/aaddrick/claude-desktop-debian/issues/583))
|
||||
|
||||
If Claude Desktop crashes repeatedly on launch or shortly after,
|
||||
the most common cause on Linux is the Chromium GPU process hitting
|
||||
a FATAL exhaustion path. `claude-desktop --doctor` surfaces this
|
||||
when `systemd-coredump` shows 3+ Electron crashes in the last 7
|
||||
days, pointing at this issue.
|
||||
|
||||
Two ways to disable hardware acceleration as a workaround:
|
||||
|
||||
1. **In-app:** Settings → toggle hardware acceleration off →
|
||||
restart Claude Desktop. Persists in the upstream config.
|
||||
2. **Env var (headless / persists across reinstalls):** set
|
||||
`CLAUDE_DISABLE_GPU=1` in the environment before launching.
|
||||
|
||||
```bash
|
||||
# One-off:
|
||||
CLAUDE_DISABLE_GPU=1 claude-desktop
|
||||
|
||||
# Persistent (shell profile):
|
||||
echo 'export CLAUDE_DISABLE_GPU=1' >> ~/.profile
|
||||
```
|
||||
|
||||
When `CLAUDE_DISABLE_GPU=1` is set, the launcher passes
|
||||
`--disable-gpu --disable-software-rasterizer` to Electron (see
|
||||
`scripts/launcher-common.sh`). This is the same pair of flags
|
||||
applied automatically inside XRDP sessions, where software
|
||||
rendering is required regardless. Either signal is sufficient —
|
||||
the launcher won't stack duplicate flags.
|
||||
|
||||
**When to prefer which:** the in-app toggle is friendlier if you
|
||||
can reach Settings without the app crashing. Reach for
|
||||
`CLAUDE_DISABLE_GPU=1` when the app crashes before you can open
|
||||
Settings, when running in environments with no GPU available
|
||||
(XRDP, headless CI smoke tests, some VMs), or when you want the
|
||||
behavior to persist across reinstalls and config resets.
|
||||
|
||||
Tracking issue: [#583](https://github.com/aaddrick/claude-desktop-debian/issues/583).
|
||||
|
||||
### AppImage Sandbox Warning
|
||||
|
||||
AppImages run with `--no-sandbox` due to electron's chrome-sandbox requiring root privileges for unprivileged namespace creation. This is a known limitation of AppImage format with Electron applications.
|
||||
|
||||
For enhanced security, consider:
|
||||
- Using the .deb package instead
|
||||
- Running the AppImage within a separate sandbox (e.g., bubblewrap)
|
||||
- Using Gear Lever's integrated AppImage management for better isolation
|
||||
|
||||
### Cowork on Ubuntu 24.04+ (AppArmor Blocks User Namespaces)
|
||||
|
||||
Ubuntu 24.04 ships with `apparmor_restrict_unprivileged_userns=1`
|
||||
by default, which blocks the unprivileged user namespaces that
|
||||
Cowork's bubblewrap sandbox relies on. Symptoms:
|
||||
|
||||
- `claude-desktop --doctor` reports `bubblewrap: sandbox probe failed`
|
||||
with `Operation not permitted` in stderr.
|
||||
- `~/.config/Claude/logs/cowork_vm_daemon.log` contains
|
||||
`bwrap is installed but cannot create a user namespace`.
|
||||
- Cowork sessions hang at "Starting VM..." or loop on reconnect.
|
||||
|
||||
Permit user namespaces for `bwrap` via an AppArmor profile (one-time
|
||||
setup, requires sudo):
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/bwrap <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile bwrap /usr/bin/bwrap flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/bwrap>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/bwrap
|
||||
```
|
||||
|
||||
After applying the profile, run `claude-desktop --doctor` — the
|
||||
bubblewrap probe should pass, and Cowork should start without
|
||||
falling back to host-direct.
|
||||
|
||||
**Security note:** this grants `/usr/bin/bwrap` the unconfined
|
||||
profile plus the `userns` capability. It matches the behavior
|
||||
bwrap had on Ubuntu 22.04 and earlier, and on most other distros,
|
||||
but is a system-wide change that affects every program invoking
|
||||
`/usr/bin/bwrap` (not just Claude Desktop). Review the profile
|
||||
against your threat model before applying.
|
||||
|
||||
Credit: this workaround was contributed by
|
||||
[@hfyeh](https://github.com/hfyeh) in
|
||||
[#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
|
||||
|
||||
### Cowork: "VM connection timeout after 60 seconds"
|
||||
|
||||
If Cowork fails with a VM timeout, the KVM backend is selected but the guest VM cannot connect back to the host via vsock within the timeout window. Common causes:
|
||||
|
||||
1. **First-boot initialization** — the guest VM may take longer than 60 seconds on first launch
|
||||
2. **vsock driver issues** — the host may be missing the `vhost_vsock` module (`sudo modprobe vhost_vsock`), or the guest initrd may lack `vmw_vsock_virtio_transport`
|
||||
|
||||
**Fix:** Force the bubblewrap backend, which provides namespace-level isolation without a VM:
|
||||
|
||||
```bash
|
||||
COWORK_VM_BACKEND=bwrap claude-desktop
|
||||
```
|
||||
|
||||
See [configuration.md](configuration.md#cowork-backend) for how to make this permanent.
|
||||
|
||||
### Cowork: virtiofsd not found (Fedora/RHEL)
|
||||
|
||||
On Fedora and RHEL, `virtiofsd` installs to `/usr/libexec/virtiofsd` which is
|
||||
outside `$PATH`. The `--doctor` check detects it there automatically and will
|
||||
show `[PASS]`, but the KVM backend spawns `virtiofsd` by name at runtime and
|
||||
resolves it through `$PATH` only.
|
||||
|
||||
**Fix:** Create a symlink so the KVM backend can find it at runtime:
|
||||
|
||||
```bash
|
||||
sudo ln -s /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
|
||||
```
|
||||
|
||||
On Debian/Ubuntu, the same issue can occur with `/usr/lib/qemu/virtiofsd`.
|
||||
|
||||
### Cowork: cross-device link error on Fedora tmpfs /tmp
|
||||
|
||||
On Fedora, `/tmp` is a tmpfs by default. VM bundle downloads may fail with `EXDEV: cross-device link not permitted` when moving files from `/tmp` to `~/.config/Claude/`.
|
||||
|
||||
**Fix:** Set `TMPDIR` to a directory on the same filesystem:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/Claude/tmp
|
||||
TMPDIR=~/.config/Claude/tmp claude-desktop
|
||||
```
|
||||
|
||||
Or add `TMPDIR=%h/.config/Claude/tmp` to the `Exec=` line in your `.desktop` file.
|
||||
|
||||
### Cowork: ENAMETOOLONG on encrypted home (eCryptfs)
|
||||
|
||||
Cowork sessions can fail with an opaque `ENAMETOOLONG` error when
|
||||
`$HOME` is on a filesystem with a short filename limit. The common
|
||||
case is **eCryptfs** — the legacy "encrypted home" option on older
|
||||
Ubuntu and Linux Mint installs, which caps individual filenames at
|
||||
143 chars because of filename-encryption overhead. Standard
|
||||
filesystems (ext4, btrfs, xfs, zfs) cap at 255 chars and are fine.
|
||||
|
||||
**Why it happens:** Claude Code creates one directory per session
|
||||
under `~/.claude/projects/`, named after the sanitized host CWD. For
|
||||
cowork sessions the host CWD is the deeply nested outputs dir under
|
||||
`~/.config/Claude/local-agent-mode-sessions/<accountId>/<orgId>/local_<uuid>/outputs`,
|
||||
which sanitizes to ~180 chars — fits ext4 but exceeds the eCryptfs
|
||||
143-char ceiling.
|
||||
|
||||
**Diagnosis:** `claude-desktop --doctor` detects this automatically
|
||||
and emits a `[WARN] Filename limit: NAME_MAX=143…` line, plus an
|
||||
eCryptfs-specific hint when the filesystem type matches. You can
|
||||
also check by hand:
|
||||
|
||||
```bash
|
||||
df -T $HOME # look for type "ecryptfs"
|
||||
getconf NAME_MAX $HOME # eCryptfs reports 143; ext4 reports 255
|
||||
```
|
||||
|
||||
**Workaround:** move Claude's data onto a separate LUKS-encrypted
|
||||
ext4 volume (NAME_MAX = 255) and symlink the original paths back.
|
||||
`~/.claude/` is the critical one — that's where Claude Code creates
|
||||
the long-named per-session dirs that overflow the limit — and
|
||||
`~/.config/Claude/` plus `~/.cache/claude-desktop-debian/` are
|
||||
relocated alongside it so all Claude state lives on the same volume.
|
||||
This keeps the data encrypted at rest while sidestepping the
|
||||
eCryptfs filename-length cap.
|
||||
|
||||
```bash
|
||||
# 1. Create a 2 GB LUKS container
|
||||
sudo dd if=/dev/urandom of=/opt/claude-secure.img bs=1M count=2048 \
|
||||
status=progress
|
||||
sudo cryptsetup luksFormat /opt/claude-secure.img
|
||||
sudo cryptsetup open /opt/claude-secure.img claude-secure
|
||||
sudo mkfs.ext4 /dev/mapper/claude-secure
|
||||
|
||||
# 2. Mount and move Claude's data in
|
||||
sudo mkdir -p /mnt/claude-secure
|
||||
sudo mount /dev/mapper/claude-secure /mnt/claude-secure
|
||||
sudo chown "$USER:$USER" /mnt/claude-secure
|
||||
|
||||
mv ~/.config/Claude /mnt/claude-secure/Claude-config
|
||||
mv ~/.cache/claude-desktop-debian /mnt/claude-secure/claude-cache
|
||||
# ~/.claude may not exist yet on a fresh install — create the target
|
||||
# either way so the symlink below resolves.
|
||||
if [ -e ~/.claude ]; then
|
||||
mv ~/.claude /mnt/claude-secure/claude-home
|
||||
else
|
||||
mkdir -p /mnt/claude-secure/claude-home
|
||||
fi
|
||||
|
||||
ln -s /mnt/claude-secure/Claude-config ~/.config/Claude
|
||||
ln -s /mnt/claude-secure/claude-cache ~/.cache/claude-desktop-debian
|
||||
ln -s /mnt/claude-secure/claude-home ~/.claude
|
||||
|
||||
# 3. Verify the filename limit and the symlinks
|
||||
getconf NAME_MAX /mnt/claude-secure # should print 255
|
||||
mountpoint /mnt/claude-secure # confirms the volume is mounted
|
||||
readlink ~/.claude # /mnt/claude-secure/claude-home
|
||||
readlink ~/.config/Claude # /mnt/claude-secure/Claude-config
|
||||
```
|
||||
|
||||
**If you've set `CLAUDE_CONFIG_DIR`** (or otherwise reconfigured
|
||||
Claude Code to use a directory other than `~/.claude/`), the
|
||||
`~/.claude` symlink above doesn't apply — adapt the path to wherever
|
||||
your Claude Code config actually lives. The constraint is the same:
|
||||
the directory tree where Claude Code creates per-session project
|
||||
dirs must sit on a filesystem with `NAME_MAX` ≥ ~200.
|
||||
|
||||
**Auto-mount at login** with `pam_mount` so the volume unlocks
|
||||
without a manual `cryptsetup open`:
|
||||
|
||||
```bash
|
||||
sudo apt install libpam-mount
|
||||
```
|
||||
|
||||
Add a `<volume>` entry to `/etc/security/pam_mount.conf.xml`
|
||||
(replace `YOUR_USERNAME` with your login name):
|
||||
|
||||
```xml
|
||||
<volume user="YOUR_USERNAME" fstype="crypt"
|
||||
path="/opt/claude-secure.img"
|
||||
mountpoint="/mnt/claude-secure"
|
||||
options="" />
|
||||
```
|
||||
|
||||
`libpam-mount` registers itself with `/etc/pam.d/common-auth` and
|
||||
`/etc/pam.d/common-session` automatically on install.
|
||||
|
||||
**Notes:**
|
||||
- Tested on Linux Mint with LightDM as the display manager.
|
||||
- **LUKS passphrase tradeoff:** for `pam_mount` to unlock silently
|
||||
at login the LUKS passphrase must match your login password. That
|
||||
means one compromise unlocks both your session and the encrypted
|
||||
volume — equivalent to the threat surface eCryptfs already had,
|
||||
but worth a deliberate choice. Use a distinct LUKS passphrase if
|
||||
you'd rather be prompted on each unlock.
|
||||
- **Confidentiality posture vs eCryptfs.** The LUKS image lives at
|
||||
`/opt/claude-secure.img`, outside `$HOME` and outside whatever
|
||||
encryption envelope eCryptfs gives you. If `pam_mount` ever fails
|
||||
silently — wrong passphrase, mount race at login, profile error —
|
||||
Claude won't start (the symlink targets won't exist), so writes
|
||||
fail loudly rather than landing on plaintext disk. Verify with
|
||||
`mountpoint /mnt/claude-secure` after login if you're unsure.
|
||||
- 2 GB is a conservative starting size; the Claude config
|
||||
directory can exceed 500 MB once cowork session history
|
||||
accumulates. Resize if needed.
|
||||
- This is a system-wide change that affects login flow — review
|
||||
the pam_mount config against your threat model before applying.
|
||||
|
||||
Credit: reported with detailed `--doctor` output by
|
||||
[@michelsfun](https://github.com/michelsfun); LUKS-volume workaround
|
||||
contributed by [@proffalken](https://github.com/proffalken) in
|
||||
[#590](https://github.com/aaddrick/claude-desktop-debian/issues/590).
|
||||
|
||||
### Authentication Errors (401)
|
||||
|
||||
If you encounter recurring "API Error: 401" messages after periods of inactivity, the cached OAuth token may need to be cleared. This is an upstream application issue reported in [#156](https://github.com/aaddrick/claude-desktop-debian/issues/156).
|
||||
|
||||
To fix manually (credit: [MrEdwards007](https://github.com/MrEdwards007)):
|
||||
|
||||
1. Close Claude Desktop completely
|
||||
2. Edit `~/.config/Claude/config.json`
|
||||
3. Remove the line containing `"oauth:tokenCache"` (and any trailing comma if needed)
|
||||
4. Save the file and restart Claude Desktop
|
||||
5. Log in again when prompted
|
||||
|
||||
A scripted solution is also available at the bottom of [this comment](https://github.com/aaddrick/claude-desktop-debian/issues/156#issuecomment-2682547498).
|
||||
|
||||
## Uninstallation
|
||||
|
||||
### For APT repository installations (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
|
||||
# Remove the repository and GPG key
|
||||
sudo rm /etc/apt/sources.list.d/claude-desktop.list
|
||||
sudo rm /usr/share/keyrings/claude-desktop.gpg
|
||||
```
|
||||
|
||||
### For DNF repository installations (Fedora/RHEL)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
|
||||
# Remove the repository
|
||||
sudo rm /etc/yum.repos.d/claude-desktop.repo
|
||||
```
|
||||
|
||||
### For AUR installations (Arch Linux)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -R claude-desktop-appimage
|
||||
|
||||
# Or using paru
|
||||
paru -R claude-desktop-appimage
|
||||
|
||||
# Or using pacman directly
|
||||
sudo pacman -R claude-desktop-appimage
|
||||
```
|
||||
|
||||
### For .deb packages (manual install)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
# Or: sudo dpkg -r claude-desktop
|
||||
|
||||
# Remove package and configuration
|
||||
sudo dpkg -P claude-desktop
|
||||
```
|
||||
|
||||
### For .rpm packages
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
# Or: sudo rpm -e claude-desktop
|
||||
```
|
||||
|
||||
### For AppImages
|
||||
|
||||
1. Delete the `.AppImage` file
|
||||
2. Remove the `.desktop` file from `~/.local/share/applications/`
|
||||
3. If using Gear Lever, use its uninstall option
|
||||
|
||||
### Remove user configuration (all formats)
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/Claude
|
||||
```
|
||||
@@ -1,164 +0,0 @@
|
||||
# Upstream report draft: MCP double-spawn (issue #546)
|
||||
|
||||
This is the draft for the upstream bug report covering [#546](https://github.com/aaddrick/claude-desktop-debian/issues/546). Filing target is `anthropics/claude-code` GitHub Issues, with an in-app `/bug` from Claude Desktop as a complement so the report ties to build telemetry.
|
||||
|
||||
## Template mismatch note
|
||||
|
||||
The `anthropics/claude-code` bug template is built for the Claude Code CLI, not Claude Desktop. Required fields like "Claude Code Version" and "Terminal/Shell" don't apply cleanly. Other Claude Desktop bug reports in the same repo work around this by putting `N/A — Claude Desktop <version>` in the version field and selecting `Other` for terminal (see #43705, #36319, #14807).
|
||||
|
||||
## Title
|
||||
|
||||
```
|
||||
[BUG] Claude Desktop 1.5354.0: stdio MCP servers double-spawn from independent CCD/LAM coordinator registries
|
||||
```
|
||||
|
||||
## Form fields
|
||||
|
||||
### Preflight Checklist
|
||||
|
||||
- [x] I have searched existing issues and this hasn't been reported yet
|
||||
- [x] This is a single bug report
|
||||
- [x] I am using the latest version of Claude Code
|
||||
|
||||
### What's Wrong?
|
||||
|
||||
I maintain [claude-desktop-debian](https://github.com/aaddrick/claude-desktop-debian) (~2,300 package downloads/day across the last 3 releases), which repackages the Windows Electron build for Linux. I was reading the MCP spawn path in 1.5354.0 and found that stdio MCP servers configured in `claude_desktop_config.json` get spawned twice when both the chat panel and Code/Agent panel are active.
|
||||
|
||||
The user-visible symptom is two `node` processes per MCP, both children of the Electron main PID. Killing one disconnects one panel and the other keeps working. They're independent client/server pairs with no failover between them.
|
||||
|
||||
The original symptom report came from @communitytranslations against an earlier build (tracked in our repo as #526). I went back and read the bundle to confirm the cause. What I found was different from what we'd previously documented.
|
||||
|
||||
CCD wraps the spawn path in a per-key promise queue keyed by server name. It shuts down any prior entry in its global registry Map before respawning. That's correct dedup within CCD. But LAM (`LocalMcpServerManager`) has its own `this.connections` Map and its own `getOrCreateConnection` path. It never consults CCD's registry.
|
||||
|
||||
CCD and LAM each maintain independent spawn lifecycle management. They each spawn their own copy of the same MCP server. The double-spawn is structural in the current architecture. Each coordinator legitimately holds its own connection.
|
||||
|
||||
There's also a third coordinator class, `SshMcpServerManager`, that follows the same per-coordinator-registry pattern. It uses an SSH transport, so it doesn't contribute to local-node double-spawn directly. Its existence suggests per-coordinator isolated state is a deliberate pattern, not a one-off.
|
||||
|
||||
Secondary bug worth flagging while you're in this code. The `child_process.spawn` wrapper does proper signal escalation (end stdin, wait 2s, SIGTERM, wait 2s, SIGKILL). The `utilityProcess.fork` wrapper doesn't. It sends `process.kill()` (default SIGTERM), waits 5s, then calls `kill()` again with the same default signal. No SIGKILL escalation. A built-in-node MCP server that ignores SIGTERM could leak as an orphaned utility process.
|
||||
|
||||
### What Should Happen?
|
||||
|
||||
One process per stdio MCP server entry in `claude_desktop_config.json`, regardless of how many panels are open. Resource-side that means no more 2x memory and 2x stdin/stdout traffic per server. User-side that means `ps` shows one entry per declared server.
|
||||
|
||||
The fix is architectural. CCD and LAM share a registry, or the local-spawn factory dedups at the transport layer, or LAM proxies through CCD when running in-process. Any of those would collapse the duplication.
|
||||
|
||||
### Error Messages/Logs
|
||||
|
||||
The user-facing log prefixes are stable across releases. Grep `~/.config/Claude/logs/` for:
|
||||
|
||||
```
|
||||
[CCD]
|
||||
[LAM]
|
||||
[LocalMcpServerManager]
|
||||
[SshMcpServerManager]
|
||||
```
|
||||
|
||||
For the spawn lifecycle specifically, look for:
|
||||
|
||||
```
|
||||
"Launching MCP Server: <name>" (CCD spawn entry)
|
||||
"Shutting down MCP Server: <name>" (CCD shutdown entry)
|
||||
"local-mcp-server-cleanup" (LAM cleanup path)
|
||||
```
|
||||
|
||||
Two of these per declared MCP server is the diagnostic signal.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. Linux host running Claude Desktop at or near 1.5354.0
|
||||
2. Declare at least one stdio MCP server in `~/.config/Claude/claude_desktop_config.json`
|
||||
3. Open Claude Desktop, start a session, open the Code/Agent panel and let it initialize fully (the original report waited about 5 minutes)
|
||||
4. `ps -ef | grep <server-binary-name>`
|
||||
|
||||
Expected: 1 process per MCP. Actual: 2 processes per MCP, both children of the same Electron main PID.
|
||||
|
||||
### Claude Model
|
||||
|
||||
Not sure / Multiple models
|
||||
|
||||
### Is this a regression?
|
||||
|
||||
I don't know
|
||||
|
||||
### Last Working Version
|
||||
|
||||
(leave blank)
|
||||
|
||||
### Claude Code Version
|
||||
|
||||
```
|
||||
N/A — this is a Claude Desktop issue. Bundle version: 1.5354.0
|
||||
```
|
||||
|
||||
### Platform
|
||||
|
||||
Anthropic API
|
||||
|
||||
### Operating System
|
||||
|
||||
Ubuntu/Debian Linux
|
||||
|
||||
### Terminal/Shell
|
||||
|
||||
Other
|
||||
|
||||
### Additional Information
|
||||
|
||||
Bundle reference table for 1.5354.0. Symbols rename across releases, so each row has a stable string anchor for re-finding them.
|
||||
|
||||
| Role | Symbol in 1.5354.0 | Stable anchor |
|
||||
|---|---|---|
|
||||
| CCD spawn function | `BPt` | `"Launching MCP Server:"` |
|
||||
| CCD shutdown function | `CPt` | `"Shutting down MCP Server:"` |
|
||||
| CCD per-key promise queue | `dPt` | called by CCD spawn fn: `await dPt(e, async () => {...})` |
|
||||
| CCD server registry Map | `xX` | `.get()` immediately preceding the CCD shutdown log line |
|
||||
| Shared transport factory | `oPt` | `"built-in-node"` literal in factory body |
|
||||
| LAM manager class | `p0A` | `"[LocalMcpServerManager]"` or `"local-mcp-server-cleanup"` |
|
||||
| SSH manager class | `Rde` | `"[SshMcpServerManager]"` or `"ssh-mcp-server-cleanup"` |
|
||||
| `utilityProcess.fork` wrapper | `mFr` | constructed in shared factory's `built-in-node` branch |
|
||||
| `child_process.spawn` wrapper | `tFr` | constructed in shared factory's default branch |
|
||||
|
||||
Extraction commands (verified against 1.5354.0):
|
||||
|
||||
```bash
|
||||
cd build-reference/app-extracted/.vite/build
|
||||
|
||||
# CCD spawn function name
|
||||
grep -Pzo 'async function \K\w+(?=\(\w*\)\s*\{(?s).{0,800}?Launching MCP Server)' index.js | tr '\0' '\n'
|
||||
|
||||
# Shared transport factory (anchored on the unique 'built-in-node' string)
|
||||
grep -Pzo 'async function \K\w+(?=\([^)]*\)\s*\{(?s).{0,400}?built-in-node)' index.js | tr '\0' '\n'
|
||||
|
||||
# All coordinator classes following the per-coordinator-registry pattern
|
||||
grep -Pzo 'class \K\w+(?=\s*\{(?s).{0,300}?this\.connections\s*=\s*new Map)' index.js | tr '\0' '\n'
|
||||
|
||||
# LAM manager class specifically
|
||||
grep -Pzo 'class \K\w+(?=\s*\{(?s).{0,500}?local-mcp-server-cleanup)' index.js | tr '\0' '\n'
|
||||
```
|
||||
|
||||
Two questions where a one-line answer from the team would help us route this downstream:
|
||||
|
||||
1. Is per-coordinator isolated state intentional, or is it legacy drift from when each coordinator instantiated its transport inline?
|
||||
2. Is the recent extraction of the shared transport factory (`oPt`) the start of a dedup refactor, or incidental cleanup?
|
||||
|
||||
If (1) is "intentional," we'll point users at the lockfile workaround as the supported path. If (2) is "in progress," this report saves you the duplicate analysis work.
|
||||
|
||||
Full provenance: [aaddrick/claude-desktop-debian#546](https://github.com/aaddrick/claude-desktop-debian/issues/546). Related learnings doc updates: [#527](https://github.com/aaddrick/claude-desktop-debian/pull/527) and [#547](https://github.com/aaddrick/claude-desktop-debian/pull/547).
|
||||
|
||||
## Filing checklist
|
||||
|
||||
When you're ready to file:
|
||||
|
||||
1. Open https://github.com/anthropics/claude-code/issues/new?template=bug_report.yml
|
||||
2. Paste each section above into the matching form field
|
||||
3. Submit
|
||||
4. Drop the GitHub issue URL as a comment on [#546](https://github.com/aaddrick/claude-desktop-debian/issues/546) so the trail is bidirectional
|
||||
|
||||
Note: there is no in-app engineering bug-report path in Claude Desktop. `/bug` and `/feedback` are inert. The Help menu has "Get Support" (routes to the support chat, wrong queue for engineering) and "Troubleshooting" (self-diagnostic — useful for attaching `Copy Installation ID` or `Show Logs in File Manager` output to a GitHub issue, but not a reporting step on its own).
|
||||
|
||||
## Voice and authorship
|
||||
|
||||
Drafted using the [aaddrick-voice](https://github.com/aaddrick/written-voice-replication/blob/78f178dcf832943bcf1d5a65bf7627c3a20053a6/.claude/agents/aaddrick-voice.md) style profile against the form schema in `anthropics/claude-code/.github/ISSUE_TEMPLATE/bug_report.yml`.
|
||||
|
||||
---
|
||||
Written by Claude Opus 4.7 via [Claude Code](https://claude.ai/code)
|
||||
18
flake.lock
generated
18
flake.lock
generated
@@ -5,11 +5,11 @@
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"lastModified": 1775087534,
|
||||
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778869304,
|
||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||
"lastModified": 1776949667,
|
||||
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -36,11 +36,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1777168982,
|
||||
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||
"lastModified": 1774748309,
|
||||
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
}:
|
||||
let
|
||||
pname = "claude-desktop";
|
||||
version = "1.8555.2";
|
||||
version = "1.5354.0";
|
||||
|
||||
srcs = {
|
||||
x86_64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
|
||||
hash = "sha256-GrV+iMhkUc8ZnRVo11Hat/4p5L36Wj8DX9sVuHLHo1I=";
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
|
||||
hash = "sha256-5hnHvTtnRqcwfr7+UJv+RHoUOu2X5sf2Zmd7Nqa2ulQ=";
|
||||
};
|
||||
aarch64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
|
||||
hash = "sha256-PDGaWaWbML/rhvcbbfgIkcXJg0BPEuRk9L4XVM1NLJQ=";
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
|
||||
hash = "sha256-v33l1sASVC/q331cqnenLfzqGyRRLpptKOAEukrioR0=";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -124,7 +124,6 @@ stdenvNoCC.mkDerivation {
|
||||
# Copy the ELF binary — MUST be a real copy (not symlink) so that
|
||||
# /proc/self/exe resolves to our tree
|
||||
cp ${electronDir}/electron $electron_tree/electron
|
||||
chmod +x $electron_tree/electron
|
||||
|
||||
# Symlink everything else from electron-unwrapped
|
||||
for item in ${electronDir}/*; do
|
||||
@@ -246,7 +245,6 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start (NixOS) ---'
|
||||
log_message "Timestamp: $(date)"
|
||||
log_message "Arguments: $@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Cowork patch markers — single source of truth.
|
||||
#
|
||||
# Format:
|
||||
# <name><TAB><pcre_pattern><TAB><sample>
|
||||
# Lines starting with '#' and blank lines are ignored.
|
||||
#
|
||||
# Each row names a post-patch fingerprint of patch_cowork_linux() in
|
||||
# scripts/patches/cowork.sh. Both verify-patches.sh and
|
||||
# tests/verify-patches.bats consume this file, so adding a marker
|
||||
# here adds it to the runtime check and the test matrix at the same
|
||||
# time.
|
||||
#
|
||||
# Columns:
|
||||
# name — kebab-case id; surfaces in verify output and BATS names.
|
||||
# pattern — PCRE matched against the shipped index.js by `grep -P`.
|
||||
# sample — concrete string the pattern matches; BATS uses it to
|
||||
# build positive and per-marker negative fixtures.
|
||||
#
|
||||
# The 9 markers below correspond 1:1 with the smoke-test set defined
|
||||
# in issue #559 (PR #555 retrofit, deliverable D6).
|
||||
vmclient-log-gate process\.platform==="linux"\)\s*\?\s*"vmClient \(TypeScript\)" (F||process.platform==="linux")?"vmClient (TypeScript)"
|
||||
vm-assignment-linux-gate process\.platform==="linux"\)\?\(?[\w$]+=\{vm:[\w$]+\} (F||process.platform==="linux")?N={vm:M}
|
||||
unix-socket-path process\.platform==="linux"\?\(process\.env\.XDG_RUNTIME_DIR\|\|"/tmp"\)\+"/cowork-vm-service\.sock" process.platform==="linux"?(process.env.XDG_RUNTIME_DIR||"/tmp")+"/cowork-vm-service.sock"
|
||||
empty-linux-bundle-manifest linux:\{x64:\[\],arm64:\[\]\} ,linux:{x64:[],arm64:[]}
|
||||
getdownloadstatus-suppression getDownloadStatus\(\)\{return process\.platform==="linux"\?[\w$]+\.NotDownloaded getDownloadStatus(){return process.platform==="linux"?Z.NotDownloaded
|
||||
econnrefused-on-linux process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED" (n.code==="ENOENT"||process.platform==="linux"&&n.code==="ECONNREFUSED")
|
||||
cowork-daemon-pid global\.__coworkDaemonPid global.__coworkDaemonPid=_c.pid
|
||||
cowork-linux-daemon-shutdown cowork-linux-daemon-shutdown name:"cowork-linux-daemon-shutdown"
|
||||
sharedcwdpath-threadthrough sharedCwdPath:this\.sessions\.get\( sharedCwdPath:this.sessions.get(t)?.userSelectedFolders?.[0]
|
||||
|
Can't render this file because it contains an unexpected character in line 21 and column 39.
|
@@ -2420,7 +2420,7 @@ function detectBackend(emitEvent) {
|
||||
+ 'AppArmor blocks unprivileged user namespaces by '
|
||||
+ 'default (apparmor_restrict_unprivileged_userns=1). '
|
||||
+ 'See the "Cowork on Ubuntu 24.04" section in '
|
||||
+ 'docs/troubleshooting.md for the AppArmor profile '
|
||||
+ 'docs/TROUBLESHOOTING.md for the AppArmor profile '
|
||||
+ 'fix.');
|
||||
} else {
|
||||
logError(`bwrap probe failed: ${e.message || '(no message)'}`);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# shellcheck shell=bash
|
||||
#===============================================================================
|
||||
# Doctor Diagnostics
|
||||
#
|
||||
@@ -72,110 +71,12 @@ _cowork_pkg_hint() {
|
||||
arch) pkg='qemu-full' ;;
|
||||
esac
|
||||
;;
|
||||
ibus-gtk3)
|
||||
# Arch ships the GTK3 immodule as part of the main ibus
|
||||
# package; Debian/Ubuntu and Fedora split it out.
|
||||
case "$distro" in
|
||||
arch) pkg='ibus' ;;
|
||||
*) pkg='ibus-gtk3' ;;
|
||||
esac
|
||||
;;
|
||||
*) pkg="$tool" ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$pkg_cmd $pkg"
|
||||
}
|
||||
|
||||
# Return 0 if the named package is installed, 1 otherwise. Returns 2
|
||||
# (treated as "unknown") when no recognized package manager is
|
||||
# available — callers should not warn in that case to avoid false
|
||||
# positives on unsupported distros.
|
||||
_pkg_installed() {
|
||||
local distro="$1"
|
||||
local pkg="$2"
|
||||
case "$distro" in
|
||||
debian|ubuntu)
|
||||
command -v dpkg-query &>/dev/null || return 2
|
||||
dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \
|
||||
| grep -q 'install ok installed'
|
||||
;;
|
||||
fedora)
|
||||
command -v rpm &>/dev/null || return 2
|
||||
rpm -q "$pkg" &>/dev/null
|
||||
;;
|
||||
arch)
|
||||
command -v pacman &>/dev/null || return 2
|
||||
pacman -Q "$pkg" &>/dev/null
|
||||
;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Diagnose IBus / GTK input-method misconfigurations that break
|
||||
# keyboard input in the chat (#550). Surfaces:
|
||||
# - CLAUDE_GTK_IM_MODULE override visibility (informational)
|
||||
# - XWayland-with-IBus routing note: on a Wayland session Electron
|
||||
# defaults to XWayland (preserves global hotkeys), which forces
|
||||
# the IBus path through XIM — a known weak link for some IMEs.
|
||||
# - ibus-gtk3 package missing when GTK_IM_MODULE=ibus
|
||||
# - GTK immodules cache stale: active module not listed by
|
||||
# gtk-query-immodules-3.0 (--update-cache fixes it)
|
||||
#
|
||||
# Usage: _doctor_check_im_modules <distro_id>
|
||||
_doctor_check_im_modules() {
|
||||
local distro="$1"
|
||||
local active_im="${CLAUDE_GTK_IM_MODULE:-${GTK_IM_MODULE:-}}"
|
||||
|
||||
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
|
||||
_info "CLAUDE_GTK_IM_MODULE=$CLAUDE_GTK_IM_MODULE" \
|
||||
"(overrides GTK_IM_MODULE for Electron)"
|
||||
fi
|
||||
|
||||
if [[ ${XDG_SESSION_TYPE:-} == 'wayland' \
|
||||
&& -z ${CLAUDE_USE_WAYLAND:-} ]]; then
|
||||
_info \
|
||||
'IME note: Wayland session, Electron via XWayland —' \
|
||||
'IBus path goes through XIM (lossy for some IMEs).'
|
||||
_info \
|
||||
'Tip: CLAUDE_USE_WAYLAND=1 enables native Wayland IME' \
|
||||
'(loses global hotkeys).'
|
||||
fi
|
||||
|
||||
# Nothing further to check without an active IM module.
|
||||
[[ -n $active_im ]] || return 0
|
||||
|
||||
# ibus-gtk3 package check — only when the active module is ibus.
|
||||
# rc=1 means definitely missing (warn); rc=2 means unsupported
|
||||
# distro / no package manager (skip silently to avoid false
|
||||
# negatives). On warn, return early — `apt install` refreshes
|
||||
# the immodules cache, so the cache check below would be noise.
|
||||
if [[ $active_im == 'ibus' ]]; then
|
||||
_pkg_installed "$distro" ibus-gtk3
|
||||
case $? in
|
||||
1)
|
||||
_warn \
|
||||
"GTK_IM_MODULE=ibus but ibus-gtk3 is not installed"
|
||||
_info "Fix: $(_cowork_pkg_hint "$distro" ibus-gtk3)"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# GTK immodules cache check. gtk-query-immodules-3.0 ships with
|
||||
# libgtk-3-bin (Debian/Ubuntu) / gtk3 (Fedora/Arch); absence
|
||||
# means GTK 3 isn't in use — skip silently rather than warn.
|
||||
command -v gtk-query-immodules-3.0 &>/dev/null || return 0
|
||||
|
||||
if ! gtk-query-immodules-3.0 2>/dev/null \
|
||||
| grep -q "\"$active_im\""; then
|
||||
_warn \
|
||||
"GTK immodules: '$active_im' not listed by" \
|
||||
"gtk-query-immodules-3.0 (cache may be stale)"
|
||||
_info \
|
||||
'Fix: sudo gtk-query-immodules-3.0 --update-cache'
|
||||
fi
|
||||
}
|
||||
|
||||
# Read the version string from the version file beside an Electron binary.
|
||||
# Prints the raw version string, or nothing if unavailable.
|
||||
_electron_version() {
|
||||
@@ -437,147 +338,6 @@ JSEOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Diagnose short-filename-limit filesystems that break cowork session
|
||||
# initialization. Claude Code creates a per-session directory under
|
||||
# ~/.claude/projects/ whose name is the sanitized host CWD — for cowork
|
||||
# sessions that flattens to ~180 chars (the host CWD is the deeply
|
||||
# nested outputs dir under ~/.config/Claude/local-agent-mode-sessions/
|
||||
# <accountId>/<orgId>/local_<uuid>/outputs). On filesystems with a
|
||||
# short NAME_MAX — eCryptfs caps at 143 due to filename-encryption
|
||||
# overhead — that mkdir fails with ENAMETOOLONG and the session never
|
||||
# starts. Standard fs (ext4/btrfs/xfs/zfs) cap at 255 and are fine. See
|
||||
# #590.
|
||||
_doctor_check_filename_limit() {
|
||||
# Walk up from ~/.claude/projects to the first dir that exists so
|
||||
# getconf has something to query on a fresh install where the tree
|
||||
# hasn't been created yet. $HOME is the floor — stop there rather
|
||||
# than crossing into /.
|
||||
local probe_dir="$HOME/.claude/projects"
|
||||
while [[ ! -d $probe_dir ]]; do
|
||||
probe_dir=$(dirname "$probe_dir")
|
||||
[[ $probe_dir == "$HOME" || $probe_dir == / ]] && break
|
||||
done
|
||||
[[ -d $probe_dir ]] || return 0
|
||||
|
||||
local name_max
|
||||
name_max=$(getconf NAME_MAX "$probe_dir" 2>/dev/null) || return 0
|
||||
[[ $name_max =~ ^[0-9]+$ ]] || return 0
|
||||
|
||||
((name_max >= 200)) && return 0
|
||||
|
||||
_warn "Filename limit: NAME_MAX=$name_max on $probe_dir (< 200)"
|
||||
_info \
|
||||
'Cowork sessions create project-dir names up to ~180 chars' \
|
||||
'under ~/.claude/projects/; short limits cause ENAMETOOLONG'
|
||||
_info 'when Claude Code initializes a session inside cowork (#590).'
|
||||
|
||||
local fs_type
|
||||
fs_type=$(df --output=fstype "$probe_dir" 2>/dev/null \
|
||||
| awk 'NR==2 {print $1}')
|
||||
if [[ $fs_type == 'ecryptfs' ]]; then
|
||||
_info \
|
||||
'Detected eCryptfs (legacy Ubuntu/Mint encrypted home,' \
|
||||
'NAME_MAX=143 due to filename-encryption overhead).'
|
||||
_info \
|
||||
'Workaround: move ~/.config/Claude onto a separate' \
|
||||
'LUKS-encrypted ext4 volume (NAME_MAX=255) and symlink it'
|
||||
_info \
|
||||
'back. See docs/troubleshooting.md "Cowork: ENAMETOOLONG' \
|
||||
'on encrypted home (eCryptfs)" for the worked steps.'
|
||||
fi
|
||||
}
|
||||
|
||||
# Surface a warning when systemd-coredump shows N+ recent Electron
|
||||
# crashes. The most common cause on Linux is the GPU process FATAL
|
||||
# exhaustion tracked in #583 — workaround for affected users is the
|
||||
# upstream Settings → disable hardware acceleration toggle, or
|
||||
# CLAUDE_DISABLE_GPU=1 in the environment for headless persistence.
|
||||
#
|
||||
# Arguments: $1 = electron path (e.g.,
|
||||
# /usr/lib/claude-desktop/node_modules/electron/dist/electron)
|
||||
# Used to filter results to claude-desktop's electron when possible;
|
||||
# falls back to all-electron crashes when the path doesn't match
|
||||
# (e.g., AppImage mount paths are transient).
|
||||
_doctor_check_recent_crashes() {
|
||||
local electron_path="${1:-}"
|
||||
command -v coredumpctl &>/dev/null || return 0
|
||||
|
||||
# `coredumpctl list electron` filters by COMM=electron. If the
|
||||
# exact electron_path matches any entry's EXE column, prefer that
|
||||
# tighter count; otherwise fall back to all-electron entries.
|
||||
local listing total_count path_count
|
||||
listing=$(coredumpctl list electron \
|
||||
--since='7 days ago' --no-pager 2>/dev/null) || return 0
|
||||
[[ -n $listing ]] || return 0
|
||||
|
||||
# Drop the header line; count remaining entries.
|
||||
# Assumes `coredumpctl list electron`'s COMM=electron filter
|
||||
# excludes `-- Reboot --` separator rows from the listing (true
|
||||
# on systemd as of writing). The path-matched branch below uses
|
||||
# index($0, p) so it's unaffected even if that ever changes;
|
||||
# revisit this total-count branch if a future systemd version
|
||||
# starts leaking reboot markers into per-COMM listings.
|
||||
total_count=$(awk 'NR>1 && NF>0' <<< "$listing" | wc -l)
|
||||
((total_count == 0)) && return 0
|
||||
|
||||
if [[ -n $electron_path ]]; then
|
||||
path_count=$(awk -v p="$electron_path" \
|
||||
'NR>1 && index($0, p)' <<< "$listing" | wc -l)
|
||||
else
|
||||
path_count=0
|
||||
fi
|
||||
|
||||
# Use the path-matched count when available; else the unfiltered
|
||||
# count with a footnote so the user knows it may include other
|
||||
# Electron apps (Slack, VSCode, etc.).
|
||||
local count footnote=''
|
||||
if ((path_count > 0)); then
|
||||
count=$path_count
|
||||
else
|
||||
count=$total_count
|
||||
footnote=' (some entries may be from other Electron apps)'
|
||||
fi
|
||||
|
||||
# Threshold tuned against the #583 repro (~10 crashes over 7 days
|
||||
# on the affected laptop); a noisy session typically clears 3 in a
|
||||
# week, so 3 is the floor for "worth surfacing the workaround".
|
||||
if ((count >= 3)); then
|
||||
_warn "Recent Electron crashes: $count in last 7 days$footnote"
|
||||
_info \
|
||||
'Most common cause: Chromium GPU process FATAL (#583).' \
|
||||
'Try one of:'
|
||||
_info ' Settings → toggle hardware acceleration off → restart'
|
||||
_info ' or set CLAUDE_DISABLE_GPU=1 in the environment'
|
||||
_info \
|
||||
'Tracking:' \
|
||||
'https://github.com/aaddrick/claude-desktop-debian/issues/583'
|
||||
elif ((count > 0)); then
|
||||
_info "Recent Electron crashes: $count in last 7 days$footnote"
|
||||
fi
|
||||
}
|
||||
|
||||
# Report the active Chromium password-store backend.
|
||||
#
|
||||
# Calls _detect_password_store() (defined in launcher-common.sh, which
|
||||
# sources this file) to surface what keyring Electron will use for
|
||||
# safeStorage / cookie encryption. 'basic' is valid but means tokens
|
||||
# rely on filesystem permissions alone, so we note it for visibility.
|
||||
# Never fails — basic is an intentional fallback, not an error.
|
||||
_doctor_check_password_store() {
|
||||
local store
|
||||
store=$(_detect_password_store)
|
||||
_pass "Password store: $store"
|
||||
if [[ $store == 'basic' ]]; then
|
||||
_info \
|
||||
' → using fixed-key fallback;' \
|
||||
'tokens are protected by filesystem permissions only'
|
||||
fi
|
||||
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
|
||||
_info \
|
||||
" → overridden by CLAUDE_PASSWORD_STORE=${CLAUDE_PASSWORD_STORE}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run all diagnostic checks and print results
|
||||
# Arguments: $1 = electron path (optional, for package-specific checks)
|
||||
run_doctor() {
|
||||
@@ -585,11 +345,6 @@ run_doctor() {
|
||||
local _doctor_failures=0
|
||||
_doctor_colors
|
||||
|
||||
# Distro ID is shared between the IM-module check (#550) and the
|
||||
# Cowork Mode section further down. Resolve once.
|
||||
local _distro_id
|
||||
_distro_id=$(_cowork_distro_id)
|
||||
|
||||
echo -e "${_bold}Claude Desktop Diagnostics${_reset}"
|
||||
echo '================================'
|
||||
echo
|
||||
@@ -626,9 +381,6 @@ run_doctor() {
|
||||
_info 'Fix: Run from within an X11 or Wayland session, not a TTY'
|
||||
fi
|
||||
|
||||
# -- Input method (IBus / GTK) --
|
||||
_doctor_check_im_modules "$_distro_id"
|
||||
|
||||
# -- Menu bar mode --
|
||||
local menu_bar_mode="${CLAUDE_MENU_BAR:-}"
|
||||
if [[ -n $menu_bar_mode ]]; then
|
||||
@@ -750,9 +502,6 @@ run_doctor() {
|
||||
_pass 'SingletonLock: no lock file (OK)'
|
||||
fi
|
||||
|
||||
# -- Password store --
|
||||
_doctor_check_password_store
|
||||
|
||||
# -- MCP config --
|
||||
local mcp_config="$config_dir/claude_desktop_config.json"
|
||||
if [[ -f $mcp_config ]]; then
|
||||
@@ -840,6 +589,10 @@ print(len(servers))
|
||||
echo -e "${_bold}Cowork Mode${_reset}"
|
||||
echo '----------------'
|
||||
|
||||
# Detect distro for package hints
|
||||
local _distro_id
|
||||
_distro_id=$(_cowork_distro_id)
|
||||
|
||||
# Determine whether bwrap is the active backend (for severity
|
||||
# of bwrap-related diagnostics). Auto-detect prefers bwrap, so
|
||||
# bwrap is active unless the user has overridden to KVM or host.
|
||||
@@ -888,7 +641,7 @@ print(len(servers))
|
||||
' Common on Ubuntu 24.04+ where AppArmor sets' \
|
||||
'apparmor_restrict_unprivileged_userns=1'
|
||||
_info \
|
||||
' by default. See docs/troubleshooting.md' \
|
||||
' by default. See docs/TROUBLESHOOTING.md' \
|
||||
'"Cowork on Ubuntu 24.04"'
|
||||
_info ' for the AppArmor profile fix.'
|
||||
fi
|
||||
@@ -1032,10 +785,6 @@ print(len(servers))
|
||||
# Custom bwrap mount configuration
|
||||
_doctor_check_bwrap_mounts
|
||||
|
||||
# Short NAME_MAX on the host's ~/.claude tree (eCryptfs etc.)
|
||||
# blocks cowork session init with ENAMETOOLONG — see #590.
|
||||
_doctor_check_filename_limit
|
||||
|
||||
# -- Orphaned cowork daemon --
|
||||
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon
|
||||
# above: a live UI is an Electron main process on app.asar that is
|
||||
@@ -1072,11 +821,6 @@ print(len(servers))
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Recent crashes --
|
||||
# Surfaces the GPU process FATAL pattern (#583) before users
|
||||
# notice the in-app "Claude crashed repeatedly" prompt.
|
||||
_doctor_check_recent_crashes "$electron_path"
|
||||
|
||||
# -- Log file --
|
||||
local log_path
|
||||
log_path="${XDG_CACHE_HOME:-$HOME/.cache}"
|
||||
|
||||
@@ -81,19 +81,15 @@ const CLOSE_TO_TRAY = process.platform === 'linux'
|
||||
&& process.env.CLAUDE_QUIT_ON_CLOSE !== '1';
|
||||
console.log(`[Frame Fix] Close-to-tray: ${CLOSE_TO_TRAY ? 'on' : 'off'}`);
|
||||
|
||||
// Detect if a window intends to be frameless (popup/Quick Entry/About).
|
||||
// Window kinds — see build-reference/app-extracted/.vite/build/index.js:
|
||||
// Quick Entry: titleBarStyle:"hidden", frame:false (caught early)
|
||||
// About: titleBarStyle:"hiddenInset", no minWidth, no parent
|
||||
// Main: titleBarStyle:"hidden", minWidth:600
|
||||
// Hardware Buddy: titleBarStyle:"hiddenInset", parent set (child modal — keep frame)
|
||||
// minWidth excludes Main; the `parent` key excludes Hardware Buddy. About
|
||||
// went from "" to "hiddenInset" upstream, so the test matches either.
|
||||
// Detect if a window intends to be frameless (popup/Quick Entry/About)
|
||||
// Quick Entry: titleBarStyle:"", skipTaskbar:true, transparent:true, resizable:false
|
||||
// About: titleBarStyle:"", skipTaskbar:true, resizable:false
|
||||
// Main: titleBarStyle:"", titleBarOverlay:false(linux), resizable (has minWidth)
|
||||
// The main window has minWidth set; popups do not.
|
||||
function isPopupWindow(options) {
|
||||
if (!options) return false;
|
||||
if (options.frame === false) return true;
|
||||
if ('parent' in options) return false;
|
||||
if ((options.titleBarStyle === '' || options.titleBarStyle === 'hiddenInset') && !options.minWidth) return true;
|
||||
if (options.titleBarStyle === '' && !options.minWidth) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -121,28 +117,6 @@ const LINUX_CSS = `
|
||||
}
|
||||
`;
|
||||
|
||||
// autoUpdater no-op: every property access returns a chainable function
|
||||
// so `.on(...).once(...).setFeedURL(...).checkForUpdates()` is harmless.
|
||||
// `getFeedURL` returns '' so any code that inspects the URL gets a
|
||||
// well-typed empty string rather than undefined. `then`/`catch`/`finally`
|
||||
// and `Symbol.toPrimitive`/`Symbol.iterator` resolve to `undefined` so the
|
||||
// Proxy is not mistaken for a thenable (which would call chainNoop as
|
||||
// `then(resolve, reject)` and never resolve — silent await hang) or
|
||||
// asked to coerce to a primitive. Writes land on the target but are
|
||||
// shadowed by the get-trap. Defined once and reused across all
|
||||
// require('electron') calls. Linux-only; macOS/Windows still see the
|
||||
// real autoUpdater. See #567.
|
||||
const autoUpdaterNoop = new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'getFeedURL') return () => '';
|
||||
if (prop === 'then' || prop === 'catch' || prop === 'finally'
|
||||
|| prop === Symbol.toPrimitive || prop === Symbol.iterator) {
|
||||
return undefined;
|
||||
}
|
||||
return function chainNoop() { return autoUpdaterNoop; };
|
||||
},
|
||||
});
|
||||
|
||||
// Build the patched BrowserWindow class and Menu interceptor once,
|
||||
// on first require('electron'), then reuse via Proxy on every access.
|
||||
let PatchedBrowserWindow = null;
|
||||
@@ -339,32 +313,6 @@ Module.prototype.require = function(id) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// CLAUDE_QUIT_ON_CLOSE=1: the bundled main-process code
|
||||
// (`.vite/build/index.js`) installs its own main-window
|
||||
// close listener that hardcodes `preventDefault()` +
|
||||
// `hide()` on every non-Windows platform, with no
|
||||
// setting or env var to disable it. The wrapper's
|
||||
// opt-out above only removes *this* file's hide handler;
|
||||
// the bundled one still runs, so without this branch
|
||||
// closing the window still leaves the app alive in the
|
||||
// tray (in-app schedulers / single-instance lock /
|
||||
// deleted-inode electron after dpkg upgrade-in-place).
|
||||
//
|
||||
// Approach: register a close listener that runs *first*
|
||||
// and calls app.quit(). app.quit() emits 'before-quit'
|
||||
// synchronously, which sets the bundled code's
|
||||
// "quitting in progress" flag. The bundled close
|
||||
// listener then runs second, sees that flag, and
|
||||
// short-circuits via its own `if (lC()) return;` guard
|
||||
// — so it never calls preventDefault, and the window
|
||||
// closes normally during the quit flow. We ride the
|
||||
// upstream's own quit-safety contract instead of trying
|
||||
// to remove or splice their listener; robust to any
|
||||
// refactor that preserves the quit-in-progress short-
|
||||
// circuit (which they need for Ctrl+Q / tray Quit /
|
||||
// SIGTERM anyway). Fixes: #623
|
||||
this.on('close', () => { result.app.quit(); });
|
||||
}
|
||||
|
||||
// Directly set child view bounds to match content size.
|
||||
@@ -706,74 +654,6 @@ X-GNOME-Autostart-enabled=true
|
||||
console.log('[Autostart] XDG Autostart shim installed');
|
||||
}
|
||||
|
||||
// Detect in-place package upgrade (dpkg/rpm rename-replace of
|
||||
// app.asar) and offer a restart, since post-swap window loads
|
||||
// mix v(N+1) HTML/assets with the v(N) IPC/preload still in
|
||||
// memory. AppImage and Nix are immune (immutable running file);
|
||||
// the watcher just no-ops there. Fixes: see PR #564.
|
||||
const armUpgradeWatcher = () => {
|
||||
if (process.platform !== 'linux') return;
|
||||
const fs = require('fs');
|
||||
const asarPath = path.join(process.resourcesPath, 'app.asar');
|
||||
let baseline;
|
||||
try { baseline = fs.statSync(asarPath); } catch { return; }
|
||||
|
||||
let notified = false;
|
||||
let debounceTimer = null;
|
||||
const promptRestart = () => {
|
||||
if (notified) return;
|
||||
let cur;
|
||||
try { cur = fs.statSync(asarPath); } catch { return; }
|
||||
// ino catches rename-replace; mtime catches in-place
|
||||
// rewrite. Either is sufficient on its own for dpkg/rpm,
|
||||
// but checking both keeps us honest against odd packagers.
|
||||
if (cur.ino === baseline.ino
|
||||
&& cur.mtimeMs === baseline.mtimeMs) return;
|
||||
notified = true;
|
||||
console.log('[Frame Fix] app.asar replaced — prompting restart');
|
||||
// whenReady() resolves immediately if already ready, so no
|
||||
// isReady() branch needed. Linux libnotify ignores
|
||||
// Notification.actions (macOS-only), so whole-notification
|
||||
// click is the only restart affordance.
|
||||
result.app.whenReady().then(() => {
|
||||
try {
|
||||
const n = new result.Notification({
|
||||
title: 'Claude Desktop has been updated',
|
||||
body: 'Click to restart and apply the update.',
|
||||
});
|
||||
n.on('click', () => {
|
||||
result.app.relaunch();
|
||||
result.app.quit();
|
||||
});
|
||||
n.show();
|
||||
} catch (err) {
|
||||
console.warn('[Frame Fix] Restart notification failed:',
|
||||
err.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Watch the parent dir, not the file: file-level fs.watch
|
||||
// loses the inode across rename-replace. Filename filter
|
||||
// ignores unrelated activity in the resources dir; 5s
|
||||
// debounce covers dpkg's .dpkg-new → rename dance and
|
||||
// similar multi-stage swaps in rpm/Nix.
|
||||
const watcher = fs.watch(path.dirname(asarPath),
|
||||
(_evt, filename) => {
|
||||
if (filename !== 'app.asar') return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(promptRestart, 5000);
|
||||
});
|
||||
// App's other handles drive process lifetime; the watcher
|
||||
// shouldn't keep the loop alive on its own.
|
||||
watcher.unref();
|
||||
console.log('[Frame Fix] Upgrade watcher armed:', asarPath);
|
||||
};
|
||||
try { armUpgradeWatcher(); } catch (err) {
|
||||
console.warn('[Frame Fix] Upgrade watcher failed to arm:',
|
||||
err.message);
|
||||
}
|
||||
|
||||
console.log('[Frame Fix] Patches built successfully');
|
||||
}
|
||||
|
||||
@@ -793,23 +673,6 @@ X-GNOME-Autostart-enabled=true
|
||||
}
|
||||
});
|
||||
}
|
||||
if (prop === 'autoUpdater' && process.platform === 'linux') {
|
||||
// Force autoUpdater into a no-op on Linux. Upstream's bundled
|
||||
// app code sets a feed URL of api.anthropic.com/api/desktop/linux/...
|
||||
// when app.isPackaged is true (we set ELECTRON_FORCE_IS_PACKAGED=true
|
||||
// unconditionally). Today this is a happy accident: Electron's Linux
|
||||
// autoUpdater is unimplemented and logs "AutoUpdater is not supported
|
||||
// on Linux", so the calls no-op. If a future Electron implements it,
|
||||
// every install would start hitting that feed and would either 404
|
||||
// or — worse — receive content the install wasn't prepared for.
|
||||
// .deb/.rpm/AppImage updates flow through the OS package manager
|
||||
// (or AppImageUpdate); the Anthropic feed has no Linux artifacts.
|
||||
// We replace the entire autoUpdater object with a Proxy that
|
||||
// no-ops every method and returns chainable stubs for EventEmitter
|
||||
// calls so listener registration in the bundled code is harmless.
|
||||
// See #567.
|
||||
return autoUpdaterNoop;
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,41 +16,6 @@ log_message() {
|
||||
echo "$1" >> "$log_file"
|
||||
}
|
||||
|
||||
# Log the session/IME environment vars that drive display and input
|
||||
# decisions, so bug reports include enough context to reason about
|
||||
# them without round-trip env-dump requests (#548).
|
||||
#
|
||||
# Emits one block:
|
||||
# env={
|
||||
# KEY=value
|
||||
# ...
|
||||
# }
|
||||
#
|
||||
# Empty or unset values are emitted as `KEY=` so absence is
|
||||
# unambiguous (vs. silently omitted). Caller must run setup_logging
|
||||
# first.
|
||||
log_session_env() {
|
||||
local key
|
||||
log_message 'env={'
|
||||
for key in \
|
||||
XDG_SESSION_TYPE \
|
||||
WAYLAND_DISPLAY \
|
||||
DISPLAY \
|
||||
XDG_CURRENT_DESKTOP \
|
||||
GTK_IM_MODULE \
|
||||
XMODIFIERS \
|
||||
QT_IM_MODULE \
|
||||
CLAUDE_USE_WAYLAND \
|
||||
CLAUDE_TITLEBAR_STYLE \
|
||||
CLAUDE_PASSWORD_STORE \
|
||||
CLAUDE_GTK_IM_MODULE \
|
||||
CLAUDE_DISABLE_GPU
|
||||
do
|
||||
log_message " $key=${!key:-}"
|
||||
done
|
||||
log_message '}'
|
||||
}
|
||||
|
||||
# Detect display backend (Wayland vs X11)
|
||||
# Sets: is_wayland, use_x11_on_wayland
|
||||
detect_display_backend() {
|
||||
@@ -102,56 +67,6 @@ _resolve_titlebar_style() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Determine the best available Chromium password-store backend.
|
||||
#
|
||||
# Electron's safeStorage API and Chromium's cookie encryption both rely
|
||||
# on the OS credential store selected by --password-store. Without a
|
||||
# working store safeStorage.isEncryptionAvailable() returns false, OAuth
|
||||
# tokens are silently discarded on exit, and users must re-authenticate
|
||||
# on every launch (Cookies file stays 0 bytes). Fixes: #593
|
||||
#
|
||||
# Detection order (first match wins):
|
||||
# CLAUDE_PASSWORD_STORE env var — explicit user override
|
||||
# kwallet6 — KDE Plasma 6 keyring
|
||||
# gnome-libsecret — GNOME Keyring / libsecret bridge
|
||||
# basic — fixed internal key (always works)
|
||||
#
|
||||
# With 'basic' the stored data is encrypted with a fixed key. Tokens
|
||||
# remain protected by Linux filesystem permissions on ~/.config/Claude/.
|
||||
#
|
||||
# Assumes a D-Bus session bus is available; this is true for any
|
||||
# graphical login session.
|
||||
_detect_password_store() {
|
||||
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
|
||||
echo "$CLAUDE_PASSWORD_STORE"
|
||||
return
|
||||
fi
|
||||
|
||||
# kwallet6: KDE Plasma 6 keyring
|
||||
if dbus-send --session --print-reply --reply-timeout=1000 \
|
||||
--dest=org.kde.kwalletd6 \
|
||||
/modules/kwalletd6 \
|
||||
org.kde.KWallet.isEnabled 2>/dev/null \
|
||||
| grep -q 'boolean true'
|
||||
then
|
||||
echo 'kwallet6'
|
||||
return
|
||||
fi
|
||||
|
||||
# gnome-libsecret: GNOME Keyring, KWallet 5 compat bridge, etc.
|
||||
if dbus-send --session --print-reply --reply-timeout=1000 \
|
||||
--dest=org.freedesktop.secrets \
|
||||
/org/freedesktop/secrets \
|
||||
org.freedesktop.DBus.Peer.Ping >/dev/null 2>&1
|
||||
then
|
||||
echo 'gnome-libsecret'
|
||||
return
|
||||
fi
|
||||
|
||||
# No keyring accessible — fall back to fixed-key provider.
|
||||
echo 'basic'
|
||||
}
|
||||
|
||||
# Build Electron arguments array based on display backend
|
||||
# Requires: is_wayland, use_x11_on_wayland to be set
|
||||
# (call detect_display_backend first)
|
||||
@@ -181,19 +96,6 @@ build_electron_args() {
|
||||
electron_args+=('--disable-features=CustomTitlebar')
|
||||
fi
|
||||
|
||||
# Chromium's safeStorage API and cookie encryption both require a
|
||||
# system keyring selected by --password-store. Without an explicit
|
||||
# value, Electron may silently report encryption unavailable even
|
||||
# when a keyring daemon is running, discarding OAuth tokens on exit
|
||||
# and forcing re-authentication on every launch. We probe for the
|
||||
# best available store at startup and pass it before the app path
|
||||
# so Chromium treats it as a Chromium flag (args after the app
|
||||
# path go to the renderer, not Chromium). Fixes: #593
|
||||
local pw_store
|
||||
pw_store=$(_detect_password_store)
|
||||
electron_args+=("--password-store=${pw_store}")
|
||||
log_message "Password store: ${pw_store}"
|
||||
|
||||
# Remote XRDP sessions lack GPU acceleration and render a blank
|
||||
# window when GPU compositing is enabled. Detect via XRDP_SESSION
|
||||
# (set by xrdp's session init) and loginctl session Type. We do
|
||||
@@ -205,24 +107,10 @@ build_electron_args() {
|
||||
loginctl show-session "$XDG_SESSION_ID" \
|
||||
-p Type --value 2>/dev/null
|
||||
)
|
||||
# Track GPU-disable decision so XRDP and CLAUDE_DISABLE_GPU don't
|
||||
# stack duplicate flags. Either signal is sufficient.
|
||||
local _disable_gpu=false
|
||||
if [[ -n ${XRDP_SESSION:-} || $rdp_session_type == xrdp ]]; then
|
||||
_disable_gpu=true
|
||||
electron_args+=('--disable-gpu' '--disable-software-rasterizer')
|
||||
log_message 'XRDP session detected - GPU compositing disabled'
|
||||
fi
|
||||
# CLAUDE_DISABLE_GPU=1: opt-in workaround for users hitting the
|
||||
# Chromium GPU process FATAL exhaustion (#583). The same upstream
|
||||
# behaviour is reachable via Settings → disable hardware
|
||||
# acceleration; this lets users persist it via the env without
|
||||
# having to reach the Settings UI through repeated crashes.
|
||||
if [[ ${CLAUDE_DISABLE_GPU:-} == '1' ]]; then
|
||||
_disable_gpu=true
|
||||
log_message 'CLAUDE_DISABLE_GPU=1 - hardware acceleration disabled'
|
||||
fi
|
||||
[[ $_disable_gpu == true ]] \
|
||||
&& electron_args+=('--disable-gpu' '--disable-software-rasterizer')
|
||||
|
||||
# X11 session - no special flags needed
|
||||
if [[ $is_wayland != true ]]; then
|
||||
@@ -394,15 +282,6 @@ setup_electron_env() {
|
||||
if [[ $(_resolve_titlebar_style) != 'hidden' ]]; then
|
||||
export ELECTRON_USE_SYSTEM_TITLE_BAR=1
|
||||
fi
|
||||
# CLAUDE_GTK_IM_MODULE: opt-in override for users hit by broken
|
||||
# IBus integration on Linux (#549). Propagated to GTK_IM_MODULE
|
||||
# so e.g. `xim` can be persisted without wrapping every launch.
|
||||
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
|
||||
local prev="${GTK_IM_MODULE:-<unset>}"
|
||||
export GTK_IM_MODULE="$CLAUDE_GTK_IM_MODULE"
|
||||
log_message \
|
||||
"GTK_IM_MODULE override: $prev -> $GTK_IM_MODULE (via CLAUDE_GTK_IM_MODULE)"
|
||||
fi
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
|
||||
@@ -98,7 +98,6 @@ log_message '--- Claude Desktop AppImage Start ---'
|
||||
log_message "Timestamp: $(date)"
|
||||
log_message "Arguments: $@"
|
||||
log_message "APPDIR: $appdir"
|
||||
log_session_env
|
||||
|
||||
# Path to the bundled Electron executable and app
|
||||
electron_exec="$appdir/usr/lib/node_modules/electron/dist/electron"
|
||||
|
||||
@@ -114,7 +114,6 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start ---'
|
||||
log_message "Timestamp: \$(date)"
|
||||
log_message "Arguments: \$@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
|
||||
@@ -97,7 +97,6 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start ---'
|
||||
log_message "Timestamp: \$(date)"
|
||||
log_message "Arguments: \$@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -229,15 +228,18 @@ install -Dm 644 $staging_dir/claude-desktop.desktop %{buildroot}/usr/share/appli
|
||||
# Install launcher script
|
||||
install -Dm 755 $staging_dir/claude-desktop %{buildroot}/usr/bin/claude-desktop
|
||||
|
||||
# Set the chrome-sandbox suid bit in the buildroot so the /usr/lib
|
||||
# directory walk in %files records 4755 in the payload (preserves #539
|
||||
# without the "File listed twice" warning #609 — see %files block).
|
||||
chmod 4755 %{buildroot}/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
%post
|
||||
# Update desktop database for MIME types
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
|
||||
# Set correct permissions for chrome-sandbox
|
||||
SANDBOX_PATH="/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox"
|
||||
if [ -f "\$SANDBOX_PATH" ]; then
|
||||
echo "Setting chrome-sandbox permissions..."
|
||||
chown root:root "\$SANDBOX_PATH" || echo "Warning: Failed to chown chrome-sandbox"
|
||||
chmod 4755 "\$SANDBOX_PATH" || echo "Warning: Failed to chmod chrome-sandbox"
|
||||
fi
|
||||
|
||||
%postun
|
||||
# Update desktop database after removal
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
@@ -255,26 +257,14 @@ echo 'RPM spec file created'
|
||||
# --- Build RPM Package ---
|
||||
echo 'Building RPM package...'
|
||||
|
||||
rpmbuild_log="$work_dir/rpmbuild.log"
|
||||
rpmbuild --define "_topdir $rpmbuild_dir" \
|
||||
if ! rpmbuild --define "_topdir $rpmbuild_dir" \
|
||||
--define "_rpmdir $work_dir" \
|
||||
--target "$rpm_arch" \
|
||||
-bb "$rpmbuild_dir/SPECS/$package_name.spec" 2>&1 |
|
||||
tee "$rpmbuild_log"
|
||||
if (( PIPESTATUS[0] != 0 )); then
|
||||
-bb "$rpmbuild_dir/SPECS/$package_name.spec"; then
|
||||
echo 'Failed to build RPM package' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Guard against re-introducing #609. The "File listed twice" warning
|
||||
# means %files has overlapping listings, and on modern rpmbuild any
|
||||
# %exclude workaround silently strips the file from the payload.
|
||||
if grep -qF 'File listed twice' "$rpmbuild_log"; then
|
||||
echo 'rpmbuild emitted "File listed twice" — %files has overlapping listings (see #609)' >&2
|
||||
grep -F 'File listed twice' "$rpmbuild_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find and move the built RPM (it will be in a subdirectory)
|
||||
rpm_file=$(find "$work_dir" -name "${package_name}-${rpm_version}*.rpm" -type f | head -n 1)
|
||||
if [[ -z $rpm_file ]]; then
|
||||
|
||||
@@ -37,21 +37,16 @@ EOFENTRY
|
||||
|
||||
# Update package.json
|
||||
echo 'Modifying package.json to load frame fix and add node-pty...'
|
||||
local desktop_name='claude-desktop.desktop'
|
||||
if [[ ${build_format:-} == 'appimage' ]]; then
|
||||
desktop_name='io.github.aaddrick.claude-desktop-debian.desktop'
|
||||
fi
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = require('./app.asar.contents/package.json');
|
||||
pkg.originalMain = pkg.main;
|
||||
pkg.main = 'frame-fix-entry.js';
|
||||
pkg.desktopName = process.argv[1];
|
||||
pkg.optionalDependencies = pkg.optionalDependencies || {};
|
||||
pkg.optionalDependencies['node-pty'] = '^1.0.0';
|
||||
fs.writeFileSync('./app.asar.contents/package.json', JSON.stringify(pkg, null, 2));
|
||||
console.log('Updated package.json: main entry, desktopName, and node-pty dependency');
|
||||
" "$desktop_name"
|
||||
console.log('Updated package.json: main entry and node-pty dependency');
|
||||
"
|
||||
|
||||
# Create stub native module
|
||||
echo 'Creating stub native module...'
|
||||
|
||||
@@ -109,13 +109,6 @@ if (vmClientLogMatch) {
|
||||
'(' + win32Var + '||process.platform==="linux")$1');
|
||||
console.log(' Patched VM client log check for Linux');
|
||||
patchCount++;
|
||||
} else if (code.includes(
|
||||
'||process.platform==="linux")?"vmClient (TypeScript)"'
|
||||
)) {
|
||||
console.log(' VM client log gate already applied (Patch 2a)');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find anchor for VM client log' +
|
||||
' gate (Patch 2a) — half-patched asar will fail Cowork startup');
|
||||
}
|
||||
|
||||
// 2b: Patch the actual module assignment
|
||||
@@ -132,12 +125,6 @@ if (vmClientLogMatch) {
|
||||
'(' + win32Var + '||process.platform==="linux")$1');
|
||||
console.log(' Patched VM module assignment for Linux');
|
||||
patchCount++;
|
||||
} else if (/\|\|process\.platform==="linux"\)\??\(?[\w$]+=\{vm:[\w$]+\}/.test(code)) {
|
||||
console.log(' VM module assignment already applied (Patch 2b)');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find anchor for VM module' +
|
||||
' assignment (Patch 2b) — half-patched asar will fail' +
|
||||
' Cowork startup (PR #555 failure mode)');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find vmClient variable for module loading patch');
|
||||
@@ -807,27 +794,12 @@ install_node_pty() {
|
||||
echo '{"name":"node-pty-build","version":"1.0.0","private":true}' > package.json
|
||||
|
||||
echo 'Installing node-pty (this compiles native module)...'
|
||||
# Fail loudly on npm install failure rather than warn-and-continue.
|
||||
# The previous behavior silently dropped pty_src_dir, skipped the
|
||||
# entire copy block, and shipped the upstream Windows node-pty
|
||||
# binaries (the #401 failure mode). check_dependencies should now
|
||||
# install gcc/g++/make/python3 before we get here, so this branch
|
||||
# is the last line of defense for build-tool gaps that auto-install
|
||||
# couldn't fix (unknown distro, broken package mirror, etc.).
|
||||
if ! npm install node-pty 2>&1; then
|
||||
echo "Error: 'npm install node-pty' failed." >&2
|
||||
echo 'node-pty has a native module compiled via node-gyp;' >&2
|
||||
echo 'this usually means the build environment lacks a C/C++' >&2
|
||||
echo 'compiler, make, or python3.' >&2
|
||||
echo '' >&2
|
||||
echo 'Install build tools and re-run:' >&2
|
||||
echo ' Debian/Ubuntu: sudo apt install build-essential python3' >&2
|
||||
echo ' Fedora/RHEL: sudo dnf install gcc gcc-c++ make python3' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
if npm install node-pty 2>&1; then
|
||||
echo 'node-pty installed successfully'
|
||||
pty_src_dir="$node_pty_build_dir/node_modules/node-pty"
|
||||
else
|
||||
echo 'Failed to install node-pty - terminal features may not work'
|
||||
fi
|
||||
echo 'node-pty installed successfully'
|
||||
pty_src_dir="$node_pty_build_dir/node_modules/node-pty"
|
||||
fi
|
||||
|
||||
if [[ -n $pty_src_dir && -d $pty_src_dir ]]; then
|
||||
|
||||
@@ -11,9 +11,9 @@ patch_tray_menu_handler() {
|
||||
echo 'Patching tray menu handler...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
local tray_func tray_func_re tray_var first_const
|
||||
local tray_func tray_var first_const
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
echo 'Failed to extract tray menu function name' >&2
|
||||
cd "$project_root" || exit 1
|
||||
@@ -21,12 +21,8 @@ patch_tray_menu_handler() {
|
||||
fi
|
||||
echo " Found tray function: $tray_func"
|
||||
|
||||
# Escape `$` for PCRE / sed -E patterns where it would otherwise act
|
||||
# as an end-of-line anchor. Minifier emits identifiers like `i$A`.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
|
||||
tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func_re})" \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
|
||||
"$index_js")
|
||||
if [[ -z $tray_var ]]; then
|
||||
echo 'Failed to extract tray variable name' >&2
|
||||
@@ -35,17 +31,11 @@ patch_tray_menu_handler() {
|
||||
fi
|
||||
echo " Found tray variable: $tray_var"
|
||||
|
||||
# Idempotent: upstream may already ship the function as `async`
|
||||
# (1.8089.1 does). Re-applying the sed would produce
|
||||
# `async async function`, which then breaks downstream patches that
|
||||
# match `(?:async )?function NAME`.
|
||||
if ! grep -q "async function ${tray_func}(){" "$index_js"; then
|
||||
sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
|
||||
"$index_js"
|
||||
fi
|
||||
sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
|
||||
"$index_js"
|
||||
|
||||
first_const=$(grep -oP \
|
||||
"async function ${tray_func_re}\(\)\{.*?const \K\w+(?==)" \
|
||||
"async function ${tray_func}\(\)\{.*?const \K\w+(?==)" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $first_const ]]; then
|
||||
echo 'Failed to extract first const in function' >&2
|
||||
@@ -79,7 +69,7 @@ patch_tray_menu_handler() {
|
||||
"s/(${electron_var_re}\.nativeTheme\.on\(\s*\"updated\"\s*,\s*\(\)\s*=>\s*\{)/let _trayStartTime=Date.now();\1/g" \
|
||||
"$index_js"
|
||||
sed -i -E \
|
||||
"s/\((\w+\([^)]*\))\s*,\s*${tray_func_re}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"s/\((\w+\([^)]*\))\s*,\s*${tray_func}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"$index_js"
|
||||
echo ' Added startup delay check (3 second window)'
|
||||
fi
|
||||
@@ -108,19 +98,17 @@ patch_tray_inplace_update() {
|
||||
|
||||
# Re-extract the tray variable name — `patch_tray_menu_handler`
|
||||
# declares it `local` so it's not visible here. Same grep pattern.
|
||||
local tray_func tray_func_re local_tray_var tray_var_re
|
||||
local tray_func local_tray_var tray_var_re
|
||||
local menu_func path_var enabled_var enabled_count
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
echo ' Could not find tray function — skipping'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
# Escape `$` for PCRE patterns; matches the `tray_var_re` trick below.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
local_tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func_re})" \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
|
||||
"$index_js")
|
||||
if [[ -z $local_tray_var ]]; then
|
||||
echo ' Could not extract tray variable name — skipping'
|
||||
|
||||
@@ -22,51 +22,32 @@ check_dependencies() {
|
||||
rpm) all_deps="$all_deps rpmbuild" ;;
|
||||
esac
|
||||
|
||||
# node-pty has a native C++ module compiled via node-gyp during
|
||||
# `npm install`. Without gcc/g++/make/python3 the install silently
|
||||
# emits a warning, leaves pty_src_dir empty, and the build ends up
|
||||
# shipping the upstream Windows binaries (the #401 failure mode).
|
||||
# Skip when --node-pty-dir is set (Nix and explicit overrides bring
|
||||
# their own pre-built node-pty).
|
||||
if [[ -z ${node_pty_dir:-} ]]; then
|
||||
all_deps="$all_deps gcc g++ make python3"
|
||||
fi
|
||||
|
||||
# Command-to-package mappings per distro family
|
||||
declare -A debian_pkgs=(
|
||||
[p7zip]='p7zip-full' [wget]='wget' [wrestool]='icoutils'
|
||||
[icotool]='icoutils' [convert]='imagemagick'
|
||||
[dpkg-deb]='dpkg-dev' [rpmbuild]='rpm'
|
||||
[gcc]='build-essential' [g++]='build-essential'
|
||||
[make]='build-essential' [python3]='python3'
|
||||
)
|
||||
declare -A rpm_pkgs=(
|
||||
[p7zip]='p7zip p7zip-plugins' [wget]='wget' [wrestool]='icoutils'
|
||||
[icotool]='icoutils' [convert]='ImageMagick'
|
||||
[dpkg-deb]='dpkg' [rpmbuild]='rpm-build'
|
||||
[gcc]='gcc' [g++]='gcc-c++'
|
||||
[make]='make' [python3]='python3'
|
||||
)
|
||||
|
||||
local cmd pkg
|
||||
local cmd
|
||||
for cmd in $all_deps; do
|
||||
if ! check_command "$cmd"; then
|
||||
case "$distro_family" in
|
||||
debian) pkg="${debian_pkgs[$cmd]}" ;;
|
||||
rpm) pkg="${rpm_pkgs[$cmd]}" ;;
|
||||
debian)
|
||||
deps_to_install="$deps_to_install ${debian_pkgs[$cmd]}"
|
||||
;;
|
||||
rpm)
|
||||
deps_to_install="$deps_to_install ${rpm_pkgs[$cmd]}"
|
||||
;;
|
||||
*)
|
||||
echo "Warning: Cannot auto-install '$cmd' on unknown distro. Please install manually." >&2
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
# Several commands map to the same package (gcc/g++/make
|
||||
# -> build-essential, wrestool/icotool -> icoutils). Skip
|
||||
# if the package is already queued so the log line stays
|
||||
# readable.
|
||||
case " $deps_to_install " in
|
||||
*" $pkg "*) ;;
|
||||
*) deps_to_install="$deps_to_install $pkg" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -217,13 +198,6 @@ setup_nodejs() {
|
||||
setup_electron_asar() {
|
||||
section_header 'Electron & Asar Handling'
|
||||
|
||||
# Pin Electron to the exact version upstream Claude Desktop ships
|
||||
# (build-reference/app-extracted/package.json). The shipped app.asar
|
||||
# binds to specific V8/NAPI ABI, Chromium pairing, and node-pty
|
||||
# native surface — running a different Electron major against this
|
||||
# asar is unsupported. Bump when upstream bumps.
|
||||
local electron_version='41.5.0'
|
||||
|
||||
echo "Ensuring local Electron and Asar installation in $work_dir..."
|
||||
cd "$work_dir" || exit 1
|
||||
|
||||
@@ -240,91 +214,19 @@ setup_electron_asar() {
|
||||
[[ ! -f $asar_bin_path ]] && echo 'Asar binary not found.' && install_needed=true
|
||||
|
||||
if [[ $install_needed == true ]]; then
|
||||
echo "Installing electron@${electron_version} and Asar locally into $work_dir..."
|
||||
if ! npm install --no-save \
|
||||
"electron@${electron_version}" @electron/asar @electron/get extract-zip; then
|
||||
echo "Installing Electron and Asar locally into $work_dir..."
|
||||
if ! npm install --no-save electron @electron/asar; then
|
||||
echo 'Failed to install Electron and/or Asar locally.' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo 'Electron and Asar installation command finished.'
|
||||
|
||||
# electron@42+ no longer ships a postinstall script that fetches
|
||||
# the prebuilt binary into dist/. If npm didn't populate it,
|
||||
# fetch the matching binary explicitly via @electron/get. See
|
||||
# #584. Retry once on transient CDN failures (503, network drops).
|
||||
#
|
||||
# Check for the binary itself (not just the dist/ directory),
|
||||
# because under Node 24 the extract-zip step in both the npm
|
||||
# postinstall (electron <42 path) and @electron/get can silently
|
||||
# no-op — leaving an empty dist/locales/ behind, which would pass
|
||||
# a bare `-d` check while no electron binary actually landed.
|
||||
if [[ ! -f $electron_dist_path/electron ]]; then
|
||||
echo 'Electron dist/electron missing; fetching binary explicitly...'
|
||||
local fetch_ok=false
|
||||
local fetch_attempts=0
|
||||
while ! node "$project_root/scripts/setup/fetch-electron-binary.js"; do
|
||||
fetch_attempts=$((fetch_attempts + 1))
|
||||
if (( fetch_attempts >= 2 )); then
|
||||
echo 'Failed to fetch Electron binary via @electron/get after 2 attempts.' >&2
|
||||
echo 'For air-gapped or mirrored builds set ELECTRON_MIRROR or ELECTRON_CUSTOM_DIR; see docs/building.md.' >&2
|
||||
break
|
||||
fi
|
||||
echo "Retrying Electron binary fetch (attempt $((fetch_attempts + 1))/2)..."
|
||||
sleep 2
|
||||
done
|
||||
if (( fetch_attempts < 2 )); then
|
||||
fetch_ok=true
|
||||
fi
|
||||
|
||||
# Final fallback: even when @electron/get reports success,
|
||||
# extract-zip can leave dist/ empty under Node 24 (the
|
||||
# unzip stream resolves without writing files). If we still
|
||||
# have no binary, the cache zip was downloaded successfully
|
||||
# — unpack it with system `unzip`.
|
||||
if [[ ! -f $electron_dist_path/electron ]]; then
|
||||
if [[ $fetch_ok == false ]]; then
|
||||
echo 'Electron download failed; no cached zip to fall back on.' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo 'extract-zip path produced no binary; unpacking @electron/get cache with system unzip...'
|
||||
local electron_cache_dir="$HOME/.cache/electron"
|
||||
local electron_arch
|
||||
case $architecture in
|
||||
amd64) electron_arch='x64' ;;
|
||||
arm64) electron_arch='arm64' ;;
|
||||
*) electron_arch='x64' ;;
|
||||
esac
|
||||
local cached_zip
|
||||
cached_zip=$(find "$electron_cache_dir" -name "electron-v${electron_version}-linux-${electron_arch}.zip" 2>/dev/null | head -1)
|
||||
if [[ -z $cached_zip ]]; then
|
||||
echo "No cached zip matching electron-v${electron_version}-linux-*.zip under $electron_cache_dir" >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v unzip >/dev/null 2>&1; then
|
||||
echo "unzip not installed; cannot apply final fallback. Install unzip and retry, or upgrade extract-zip upstream." >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$electron_dist_path"
|
||||
if ! unzip -oq "$cached_zip" -d "$electron_dist_path"; then
|
||||
echo 'unzip fallback failed.' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
printf 'v%s\n' "$electron_version" > "$electron_dist_path/version"
|
||||
printf 'electron\n' > "$work_dir/node_modules/electron/path.txt"
|
||||
echo "unzip fallback populated $electron_dist_path ($(du -sh "$electron_dist_path" | awk '{print $1}'))"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo 'Local Electron distribution and Asar binary already present.'
|
||||
fi
|
||||
|
||||
if [[ -f $electron_dist_path/electron ]]; then
|
||||
echo "Found Electron binary at $electron_dist_path."
|
||||
if [[ -d $electron_dist_path ]]; then
|
||||
echo "Found Electron distribution directory at $electron_dist_path."
|
||||
chosen_electron_module_path="$(realpath "$work_dir/node_modules/electron")"
|
||||
echo "Setting Electron module path for copying to $chosen_electron_module_path."
|
||||
else
|
||||
|
||||
@@ -24,15 +24,15 @@ detect_architecture() {
|
||||
|
||||
case "$raw_arch" in
|
||||
x86_64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
|
||||
claude_exe_sha256='1ab57e88c86451cf199d1568d751dab7fe29e4bdfa5a3f035fdb15b872c7a352'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
|
||||
claude_exe_sha256='e619c7bd3b6746a7307ebefe509bfe447a143aed97e6c7f666677b36a6b6ba54'
|
||||
architecture='amd64'
|
||||
claude_exe_filename='Claude-Setup-x64.exe'
|
||||
echo 'Configured for amd64 (x86_64) build.'
|
||||
;;
|
||||
aarch64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
|
||||
claude_exe_sha256='3c319a59a59b30bfeb86f71b6df80891c5c983404f12e464f4be1754cd4d2c94'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
|
||||
claude_exe_sha256='bf7de5d6c012542feadf7d5caa77a72dfcea1b24512e9a6d28e004ba4ae2a11d'
|
||||
architecture='arm64'
|
||||
claude_exe_filename='Claude-Setup-arm64.exe'
|
||||
echo 'Configured for arm64 (aarch64) build.'
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Fetches the Electron prebuilt binary into node_modules/electron/dist/.
|
||||
//
|
||||
// electron@42.0.0 (2026-05-06) removed the postinstall script that
|
||||
// historically populated dist/ during `npm install`. This helper restores
|
||||
// that behavior using @electron/get + extract-zip, so the rest of the
|
||||
// build pipeline (which depends on the dist/ layout) keeps working.
|
||||
//
|
||||
// Run from the directory containing node_modules/electron. Reads the
|
||||
// installed electron version from its package.json and downloads the
|
||||
// matching binary for the host platform/arch.
|
||||
//
|
||||
// See: https://github.com/aaddrick/claude-desktop-debian/issues/584
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
async function main() {
|
||||
const cwd = process.cwd();
|
||||
const electronModuleDir = path.join(cwd, 'node_modules', 'electron');
|
||||
const distDir = path.join(electronModuleDir, 'dist');
|
||||
|
||||
if (!fs.existsSync(electronModuleDir)) {
|
||||
throw new Error(
|
||||
`Electron module not found at ${electronModuleDir}; ` +
|
||||
"run 'npm install electron' first.",
|
||||
);
|
||||
}
|
||||
|
||||
const pkgPath = path.join(electronModuleDir, 'package.json');
|
||||
const { version } = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (!version) {
|
||||
throw new Error(`Could not read version from ${pkgPath}`);
|
||||
}
|
||||
|
||||
const platform = 'linux';
|
||||
// node's process.arch values map cleanly to electron release archs,
|
||||
// except 'arm' which electron publishes as 'armv7l'.
|
||||
const arch = process.arch === 'arm' ? 'armv7l' : process.arch;
|
||||
|
||||
const supportedArchs = ['x64', 'arm64', 'armv7l', 'ia32'];
|
||||
if (!supportedArchs.includes(arch)) {
|
||||
throw new Error(
|
||||
`Unsupported architecture: ${arch}. ` +
|
||||
`Electron publishes Linux binaries for ${supportedArchs.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve @electron/get and extract-zip from the work-dir's
|
||||
// node_modules. The script lives at scripts/setup/ so a plain
|
||||
// require() walks up from there and never sees work_dir/.
|
||||
const workDirRequire = createRequire(path.join(cwd, 'package.json'));
|
||||
const { downloadArtifact } = workDirRequire('@electron/get');
|
||||
const extractZip = workDirRequire('extract-zip');
|
||||
|
||||
console.log(`Fetching electron@${version} for ${platform}-${arch}...`);
|
||||
const zipPath = await downloadArtifact({
|
||||
version,
|
||||
platform,
|
||||
arch,
|
||||
artifactName: 'electron',
|
||||
});
|
||||
|
||||
console.log(`Extracting ${zipPath} into ${distDir}`);
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
await extractZip(zipPath, { dir: distDir });
|
||||
|
||||
const electronBin = path.join(distDir, 'electron');
|
||||
if (fs.existsSync(electronBin)) {
|
||||
fs.chmodSync(electronBin, 0o755);
|
||||
}
|
||||
|
||||
console.log('Electron binary fetched and extracted successfully.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err && err.stack ? err.stack : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# verify-patches.sh
|
||||
#
|
||||
# Static-greps a patched index.js for the patch markers defined in
|
||||
# a TSV (defaults to scripts/cowork-patch-markers.tsv). Exits non-zero
|
||||
# on any miss and names the missing markers in the output.
|
||||
#
|
||||
# Defends against silent half-patched asars (issue #559 D6, PR #555).
|
||||
# Reusable for non-cowork patch sets — pass any TSV of the same shape
|
||||
# via the second arg.
|
||||
#
|
||||
# Usage:
|
||||
# verify-patches.sh <path> [markers-tsv]
|
||||
#
|
||||
# <path> may be:
|
||||
# * a JavaScript file (the index.js itself)
|
||||
# * an .asar archive (extracted on the fly via npx @electron/asar)
|
||||
# * a directory containing app.asar.contents/.vite/build/index.js
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — every marker present.
|
||||
# 1 — usage error or input not found.
|
||||
# 2 — one or more markers missing (named on stderr).
|
||||
#
|
||||
|
||||
set -u
|
||||
IFS=$'\n\t'
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
default_markers_tsv="$script_dir/cowork-patch-markers.tsv"
|
||||
markers_tsv="$default_markers_tsv"
|
||||
|
||||
usage() {
|
||||
cat <<-EOF >&2
|
||||
Usage: $(basename "$0") <path> [markers-tsv]
|
||||
|
||||
<path> may be a .js file, an .asar archive, or a directory
|
||||
containing app.asar.contents/.vite/build/index.js. The script
|
||||
greps for patch markers (default: cowork, PR #555 / issue #559
|
||||
D6) and exits non-zero if any are missing.
|
||||
|
||||
[markers-tsv] overrides the default TSV so the same script can
|
||||
verify other patch sets.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse the marker TSV into three parallel arrays. Skips comments
|
||||
# and blank lines. Used by both the verify path here and by the
|
||||
# BATS test, which sources this script (see _is_sourced below) to
|
||||
# share parsing and avoid drift between the two consumers.
|
||||
load_markers() {
|
||||
marker_names=()
|
||||
marker_patterns=()
|
||||
marker_samples=()
|
||||
|
||||
if [[ ! -f $markers_tsv ]]; then
|
||||
echo "verify-patches: marker file not found:" \
|
||||
"$markers_tsv" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local name pattern sample
|
||||
while IFS=$'\t' read -r name pattern sample; do
|
||||
[[ -z $name || $name == '#'* ]] && continue
|
||||
if [[ -z ${pattern:-} || -z ${sample:-} ]]; then
|
||||
echo "verify-patches: malformed row '$name'" \
|
||||
'in markers file' >&2
|
||||
return 1
|
||||
fi
|
||||
marker_names+=("$name")
|
||||
marker_patterns+=("$pattern")
|
||||
marker_samples+=("$sample")
|
||||
done < "$markers_tsv"
|
||||
|
||||
if [[ ${#marker_names[@]} -eq 0 ]]; then
|
||||
echo 'verify-patches: no markers loaded' >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve the input path to an actual index.js. For .asar inputs,
|
||||
# extracts to a temp dir and echoes the inner index.js path. The
|
||||
# caller cleans up via cleanup_tmp.
|
||||
tmp_extract_dir=''
|
||||
cleanup_tmp() {
|
||||
if [[ -n $tmp_extract_dir && -d $tmp_extract_dir ]]; then
|
||||
rm -rf "$tmp_extract_dir"
|
||||
fi
|
||||
}
|
||||
trap cleanup_tmp EXIT
|
||||
|
||||
resolve_index_js() {
|
||||
local input="$1"
|
||||
|
||||
if [[ ! -e $input ]]; then
|
||||
echo "verify-patches: not found: $input" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -d $input ]]; then
|
||||
local candidate="$input/app.asar.contents/.vite/build/index.js"
|
||||
if [[ -f $candidate ]]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
echo "verify-patches: directory does not contain" \
|
||||
"app.asar.contents/.vite/build/index.js: $input" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $input == *.asar ]]; then
|
||||
if ! command -v npx > /dev/null 2>&1; then
|
||||
echo 'verify-patches: npx not found; install Node.js' \
|
||||
'or pre-extract the asar' >&2
|
||||
return 1
|
||||
fi
|
||||
tmp_extract_dir="$(mktemp -d)"
|
||||
if ! npx --yes @electron/asar extract "$input" \
|
||||
"$tmp_extract_dir" > /dev/null 2>&1; then
|
||||
echo "verify-patches: asar extraction failed:" \
|
||||
"$input" >&2
|
||||
return 1
|
||||
fi
|
||||
local extracted="$tmp_extract_dir/.vite/build/index.js"
|
||||
if [[ ! -f $extracted ]]; then
|
||||
echo 'verify-patches: extracted asar lacks' \
|
||||
'.vite/build/index.js' >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$extracted"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Treat as a JS file (.js or any other extension) — let grep
|
||||
# decide whether the contents are sensible.
|
||||
printf '%s\n' "$input"
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
usage
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
-h | --help)
|
||||
usage
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $# -eq 2 ]]; then
|
||||
markers_tsv="$2"
|
||||
fi
|
||||
|
||||
local index_js
|
||||
if ! index_js="$(resolve_index_js "$1")"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! load_markers; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Verifying patch markers in: $index_js"
|
||||
echo "Marker source: $markers_tsv"
|
||||
|
||||
local i missing_names=()
|
||||
for i in "${!marker_names[@]}"; do
|
||||
if grep -qP -- "${marker_patterns[$i]}" "$index_js"; then
|
||||
printf ' OK %s\n' "${marker_names[$i]}"
|
||||
else
|
||||
printf ' MISS %s\n' "${marker_names[$i]}" >&2
|
||||
missing_names+=("${marker_names[$i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing_names[@]} -gt 0 ]]; then
|
||||
local joined
|
||||
joined="$(IFS=','; printf '%s' "${missing_names[*]}")"
|
||||
printf '\nverify-patches: %d/%d markers missing: %s\n' \
|
||||
"${#missing_names[@]}" "${#marker_names[@]}" "$joined" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
printf '\nAll %d patch markers present.\n' \
|
||||
"${#marker_names[@]}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Library mode: when sourced (BATS test), expose load_markers and
|
||||
# the markers_tsv path without running main.
|
||||
_is_sourced() {
|
||||
[[ ${BASH_SOURCE[0]} != "${0}" ]]
|
||||
}
|
||||
|
||||
if ! _is_sourced; then
|
||||
main "$@"
|
||||
fi
|
||||
@@ -1,445 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# doctor.bats
|
||||
# Tests for diagnostic helpers in scripts/doctor.sh
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
export HOME="$TEST_TMP/home"
|
||||
export XDG_CACHE_HOME="$TEST_TMP/cache"
|
||||
export XDG_CONFIG_HOME="$TEST_TMP/config"
|
||||
mkdir -p "$HOME" "$XDG_CACHE_HOME" "$XDG_CONFIG_HOME"
|
||||
|
||||
# Clear all input/display vars to avoid host-state leakage
|
||||
unset DISPLAY
|
||||
unset WAYLAND_DISPLAY
|
||||
unset XDG_SESSION_TYPE
|
||||
unset CLAUDE_USE_WAYLAND
|
||||
unset GTK_IM_MODULE
|
||||
unset CLAUDE_GTK_IM_MODULE
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
|
||||
# shellcheck source=scripts/doctor.sh
|
||||
source "$SCRIPT_DIR/../scripts/doctor.sh"
|
||||
|
||||
_doctor_colors
|
||||
_doctor_failures=0
|
||||
|
||||
# Default _pkg_installed to "unknown" (rc=2) so tests don't have
|
||||
# to stub it unless they're exercising the package-check branch.
|
||||
# Override in-test for rc=0 (installed) or rc=1 (missing).
|
||||
_pkg_installed() { return 2; }
|
||||
|
||||
# Default stub for _detect_password_store (defined in
|
||||
# launcher-common.sh, not sourced here). Tests that exercise
|
||||
# _doctor_check_password_store override this in-test if needed.
|
||||
_detect_password_store() { echo 'basic'; }
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
# Make `command -v gtk-query-immodules-3.0` report "not found" so the
|
||||
# immodules cache check is skipped. Used by tests that aren't
|
||||
# exercising the cache branch but reach it because no earlier gate
|
||||
# fires. `command -v` finds bash functions too, so just unsetting a
|
||||
# stub function isn't enough — we shadow `command` itself.
|
||||
_skip_gtk_query() {
|
||||
command() {
|
||||
if [[ $1 == '-v' && $2 == 'gtk-query-immodules-3.0' ]]; then
|
||||
return 1
|
||||
fi
|
||||
builtin command "$@"
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _cowork_pkg_hint: ibus-gtk3 mapping (#550)
|
||||
# =============================================================================
|
||||
|
||||
@test "_cowork_pkg_hint: debian maps ibus-gtk3 to ibus-gtk3 via apt" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint debian ibus-gtk3)
|
||||
[[ $result == "sudo apt install ibus-gtk3" ]]
|
||||
}
|
||||
|
||||
@test "_cowork_pkg_hint: fedora maps ibus-gtk3 to ibus-gtk3 via dnf" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint fedora ibus-gtk3)
|
||||
[[ $result == "sudo dnf install ibus-gtk3" ]]
|
||||
}
|
||||
|
||||
@test "_cowork_pkg_hint: arch maps ibus-gtk3 to ibus (bundled)" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint arch ibus-gtk3)
|
||||
[[ $result == "sudo pacman -S ibus" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: CLAUDE_GTK_IM_MODULE override visibility
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: emits override line when CLAUDE_GTK_IM_MODULE set" {
|
||||
# CLAUDE_GTK_IM_MODULE makes active_im non-empty, so we'd reach
|
||||
# the cache check — skip it to keep this test focused.
|
||||
_skip_gtk_query
|
||||
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'CLAUDE_GTK_IM_MODULE=xim'* ]]
|
||||
[[ $output == *'overrides GTK_IM_MODULE for Electron'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no override line when CLAUDE_GTK_IM_MODULE unset" {
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'CLAUDE_GTK_IM_MODULE'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: XWayland-with-IBus routing note
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: emits XWayland note when wayland session and CLAUDE_USE_WAYLAND unset" {
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
# CLAUDE_USE_WAYLAND deliberately unset
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'XWayland'* ]]
|
||||
[[ $output == *'CLAUDE_USE_WAYLAND=1'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no XWayland note when CLAUDE_USE_WAYLAND=1" {
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
CLAUDE_USE_WAYLAND='1'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'XWayland'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no XWayland note on X11 session" {
|
||||
XDG_SESSION_TYPE='x11'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'XWayland'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: ibus-gtk3 package check
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: warns when ibus selected but ibus-gtk3 missing" {
|
||||
# Package not installed (rc=1, definitive answer)
|
||||
_pkg_installed() { return 1; }
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'ibus-gtk3 is not installed'* ]]
|
||||
[[ $output == *'sudo apt install ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no warning when ibus selected and ibus-gtk3 present" {
|
||||
# Package installed (rc=0); cache lists ibus.
|
||||
_pkg_installed() { return 0; }
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"ibus" "IBus" "ibus" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no package warning when active module isn't ibus" {
|
||||
# Even with rc=1 for ibus-gtk3, the package check should be
|
||||
# skipped entirely when GTK_IM_MODULE isn't ibus.
|
||||
_pkg_installed() { return 1; }
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no package warning on unsupported distro (rc=2)" {
|
||||
# Default _pkg_installed (rc=2) — no warning even with ibus.
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules unknown
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: immodules cache check
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: warns when GTK_IM_MODULE not in immodules cache" {
|
||||
# gtk-query-immodules-3.0 lists xim but not fcitx
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='fcitx'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *"'fcitx' not listed"* ]]
|
||||
[[ $output == *'gtk-query-immodules-3.0 --update-cache'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no warning when active module is in cache" {
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: skips cache check when gtk-query-immodules-3.0 missing" {
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='fcitx'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output != *'cache may be stale'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: CLAUDE_GTK_IM_MODULE takes precedence as active module" {
|
||||
# Cache lists xim but not ibus. CLAUDE_GTK_IM_MODULE=xim should
|
||||
# win over GTK_IM_MODULE=ibus, so no cache warning fires.
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no checks fire when no IM module selected" {
|
||||
# Neither GTK_IM_MODULE nor CLAUDE_GTK_IM_MODULE set — function
|
||||
# should return early before the package or cache checks.
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output != *'ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_recent_crashes: GPU FATAL crash counter (#583)
|
||||
# =============================================================================
|
||||
|
||||
# Install a coredumpctl shim. $1 is the coredumpctl-list-style
|
||||
# multi-line output to emit (header + entry rows). The shim ignores
|
||||
# its arguments — tests don't exercise the filter syntax.
|
||||
_install_coredumpctl_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
cat > "$TEST_TMP/bin/coredumpctl" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
cat <<'OUT'
|
||||
$1
|
||||
OUT
|
||||
SHIM
|
||||
chmod +x "$TEST_TMP/bin/coredumpctl"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: no coredumpctl on PATH — silent" {
|
||||
# Force coredumpctl off PATH so the helper short-circuits.
|
||||
# Restore PATH before returning so teardown's rm works.
|
||||
local saved_path="$PATH"
|
||||
export PATH="/no-such-dir-for-test"
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
export PATH="$saved_path"
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: zero crashes — silent" {
|
||||
# Listing has the header line only, no entry rows.
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: 1 crash — info line, no warn" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Recent Electron crashes: 1'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: 3+ crashes — warn + #583 pointer" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M
|
||||
Mon 2026-05-04 07:44:48 EDT 930532 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 22.8M
|
||||
Sun 2026-05-03 14:34:10 EDT 567221 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 12.4M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'Recent Electron crashes: 3'* ]]
|
||||
[[ $output == *'CLAUDE_DISABLE_GPU=1'* ]]
|
||||
[[ $output == *'/issues/583'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: path mismatch falls back with footnote" {
|
||||
# Three crashes from a DIFFERENT electron binary (e.g., Slack).
|
||||
# Caller passes claude-desktop's electron path, which doesn't
|
||||
# match — helper falls back to total count and adds the footnote
|
||||
# so the user knows the count may be cross-app.
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 09:00:00 EDT 200001 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
|
||||
Wed 2026-05-05 09:00:00 EDT 200002 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
|
||||
Wed 2026-05-04 09:00:00 EDT 200003 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'may be from other Electron apps'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: empty electron_path falls back" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
|
||||
# Caller didn't pass an electron_path — helper still counts and
|
||||
# emits the info line based on the unfiltered total.
|
||||
run _doctor_check_recent_crashes ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Recent Electron crashes: 1'* ]]
|
||||
[[ $output == *'may be from other Electron apps'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_filename_limit: NAME_MAX probe + eCryptfs hint (#590)
|
||||
# =============================================================================
|
||||
|
||||
# Install a getconf shim that emits $1 on stdout. Empty $1 → shim exits 1
|
||||
# so callers can test the "getconf failed" path.
|
||||
_install_getconf_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
local value="$1"
|
||||
if [[ -z $value ]]; then
|
||||
cat > "$TEST_TMP/bin/getconf" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
exit 1
|
||||
SHIM
|
||||
else
|
||||
cat > "$TEST_TMP/bin/getconf" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
echo ${value}
|
||||
SHIM
|
||||
fi
|
||||
chmod +x "$TEST_TMP/bin/getconf"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
# Install a df shim that emits a single-column fstype listing matching
|
||||
# the `df --output=fstype` shape the helper relies on. Empty $1 → shim
|
||||
# exits 1 so callers can test the "df failed" path.
|
||||
_install_df_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
local fstype="$1"
|
||||
if [[ -z $fstype ]]; then
|
||||
cat > "$TEST_TMP/bin/df" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
exit 1
|
||||
SHIM
|
||||
else
|
||||
cat > "$TEST_TMP/bin/df" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
cat <<'OUT'
|
||||
Type
|
||||
${fstype}
|
||||
OUT
|
||||
SHIM
|
||||
fi
|
||||
chmod +x "$TEST_TMP/bin/df"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent when NAME_MAX >= 200" {
|
||||
_install_getconf_shim '255'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: warns when NAME_MAX < 200" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim 'ext4'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output == *'#590'* ]]
|
||||
# Non-ecryptfs fs: no LUKS hint
|
||||
[[ $output != *'eCryptfs'* ]]
|
||||
[[ $output != *'LUKS'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: eCryptfs adds LUKS workaround hint" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim 'ecryptfs'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output == *'eCryptfs'* ]]
|
||||
[[ $output == *'LUKS'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent on non-numeric getconf output" {
|
||||
_install_getconf_shim 'undefined'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent when getconf fails" {
|
||||
_install_getconf_shim ''
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: df failure suppresses eCryptfs hint, keeps warn" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim ''
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output != *'eCryptfs'* ]]
|
||||
[[ $output != *'LUKS'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_password_store
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_password_store: output contains 'Password store:' with a valid backend" {
|
||||
# setup() already stubs _detect_password_store to return 'basic'.
|
||||
run _doctor_check_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Password store:'* ]]
|
||||
[[ $output == *'basic'* ]]
|
||||
}
|
||||
@@ -18,35 +18,6 @@ has_electron_arg() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Install a dbus-send stub at the front of PATH.
|
||||
# kwallet6 — echoes 'boolean true', exits 0 (kwallet6 detectable)
|
||||
# secrets-ok — fails for kwalletd6 dest, succeeds for all other dests
|
||||
# fail — always exits 1 with no output (no keyring accessible)
|
||||
_stub_dbus_send() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
case "${1:-fail}" in
|
||||
kwallet6)
|
||||
cat > "$TEST_TMP/bin/dbus-send" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
echo 'boolean true'
|
||||
STUB
|
||||
;;
|
||||
secrets-ok)
|
||||
cat > "$TEST_TMP/bin/dbus-send" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
[[ "$*" == *kwalletd6* ]] && exit 1
|
||||
exit 0
|
||||
STUB
|
||||
;;
|
||||
*)
|
||||
printf '#!/usr/bin/env bash\nexit 1\n' \
|
||||
> "$TEST_TMP/bin/dbus-send"
|
||||
;;
|
||||
esac
|
||||
chmod +x "$TEST_TMP/bin/dbus-send"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
@@ -64,17 +35,10 @@ setup() {
|
||||
unset CLAUDE_USE_WAYLAND
|
||||
unset NIRI_SOCKET
|
||||
unset XDG_CURRENT_DESKTOP
|
||||
unset XDG_SESSION_TYPE
|
||||
unset CLAUDE_MENU_BAR
|
||||
unset CLAUDE_TITLEBAR_STYLE
|
||||
unset COWORK_VM_BACKEND
|
||||
unset ELECTRON_USE_SYSTEM_TITLE_BAR
|
||||
unset GTK_IM_MODULE
|
||||
unset XMODIFIERS
|
||||
unset QT_IM_MODULE
|
||||
unset CLAUDE_GTK_IM_MODULE
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
|
||||
# shellcheck source=scripts/launcher-common.sh
|
||||
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
|
||||
@@ -122,70 +86,6 @@ teardown() {
|
||||
[[ "${lines[1]}" == "test message two" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# log_session_env
|
||||
# =============================================================================
|
||||
|
||||
@test "log_session_env: emits env={ ... } block with all required keys" {
|
||||
setup_logging
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
WAYLAND_DISPLAY='wayland-0'
|
||||
DISPLAY=':0'
|
||||
XDG_CURRENT_DESKTOP='KDE'
|
||||
GTK_IM_MODULE='ibus'
|
||||
XMODIFIERS='@im=ibus'
|
||||
QT_IM_MODULE='ibus'
|
||||
CLAUDE_USE_WAYLAND='1'
|
||||
CLAUDE_TITLEBAR_STYLE='hybrid'
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
CLAUDE_DISABLE_GPU='1'
|
||||
log_session_env
|
||||
|
||||
run cat "$log_file"
|
||||
# Exact-line match locks block structure (open/close braces on
|
||||
# their own lines) and per-key formatting in one pass.
|
||||
[[ "${lines[0]}" == 'env={' ]]
|
||||
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=wayland' ]]
|
||||
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=wayland-0' ]]
|
||||
[[ "${lines[3]}" == ' DISPLAY=:0' ]]
|
||||
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=KDE' ]]
|
||||
[[ "${lines[5]}" == ' GTK_IM_MODULE=ibus' ]]
|
||||
[[ "${lines[6]}" == ' XMODIFIERS=@im=ibus' ]]
|
||||
[[ "${lines[7]}" == ' QT_IM_MODULE=ibus' ]]
|
||||
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=1' ]]
|
||||
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=hybrid' ]]
|
||||
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=basic' ]]
|
||||
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=xim' ]]
|
||||
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=1' ]]
|
||||
[[ "${lines[13]}" == '}' ]]
|
||||
}
|
||||
|
||||
@test "log_session_env: unset/empty values render as 'KEY=' (no value)" {
|
||||
setup_logging
|
||||
# All vars unset by setup() except this one, which exercises the
|
||||
# empty-string branch (must be indistinguishable from unset).
|
||||
GTK_IM_MODULE=''
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
log_session_env
|
||||
|
||||
run cat "$log_file"
|
||||
# Exact-line match proves the line ends right after '=' — a
|
||||
# substring like *'KEY='* would also match 'KEY=value'.
|
||||
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=' ]]
|
||||
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=' ]]
|
||||
[[ "${lines[3]}" == ' DISPLAY=' ]]
|
||||
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=' ]]
|
||||
[[ "${lines[5]}" == ' GTK_IM_MODULE=' ]]
|
||||
[[ "${lines[6]}" == ' XMODIFIERS=' ]]
|
||||
[[ "${lines[7]}" == ' QT_IM_MODULE=' ]]
|
||||
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=' ]]
|
||||
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=' ]]
|
||||
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=' ]]
|
||||
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=' ]]
|
||||
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=' ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# check_display
|
||||
# =============================================================================
|
||||
@@ -393,48 +293,6 @@ teardown() {
|
||||
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set propagates to GTK_IM_MODULE" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'xim' ]]
|
||||
# Override is logged so users can verify it took effect
|
||||
run cat "$log_file"
|
||||
[[ $output == *'GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set logs <unset> when GTK_IM_MODULE was unset" {
|
||||
setup_logging
|
||||
# GTK_IM_MODULE unset by setup()
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'xim' ]]
|
||||
run cat "$log_file"
|
||||
[[ $output == *'GTK_IM_MODULE override: <unset> -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE unset leaves GTK_IM_MODULE alone" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
# CLAUDE_GTK_IM_MODULE unset by setup()
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'ibus' ]]
|
||||
# No override line should appear in the log
|
||||
run cat "$log_file"
|
||||
[[ $output != *'GTK_IM_MODULE override'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE empty leaves GTK_IM_MODULE alone" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE=''
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'ibus' ]]
|
||||
run cat "$log_file"
|
||||
[[ $output != *'GTK_IM_MODULE override'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _resolve_titlebar_style
|
||||
# =============================================================================
|
||||
@@ -729,40 +587,3 @@ s.close()
|
||||
result=$(_electron_version "$TEST_TMP/electron/electron") || true
|
||||
[[ -z $result ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _detect_password_store
|
||||
# =============================================================================
|
||||
|
||||
@test "_detect_password_store: CLAUDE_PASSWORD_STORE env var wins without calling dbus-send" {
|
||||
CLAUDE_PASSWORD_STORE='mystore'
|
||||
# Stub dbus-send to fail — the early-return path must not reach it.
|
||||
_stub_dbus_send fail
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'mystore' ]]
|
||||
}
|
||||
|
||||
@test "_detect_password_store: falls back to kwallet6 when kwallet6 dbus-send call succeeds" {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
_stub_dbus_send kwallet6
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'kwallet6' ]]
|
||||
}
|
||||
|
||||
@test "_detect_password_store: falls back to gnome-libsecret when kwallet6 fails but secrets ping succeeds" {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
_stub_dbus_send secrets-ok
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'gnome-libsecret' ]]
|
||||
}
|
||||
|
||||
@test "_detect_password_store: falls back to basic when both dbus-send calls fail" {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
_stub_dbus_send fail
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'basic' ]]
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# launcher-disable-gpu.bats
|
||||
# Tests for the CLAUDE_DISABLE_GPU env var handling in
|
||||
# build_electron_args (scripts/launcher-common.sh). The var is an
|
||||
# opt-in workaround for the Chromium GPU process FATAL exhaustion
|
||||
# tracked in #583. CLAUDE_DISABLE_GPU=1 adds --disable-gpu and
|
||||
# --disable-software-rasterizer; co-occurrence with XRDP must not
|
||||
# stack duplicate flags.
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
LAUNCHER_COMMON="${SCRIPT_DIR}/../scripts/launcher-common.sh"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
# loginctl shim — same pattern as launcher-xrdp-detection.bats.
|
||||
# Defaults to a non-XRDP session so CLAUDE_DISABLE_GPU is the
|
||||
# only signal in play unless a test overrides MOCK_LOGINCTL_TYPE.
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
cat > "$TEST_TMP/bin/loginctl" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "${MOCK_LOGINCTL_TYPE:-x11}"
|
||||
SHIM
|
||||
chmod +x "$TEST_TMP/bin/loginctl"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
|
||||
log_file="$TEST_TMP/launcher.log"
|
||||
: > "$log_file"
|
||||
|
||||
unset CLAUDE_DISABLE_GPU
|
||||
unset XRDP_SESSION
|
||||
unset XDG_SESSION_ID
|
||||
unset MOCK_LOGINCTL_TYPE
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$LAUNCHER_COMMON"
|
||||
|
||||
is_wayland=false
|
||||
use_x11_on_wayland=true
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n ${TEST_TMP:-} && -d $TEST_TMP ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
args_contain() {
|
||||
local needle="$1"
|
||||
local arg
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == "$needle" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
args_count() {
|
||||
local needle="$1"
|
||||
local arg count=0
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == "$needle" ]] && ((count++))
|
||||
done
|
||||
printf '%d' "$count"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# CLAUDE_DISABLE_GPU=1 — flags must be added
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: CLAUDE_DISABLE_GPU=1 adds flags + logs message" {
|
||||
export CLAUDE_DISABLE_GPU=1
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Co-occurrence with XRDP — no duplicate flags
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: with XRDP_SESSION, flags added exactly once (no dup)" {
|
||||
export CLAUDE_DISABLE_GPU=1
|
||||
export XRDP_SESSION=1
|
||||
export XDG_SESSION_ID=5
|
||||
export MOCK_LOGINCTL_TYPE=xrdp
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
[[ "$(args_count '--disable-gpu')" -eq 1 ]]
|
||||
[[ "$(args_count '--disable-software-rasterizer')" -eq 1 ]]
|
||||
# Both signals should still log (independent diagnostic value),
|
||||
# but only one set of flags should reach electron_args.
|
||||
grep -q 'XRDP session detected' "$log_file"
|
||||
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Off-states — flags must NOT be added
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: unset — flags NOT added" {
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
run args_contain '--disable-software-rasterizer'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: empty string — flags NOT added" {
|
||||
export CLAUDE_DISABLE_GPU=''
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: =0 — flags NOT added (only literal '1' opts in)" {
|
||||
export CLAUDE_DISABLE_GPU=0
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: =true — flags NOT added (no boolean aliases)" {
|
||||
# Documents the strict equality check. If we ever add aliases,
|
||||
# update this test to match. Strict-only matches the existing
|
||||
# CLAUDE_USE_WAYLAND pattern.
|
||||
export CLAUDE_DISABLE_GPU=true
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
87
tests/test-artifact-appimage.sh
Executable file → Normal file
87
tests/test-artifact-appimage.sh
Executable file → Normal file
@@ -94,92 +94,7 @@ assert_contains "$appdir/AppRun" 'build_electron_args' \
|
||||
|
||||
# --- App contents (asar) ---
|
||||
resources_dir="$appdir/usr/lib/node_modules/electron/dist/resources"
|
||||
validate_app_contents "$resources_dir" "${component_id}.desktop"
|
||||
|
||||
# --- Doctor smoke test ---
|
||||
# Some --doctor checks fail in CI (no display, etc.); we only care that
|
||||
# the script itself didn't crash via signal or exec failure (>=127).
|
||||
doctor_exit=0
|
||||
"$appimage_file" --doctor >/dev/null 2>&1 || doctor_exit=$?
|
||||
if [[ $doctor_exit -lt 127 ]]; then
|
||||
pass "--doctor runs without crashing (exit: $doctor_exit)"
|
||||
else
|
||||
fail "--doctor crashed (exit: $doctor_exit)"
|
||||
fi
|
||||
|
||||
# --- Headless launch smoke test ---
|
||||
# Catches startup-only regressions (asar/frame-fix-wrapper syntax errors)
|
||||
# that pure structure checks miss.
|
||||
#
|
||||
# Scope: main-process startup failures only. GPU/renderer-process
|
||||
# crashes (e.g. #583-class) leave the main process alive and pass
|
||||
# this check — Xvfb has no GPU, so Electron falls back to SwiftShader
|
||||
# and the GPU-crash path isn't exercised here.
|
||||
if command -v xvfb-run &>/dev/null \
|
||||
&& command -v dbus-run-session &>/dev/null \
|
||||
&& command -v setsid &>/dev/null; then
|
||||
|
||||
# XDG_CACHE_HOME redirect so the test owns the launcher log.
|
||||
cache_root=$(mktemp -d)
|
||||
export XDG_CACHE_HOME="$cache_root"
|
||||
launcher_log="$cache_root/claude-desktop-debian/launcher.log"
|
||||
|
||||
# setsid puts xvfb-run + Xvfb + dbus + AppRun + electron in a fresh
|
||||
# process group; xvfb-run's EXIT trap alone leaves Xvfb behind on
|
||||
# TERM, so we need kill -- -PGID below.
|
||||
# AppRun redirects electron's stdout/stderr into launcher_log;
|
||||
# xvfb_log captures xvfb-run's own stderr.
|
||||
xvfb_log=$(mktemp)
|
||||
setsid xvfb-run -a -s '-screen 0 1280x720x24' \
|
||||
dbus-run-session -- "$appimage_file" \
|
||||
>"$xvfb_log" 2>&1 &
|
||||
launch_pid=$!
|
||||
|
||||
# Safety net: covers Ctrl-C, CI timeout, or any earlier `exit` so we
|
||||
# never leak Xvfb/electron between launch and the explicit kill below.
|
||||
trap '
|
||||
kill -KILL -- "-$launch_pid" 2>/dev/null
|
||||
pkill -KILL -f "$appimage_file" 2>/dev/null
|
||||
rm -rf "$cache_root" "$xvfb_log"
|
||||
' EXIT INT TERM
|
||||
|
||||
# CI is slow; 10s is the floor for Electron startup.
|
||||
sleep 10
|
||||
|
||||
if kill -0 "$launch_pid" 2>/dev/null; then
|
||||
pass "AppImage stays alive under Xvfb for 10s"
|
||||
else
|
||||
wait "$launch_pid" 2>/dev/null
|
||||
exit_code=$?
|
||||
fail "AppImage exited within 10s (exit: $exit_code)"
|
||||
if [[ -f $launcher_log ]]; then
|
||||
echo '--- launcher.log (last 40 lines) ---' >&2
|
||||
tail -40 "$launcher_log" >&2
|
||||
echo '------------------------------------' >&2
|
||||
fi
|
||||
if [[ -s $xvfb_log ]]; then
|
||||
echo '--- xvfb-run stderr (last 20 lines) ---' >&2
|
||||
tail -20 "$xvfb_log" >&2
|
||||
echo '---------------------------------------' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Negative PID targets the process group.
|
||||
kill -TERM -- "-$launch_pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -KILL -- "-$launch_pid" 2>/dev/null || true
|
||||
wait "$launch_pid" 2>/dev/null || true
|
||||
# Sweep any electron child that escaped the group (e.g. zygote).
|
||||
pkill -KILL -f "$appimage_file" 2>/dev/null || true
|
||||
|
||||
rm -rf "$cache_root" "$xvfb_log"
|
||||
unset XDG_CACHE_HOME
|
||||
else
|
||||
# Match the codebase convention (test-artifact-common.sh
|
||||
# validate_app_contents): tool absence is a skip, not a failure.
|
||||
# Loud failure on missing tools belongs at the workflow layer.
|
||||
pass "Skipping launch smoke test (xvfb-run/dbus-run-session/setsid missing)"
|
||||
fi
|
||||
validate_app_contents "$resources_dir"
|
||||
|
||||
# --- Cleanup ---
|
||||
rm -rf "$extract_dir"
|
||||
|
||||
@@ -38,14 +38,6 @@ assert_executable() {
|
||||
fi
|
||||
}
|
||||
|
||||
assert_setuid() {
|
||||
if [[ -u $1 ]]; then
|
||||
pass "Setuid bit set: $1"
|
||||
else
|
||||
fail "Setuid bit not set: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file="$1" pattern="$2" desc="${3:-}"
|
||||
if grep -q "$pattern" "$file" 2>/dev/null; then
|
||||
@@ -67,10 +59,8 @@ assert_command_succeeds() {
|
||||
|
||||
# Validate app contents inside an Electron resources directory.
|
||||
# $1 = path to the resources/ dir containing app.asar
|
||||
# $2 = expected desktopName in app/package.json
|
||||
validate_app_contents() {
|
||||
local resources_dir="$1"
|
||||
local expected_desktop_name="${2:-claude-desktop.desktop}"
|
||||
|
||||
assert_file_exists "$resources_dir/app.asar"
|
||||
assert_dir_exists "$resources_dir/app.asar.unpacked"
|
||||
@@ -105,11 +95,6 @@ validate_app_contents() {
|
||||
'frame-fix-entry.js' \
|
||||
"package.json main field references frame-fix-entry.js"
|
||||
|
||||
# package.json desktopName matches the installed desktop file
|
||||
assert_contains "$extract_dir/app/package.json" \
|
||||
"\"desktopName\": \"$expected_desktop_name\"" \
|
||||
"package.json desktopName matches $expected_desktop_name"
|
||||
|
||||
# .vite/build/index.js exists (main process code)
|
||||
assert_file_exists "$extract_dir/app/.vite/build/index.js"
|
||||
|
||||
|
||||
@@ -41,14 +41,9 @@ electron_path='/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
assert_file_exists "$electron_path"
|
||||
assert_executable "$electron_path"
|
||||
|
||||
# chrome-sandbox: setuid bit must be set by the rpm spec's %files
|
||||
# %attr(4755, ...) entry, not by a %post chmod (#539). The check
|
||||
# guards against any regression that strips the suid bit — including
|
||||
# (but not limited to) reverting to a %post chmod, which silently
|
||||
# no-ops if the scriptlet is skipped (--noscripts, layered images).
|
||||
chrome_sandbox='/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
|
||||
assert_file_exists "$chrome_sandbox"
|
||||
assert_setuid "$chrome_sandbox"
|
||||
# chrome-sandbox
|
||||
assert_file_exists \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
|
||||
|
||||
# --- Desktop entry validation ---
|
||||
desktop_file='/usr/share/applications/claude-desktop.desktop'
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# verify-patches.bats
|
||||
# Tests for scripts/verify-patches.sh — the post-build static grep
|
||||
# that confirms patch markers (default: cowork, issue #559 D6 / PR
|
||||
# #555) are present in the shipped index.js.
|
||||
#
|
||||
# Both these tests and the verify script consume the marker list from
|
||||
# scripts/cowork-patch-markers.tsv, so adding a marker there
|
||||
# automatically expands the test matrix below.
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
VERIFY_SH="$SCRIPT_DIR/../scripts/verify-patches.sh"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
# Source the verify script in library mode and reuse its
|
||||
# parser, so a TSV format change can't desync the two consumers.
|
||||
# shellcheck source-path=SCRIPTDIR/.. source=scripts/verify-patches.sh
|
||||
source "$VERIFY_SH"
|
||||
load_markers
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n "${TEST_TMP:-}" && -d "$TEST_TMP" ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
# Build a fixture index.js containing every sample. If $1 is given,
|
||||
# the marker with that name is omitted (used to drive the missing-
|
||||
# marker negative tests).
|
||||
write_fixture() {
|
||||
local omit="${1:-}"
|
||||
local fixture="$TEST_TMP/index.js"
|
||||
: > "$fixture"
|
||||
local i
|
||||
for i in "${!marker_names[@]}"; do
|
||||
if [[ ${marker_names[$i]} != "$omit" ]]; then
|
||||
printf '%s\n' "${marker_samples[$i]}" >> "$fixture"
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "$fixture"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Marker file integrity
|
||||
# =============================================================================
|
||||
|
||||
@test "markers file: every regex matches its sample" {
|
||||
local i
|
||||
for i in "${!marker_names[@]}"; do
|
||||
run grep -qP -- "${marker_patterns[$i]}" \
|
||||
<(printf '%s\n' "${marker_samples[$i]}")
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo "regex did not match own sample: ${marker_names[$i]}"
|
||||
echo "pattern: ${marker_patterns[$i]}"
|
||||
echo "sample: ${marker_samples[$i]}"
|
||||
return 1
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
@test "markers file: at least 9 markers loaded" {
|
||||
[[ "${#marker_names[@]}" -ge 9 ]] || {
|
||||
echo "expected >= 9 markers, got ${#marker_names[@]}"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Positive path: full fixture passes
|
||||
# =============================================================================
|
||||
|
||||
@test "verify: exits 0 when every marker present" {
|
||||
local fixture
|
||||
fixture="$(write_fixture)"
|
||||
|
||||
run "$VERIFY_SH" "$fixture"
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo 'verify rejected a fully-marked fixture'
|
||||
echo "$output"
|
||||
return 1
|
||||
}
|
||||
|
||||
run grep -c 'OK ' <<< "$output"
|
||||
[[ "$output" -eq "${#marker_names[@]}" ]] || {
|
||||
echo "expected ${#marker_names[@]} OK lines, got: $output"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Negative path: per-marker missing fixture
|
||||
# =============================================================================
|
||||
|
||||
@test "verify: exits 2 and names the missing marker (each)" {
|
||||
local name fixture failures=0
|
||||
for name in "${marker_names[@]}"; do
|
||||
fixture="$(write_fixture "$name")"
|
||||
|
||||
run "$VERIFY_SH" "$fixture"
|
||||
if [[ "$status" -ne 2 ]]; then
|
||||
echo "missing $name should exit 2, got $status"
|
||||
echo "$output"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if ! grep -q "$name" <<< "$output"; then
|
||||
echo "missing $name not named in output"
|
||||
echo "$output"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done
|
||||
[[ "$failures" -eq 0 ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Input shapes
|
||||
# =============================================================================
|
||||
|
||||
@test "verify: accepts a directory containing the asar layout" {
|
||||
local layout="$TEST_TMP/staging/app.asar.contents/.vite/build"
|
||||
mkdir -p "$layout"
|
||||
: > "$layout/index.js"
|
||||
local sample
|
||||
for sample in "${marker_samples[@]}"; do
|
||||
printf '%s\n' "$sample" >> "$layout/index.js"
|
||||
done
|
||||
|
||||
run "$VERIFY_SH" "$TEST_TMP/staging"
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo 'verify rejected directory-shaped input'
|
||||
echo "$output"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "verify: rejects missing path with exit 1" {
|
||||
run "$VERIFY_SH" "$TEST_TMP/does-not-exist.js"
|
||||
[[ "$status" -eq 1 ]]
|
||||
[[ "$output" == *'not found'* ]]
|
||||
}
|
||||
|
||||
@test "verify: rejects directory without expected layout" {
|
||||
mkdir -p "$TEST_TMP/empty"
|
||||
run "$VERIFY_SH" "$TEST_TMP/empty"
|
||||
[[ "$status" -eq 1 ]]
|
||||
}
|
||||
|
||||
@test "verify: prints usage on no args and exits 1" {
|
||||
run "$VERIFY_SH"
|
||||
[[ "$status" -eq 1 ]]
|
||||
[[ "$output" == *'Usage:'* ]]
|
||||
}
|
||||
|
||||
@test "verify: --help prints usage and exits 0" {
|
||||
run "$VERIFY_SH" --help
|
||||
[[ "$status" -eq 0 ]]
|
||||
[[ "$output" == *'Usage:'* ]]
|
||||
}
|
||||
@@ -8,7 +8,10 @@ architecture, decisions, and rationale.
|
||||
## Status
|
||||
|
||||
Seventy-four specs wired (36 cross-env T-tests, 33 env-specific S-tests,
|
||||
5 H-prefix harness self-tests).
|
||||
5 H-prefix harness self-tests). See
|
||||
[`docs/testing/runner-implementation-plan.md`](../../docs/testing/runner-implementation-plan.md)
|
||||
for the tiered triage of remaining tests and the per-spec rationale
|
||||
behind tier classification.
|
||||
|
||||
| Test | What it checks | Layer |
|
||||
|------|----------------|-------|
|
||||
@@ -190,12 +193,15 @@ demonstrates the schema-rev path: when invocation rejects with
|
||||
the verbatim rejection string is the cheapest grep target back to
|
||||
the inline hand-rolled validator block (bundle bytes 5013601 /
|
||||
5018821 for the two CustomPlugins methods). See `lib/eipc.ts` for
|
||||
both surfaces.
|
||||
both surfaces, and
|
||||
[`runner-implementation-plan.md`](../../docs/testing/runner-implementation-plan.md)
|
||||
session 7 / 8 / 9 / 10 status sections for the findings.
|
||||
|
||||
Per-row pass/skip counts depend on which sweep runs against the row.
|
||||
The Quick Entry runners (S29-S35) all share the same primitive set
|
||||
(`installInterceptor()` + `openAndWaitReady()` + scenario-specific
|
||||
state setup).
|
||||
Per-row pass/skip counts depend on which sweep runs against the row;
|
||||
see `runner-implementation-plan.md` for tier classification and
|
||||
matrix-regen for the most-recent per-row outcomes. The Quick Entry
|
||||
runners (S29-S35) all share the same primitive set (`installInterceptor()`
|
||||
+ `openAndWaitReady()` + scenario-specific state setup).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -435,7 +441,7 @@ is a snapshot of what's currently on screen.
|
||||
- **T04** uses `xprop` (no `xdotool` dependency — walks `_NET_CLIENT_LIST` + `_NET_WM_PID`). Works on X11 native and KDE Wayland (XWayland), **not** on native-Wayland sessions where the app is running through Ozone-Wayland directly. Per Decision 6, project default is X11; native-Wayland window-state queries are deferred until those tests get added.
|
||||
- **T17** is shallow — it intercepts `dialog.showOpenDialog` at the Electron main process level. The integration question "does Claude make the right *portal* call?" is a v2 concern; portal-level mocking via `dbus-next` is sketched in [`docs/testing/automation.md`](../../docs/testing/automation.md) but requires displacing the running portal service or running under `dbus-run-session`.
|
||||
- **`render-matrix.sh`** isn't here yet. `sweep.sh` prints a summary; the `matrix.md` regen step from JUnit is the next addition.
|
||||
- **No CI wrapper.** Decision 4: the harness is invocable from CI but sweeps run from the dev box for the first ~20 tests.
|
||||
- **No CI wrapper.** Decision 4: the harness is invokable from CI but sweeps run from the dev box for the first ~20 tests.
|
||||
|
||||
## Adding a test
|
||||
|
||||
|
||||
280
tools/test-harness/explore/derive-vocabulary.ts
Normal file
280
tools/test-harness/explore/derive-vocabulary.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
// Derives the stable-UI vocabulary corpus from an existing inventory.
|
||||
// Output is committed at docs/testing/ui-vocabulary.json and consumed
|
||||
// by the v7 walker (Phase 2) when classifying captured accessible-
|
||||
// names. Re-run on each major upstream release.
|
||||
//
|
||||
// Rules (adapted from the v7 plan to the v6-collapsed inventory shape):
|
||||
// - Persistent entries collapse to one inventory entry with a
|
||||
// `surfaces[]` array recording every surface the element was
|
||||
// observed on. Any persistent label whose surfaces[] has length
|
||||
// >= 2 is stable by definition.
|
||||
// - Structural / menu entries: stable if the label is shared by 3+
|
||||
// entries OR appears on 2+ distinct surfaces. Either signal is
|
||||
// enough — the plan's strict 3-and-2 conjunction over-rejects
|
||||
// against a v6-collapsed inventory where most chrome already
|
||||
// deduped to one entry.
|
||||
// - Names matching any INSTANCE_SHAPES regex go to instanceShapes
|
||||
// and are excluded from stable / suspect even if they would have
|
||||
// qualified — the instance-shape pattern is the canonical
|
||||
// representation for those at resolve time.
|
||||
// - kind: instance entries are excluded from the stable corpus
|
||||
// entirely — those labels by definition vary per session. (A
|
||||
// label that appears in BOTH instance and structural entries
|
||||
// follows the structural / menu rule.)
|
||||
// - Everything else falls through to `suspect`, queued for human
|
||||
// reconciliation.
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { INSTANCE_SHAPES } from '../src/lib/name-classifier.js';
|
||||
import type { Inventory, InventoryEntry } from './walker.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const TESTING_DIR = resolve(HERE, '..', '..', '..', 'docs', 'testing');
|
||||
const DEFAULT_INVENTORY = resolve(TESTING_DIR, 'ui-inventory.json');
|
||||
const DEFAULT_OUTPUT = resolve(TESTING_DIR, 'ui-vocabulary.json');
|
||||
|
||||
interface CliOpts {
|
||||
inventory: string;
|
||||
output: string;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
interface InstanceShapeOutput {
|
||||
id: string;
|
||||
regex: string;
|
||||
flags: string;
|
||||
pattern: string | null;
|
||||
matchedNames: string[];
|
||||
}
|
||||
|
||||
interface VocabularyOutput {
|
||||
derivedAt: string;
|
||||
sourceInventory: {
|
||||
capturedAt: string;
|
||||
appVersion: string;
|
||||
walkerVersion: string;
|
||||
totalElements: number;
|
||||
};
|
||||
stable: string[];
|
||||
instanceShapes: InstanceShapeOutput[];
|
||||
suspect: string[];
|
||||
}
|
||||
|
||||
function parseCli(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = {
|
||||
inventory: DEFAULT_INVENTORY,
|
||||
output: DEFAULT_OUTPUT,
|
||||
help: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
switch (a) {
|
||||
case '-h':
|
||||
case '--help':
|
||||
opts.help = true;
|
||||
break;
|
||||
case '--inventory': {
|
||||
const v = argv[++i];
|
||||
if (!v) {
|
||||
process.stderr.write('--inventory requires a path\n');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.inventory = resolve(v);
|
||||
break;
|
||||
}
|
||||
case '--output': {
|
||||
const v = argv[++i];
|
||||
if (!v) {
|
||||
process.stderr.write('--output requires a path\n');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.output = resolve(v);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
process.stderr.write(
|
||||
`derive-vocabulary: unknown argument: ${a}\n`,
|
||||
);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
'Usage: tsx explore/derive-vocabulary.ts [options]\n' +
|
||||
'\n' +
|
||||
'Derives docs/testing/ui-vocabulary.json from an existing\n' +
|
||||
'inventory walk. Output records the stable-UI corpus, the\n' +
|
||||
'instance-shape registry hits, and any names flagged for\n' +
|
||||
'human triage.\n' +
|
||||
'\n' +
|
||||
'Options:\n' +
|
||||
' --inventory <path> Override default inventory path\n' +
|
||||
' (default: docs/testing/ui-inventory.json)\n' +
|
||||
' --output <path> Override default vocabulary output path\n' +
|
||||
' (default: docs/testing/ui-vocabulary.json)\n' +
|
||||
' -h, --help Print this help and exit\n',
|
||||
);
|
||||
}
|
||||
|
||||
function loadInventory(path: string): Inventory {
|
||||
if (!existsSync(path)) {
|
||||
process.stderr.write(
|
||||
`derive-vocabulary: inventory not found: ${path}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as Inventory;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`derive-vocabulary: failed to parse inventory: ${msg}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
interface LabelStats {
|
||||
kinds: Set<InventoryEntry['kind']>;
|
||||
surfaces: Set<string>;
|
||||
entryCount: number;
|
||||
maxPersistentSpan: number;
|
||||
}
|
||||
|
||||
function aggregate(inv: Inventory): Map<string, LabelStats> {
|
||||
const stats = new Map<string, LabelStats>();
|
||||
for (const e of inv.entries) {
|
||||
const lbl = e.label;
|
||||
if (!lbl) continue;
|
||||
let s = stats.get(lbl);
|
||||
if (!s) {
|
||||
s = {
|
||||
kinds: new Set(),
|
||||
surfaces: new Set(),
|
||||
entryCount: 0,
|
||||
maxPersistentSpan: 0,
|
||||
};
|
||||
stats.set(lbl, s);
|
||||
}
|
||||
s.kinds.add(e.kind);
|
||||
s.surfaces.add(e.surface);
|
||||
s.entryCount += 1;
|
||||
if (e.kind === 'persistent' && e.surfaces) {
|
||||
s.maxPersistentSpan = Math.max(
|
||||
s.maxPersistentSpan,
|
||||
e.surfaces.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function classify(inv: Inventory): VocabularyOutput {
|
||||
const stats = aggregate(inv);
|
||||
const stable = new Set<string>();
|
||||
const suspect = new Set<string>();
|
||||
const instanceHits = new Map<string, Set<string>>();
|
||||
for (const shape of INSTANCE_SHAPES) {
|
||||
instanceHits.set(shape.id, new Set());
|
||||
}
|
||||
|
||||
for (const [lbl, s] of stats) {
|
||||
// Pure-instance label — exclude entirely.
|
||||
if (s.kinds.size === 1 && s.kinds.has('instance')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Instance-shape regex match — record + skip stable/suspect.
|
||||
let shapeMatched = false;
|
||||
for (const shape of INSTANCE_SHAPES) {
|
||||
if (shape.regex.test(lbl)) {
|
||||
instanceHits.get(shape.id)!.add(lbl);
|
||||
shapeMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shapeMatched) continue;
|
||||
|
||||
// Persistent: surfaces[] >= 2 carries the proof that the chrome
|
||||
// element actually spans surfaces.
|
||||
if (s.maxPersistentSpan >= 2) {
|
||||
stable.add(lbl);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Structural / menu: 3+ entries OR 2+ distinct surfaces.
|
||||
if (s.entryCount >= 3 || s.surfaces.size >= 2) {
|
||||
stable.add(lbl);
|
||||
continue;
|
||||
}
|
||||
|
||||
suspect.add(lbl);
|
||||
}
|
||||
|
||||
const instanceShapesOut: InstanceShapeOutput[] = INSTANCE_SHAPES.map(
|
||||
(shape) => ({
|
||||
id: shape.id,
|
||||
regex: shape.regex.source,
|
||||
flags: shape.regex.flags,
|
||||
pattern: shape.pattern,
|
||||
matchedNames: [...instanceHits.get(shape.id)!].sort(),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
derivedAt: new Date().toISOString(),
|
||||
sourceInventory: {
|
||||
capturedAt: inv.capturedAt,
|
||||
appVersion: inv.appVersion,
|
||||
walkerVersion: inv.walkerVersion,
|
||||
totalElements: inv.totalElements,
|
||||
},
|
||||
stable: [...stable].sort(),
|
||||
instanceShapes: instanceShapesOut,
|
||||
suspect: [...suspect].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function atomicWrite(path: string, body: string): void {
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, body, 'utf8');
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
if (opts.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
const inv = loadInventory(opts.inventory);
|
||||
const out = classify(inv);
|
||||
const body = `${JSON.stringify(out, null, 2)}\n`;
|
||||
atomicWrite(opts.output, body);
|
||||
|
||||
const shapeHitTotal = out.instanceShapes.reduce(
|
||||
(n, s) => n + s.matchedNames.length,
|
||||
0,
|
||||
);
|
||||
process.stdout.write(
|
||||
`derive-vocabulary: wrote ${opts.output}\n` +
|
||||
` source: ${opts.inventory} (${inv.totalElements} entries)\n` +
|
||||
` stable: ${out.stable.length}, ` +
|
||||
`instance-shaped: ${shapeHitTotal} (${out.instanceShapes.filter((s) => s.matchedNames.length > 0).length} shapes hit), ` +
|
||||
`suspect: ${out.suspect.length}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
313
tools/test-harness/explore/diff.ts
Normal file
313
tools/test-harness/explore/diff.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
// Snapshot comparator.
|
||||
//
|
||||
// Diff semantics, in priority order:
|
||||
// - removed: an element keyed in A is absent from B → drift signal.
|
||||
// - changed: same key, different visible text or aria-label → drift.
|
||||
// - added: new key in B → informational only (UI gained surface).
|
||||
//
|
||||
// Keys are stable identity tokens chosen per element class:
|
||||
// - df-pill: aria-label (Chat / Cowork / Code)
|
||||
// - compactPill: inner text (env value, "Select folder…", …)
|
||||
// - ariaButton: aria-label (sidebar "more" buttons share labels;
|
||||
// we de-dup by counting; see compareCounts below)
|
||||
// - modal: headingText ?? aria-label ?? aria-labelledby
|
||||
// - openMenu: items diffed by `${role}::${text}`
|
||||
//
|
||||
// Pure module — no I/O, no process.exit. The dispatcher reads files
|
||||
// and prints; this file just produces a Diff value.
|
||||
|
||||
import type {
|
||||
AriaButton,
|
||||
CompactPillSnap,
|
||||
DfPill,
|
||||
MenuItem,
|
||||
ModalSnap,
|
||||
OpenMenu,
|
||||
Snapshot,
|
||||
} from './snapshot.js';
|
||||
|
||||
export interface DiffEntry {
|
||||
kind: 'removed' | 'changed' | 'added';
|
||||
category: string;
|
||||
key: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
a: { capturedAt: string; url: string; appVersion: string | null };
|
||||
b: { capturedAt: string; url: string; appVersion: string | null };
|
||||
entries: DiffEntry[];
|
||||
summary: { removed: number; changed: number; added: number };
|
||||
}
|
||||
|
||||
export function diff(a: Snapshot, b: Snapshot): DiffResult {
|
||||
const entries: DiffEntry[] = [];
|
||||
entries.push(...diffDfPills(a.dfPills, b.dfPills));
|
||||
entries.push(...diffCompactPills(a.compactPills, b.compactPills));
|
||||
entries.push(...diffAriaButtons(a.ariaLabeledButtons, b.ariaLabeledButtons));
|
||||
entries.push(...diffModals(a.modals, b.modals));
|
||||
entries.push(...diffOpenMenu(a.openMenu, b.openMenu));
|
||||
const summary = entries.reduce(
|
||||
(acc, e) => {
|
||||
acc[e.kind] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ removed: 0, changed: 0, added: 0 },
|
||||
);
|
||||
return {
|
||||
a: {
|
||||
capturedAt: a.capturedAt,
|
||||
url: a.claudeAiUrl,
|
||||
appVersion: a.appVersion,
|
||||
},
|
||||
b: {
|
||||
capturedAt: b.capturedAt,
|
||||
url: b.claudeAiUrl,
|
||||
appVersion: b.appVersion,
|
||||
},
|
||||
entries,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
// Human-readable formatter. Removed/changed first (they're failures
|
||||
// in spirit), added last (informational). Empty diff prints a single
|
||||
// line so CI logs stay tidy.
|
||||
export function formatDiff(d: DiffResult): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`A: ${d.a.capturedAt} (${d.a.url}) app=${d.a.appVersion}`);
|
||||
lines.push(`B: ${d.b.capturedAt} (${d.b.url}) app=${d.b.appVersion}`);
|
||||
lines.push('');
|
||||
if (d.entries.length === 0) {
|
||||
lines.push('No differences.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
const order: DiffEntry['kind'][] = ['removed', 'changed', 'added'];
|
||||
for (const kind of order) {
|
||||
const group = d.entries.filter((e) => e.kind === kind);
|
||||
if (group.length === 0) continue;
|
||||
lines.push(`# ${kind.toUpperCase()} (${group.length})`);
|
||||
for (const e of group) {
|
||||
if (e.kind === 'changed') {
|
||||
lines.push(
|
||||
` [${e.category}] ${e.key}: ${e.before ?? ''} → ${e.after ?? ''}`,
|
||||
);
|
||||
} else if (e.kind === 'removed') {
|
||||
lines.push(` [${e.category}] ${e.key}: ${e.before ?? ''}`);
|
||||
} else {
|
||||
lines.push(` [${e.category}] ${e.key}: ${e.after ?? ''}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(
|
||||
`Summary: ${d.summary.removed} removed, ` +
|
||||
`${d.summary.changed} changed, ${d.summary.added} added`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function diffDfPills(a: DfPill[], b: DfPill[]): DiffEntry[] {
|
||||
const aMap = byKey(a, (p) => p.ariaLabel ?? p.text);
|
||||
const bMap = byKey(b, (p) => p.ariaLabel ?? p.text);
|
||||
return compareMaps(aMap, bMap, 'dfPill', (p) => p.text);
|
||||
}
|
||||
|
||||
function diffCompactPills(
|
||||
a: CompactPillSnap[],
|
||||
b: CompactPillSnap[],
|
||||
): DiffEntry[] {
|
||||
// Compact pills can repeat by text in pathological cases, so we
|
||||
// disambiguate by appending an ordinal when needed. The ordinal is
|
||||
// stable as long as DOM order is — same approach `findCompactPills`
|
||||
// callers rely on.
|
||||
const aMap = byKeyOrdinal(a, (p) => p.text);
|
||||
const bMap = byKeyOrdinal(b, (p) => p.text);
|
||||
return compareMaps(aMap, bMap, 'compactPill', (p) => `maxW=${p.maxW}`);
|
||||
}
|
||||
|
||||
// Aria-labeled buttons frequently repeat (sidebar's ~80 conversation-row
|
||||
// "more" buttons all share a label). We compare by *count per label*
|
||||
// instead of per-instance: a delta in count surfaces as a single
|
||||
// changed entry, which is far more readable than 80 added/removed
|
||||
// rows. Per-label text is omitted since duplicate labels mean text is
|
||||
// not a stable identity.
|
||||
function diffAriaButtons(a: AriaButton[], b: AriaButton[]): DiffEntry[] {
|
||||
return compareCounts(
|
||||
countBy(a, (x) => x.ariaLabel),
|
||||
countBy(b, (x) => x.ariaLabel),
|
||||
'ariaButton',
|
||||
);
|
||||
}
|
||||
|
||||
function diffModals(a: ModalSnap[], b: ModalSnap[]): DiffEntry[] {
|
||||
const key = (m: ModalSnap) =>
|
||||
m.headingText ?? m.ariaLabel ?? m.ariaLabelledBy ?? '<unlabeled-modal>';
|
||||
const aMap = byKeyOrdinal(a, key);
|
||||
const bMap = byKeyOrdinal(b, key);
|
||||
return compareMaps(aMap, bMap, 'modal', (m) =>
|
||||
`buttons=${m.buttonLabels.join('|')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Menu diff is special: the "key" is the menu identity, but a menu
|
||||
// diff is really an item-set diff. We compare item lists, scoped under
|
||||
// the menu's labelledBy/ariaLabel for context.
|
||||
function diffOpenMenu(
|
||||
a: OpenMenu | null,
|
||||
b: OpenMenu | null,
|
||||
): DiffEntry[] {
|
||||
if (!a && !b) return [];
|
||||
const scope =
|
||||
(a?.ariaLabel ?? b?.ariaLabel) ||
|
||||
(a?.ariaLabelledBy ?? b?.ariaLabelledBy) ||
|
||||
'<menu>';
|
||||
if (a && !b) {
|
||||
return [
|
||||
{
|
||||
kind: 'removed',
|
||||
category: 'openMenu',
|
||||
key: scope,
|
||||
before: a.items.map(itemKey).join(' | '),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!a && b) {
|
||||
return [
|
||||
{
|
||||
kind: 'added',
|
||||
category: 'openMenu',
|
||||
key: scope,
|
||||
after: b.items.map(itemKey).join(' | '),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!a || !b) return [];
|
||||
const aMap = byKeyOrdinal(a.items, itemKey);
|
||||
const bMap = byKeyOrdinal(b.items, itemKey);
|
||||
return compareMaps(
|
||||
aMap,
|
||||
bMap,
|
||||
`openMenu[${scope}]`,
|
||||
(it) =>
|
||||
`disabled=${it.disabled}` +
|
||||
(it.ariaChecked !== null ? ` checked=${it.ariaChecked}` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
function itemKey(it: MenuItem): string {
|
||||
return `${it.role}::${it.text}`;
|
||||
}
|
||||
|
||||
function byKey<T>(arr: T[], k: (t: T) => string): Map<string, T> {
|
||||
const m = new Map<string, T>();
|
||||
for (const it of arr) m.set(k(it), it);
|
||||
return m;
|
||||
}
|
||||
|
||||
// When keys collide, append `#2`, `#3`, … so the comparator can still
|
||||
// detect "we used to have 3, now we have 2" (one #N drops out as
|
||||
// removed). Ordinals are local to this snapshot — they don't cross
|
||||
// snapshot boundaries.
|
||||
function byKeyOrdinal<T>(arr: T[], k: (t: T) => string): Map<string, T> {
|
||||
const m = new Map<string, T>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const it of arr) {
|
||||
const base = k(it);
|
||||
const n = (counts.get(base) ?? 0) + 1;
|
||||
counts.set(base, n);
|
||||
m.set(n === 1 ? base : `${base}#${n}`, it);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function countBy<T>(arr: T[], k: (t: T) => string): Map<string, number> {
|
||||
const m = new Map<string, number>();
|
||||
for (const it of arr) {
|
||||
const key = k(it);
|
||||
m.set(key, (m.get(key) ?? 0) + 1);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function compareMaps<T>(
|
||||
a: Map<string, T>,
|
||||
b: Map<string, T>,
|
||||
category: string,
|
||||
describe: (t: T) => string,
|
||||
): DiffEntry[] {
|
||||
const out: DiffEntry[] = [];
|
||||
for (const [k, v] of a) {
|
||||
const bv = b.get(k);
|
||||
if (bv === undefined) {
|
||||
out.push({
|
||||
kind: 'removed',
|
||||
category,
|
||||
key: k,
|
||||
before: describe(v),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const before = describe(v);
|
||||
const after = describe(bv);
|
||||
if (before !== after) {
|
||||
out.push({
|
||||
kind: 'changed',
|
||||
category,
|
||||
key: k,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [k, v] of b) {
|
||||
if (!a.has(k)) {
|
||||
out.push({
|
||||
kind: 'added',
|
||||
category,
|
||||
key: k,
|
||||
after: describe(v),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function compareCounts(
|
||||
a: Map<string, number>,
|
||||
b: Map<string, number>,
|
||||
category: string,
|
||||
): DiffEntry[] {
|
||||
const out: DiffEntry[] = [];
|
||||
for (const [k, n] of a) {
|
||||
const m = b.get(k);
|
||||
if (m === undefined) {
|
||||
out.push({
|
||||
kind: 'removed',
|
||||
category,
|
||||
key: k,
|
||||
before: `count=${n}`,
|
||||
});
|
||||
} else if (m !== n) {
|
||||
out.push({
|
||||
kind: 'changed',
|
||||
category,
|
||||
key: k,
|
||||
before: `count=${n}`,
|
||||
after: `count=${m}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [k, m] of b) {
|
||||
if (!a.has(k)) {
|
||||
out.push({
|
||||
kind: 'added',
|
||||
category,
|
||||
key: k,
|
||||
after: `count=${m}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
640
tools/test-harness/explore/explore.ts
Normal file
640
tools/test-harness/explore/explore.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
// Entry point for the explore CLI.
|
||||
//
|
||||
// Subcommand surface (matches docs/testing/claudeai-ui-mapping-plan.md
|
||||
// Phase 1):
|
||||
//
|
||||
// explore full snapshot to stdout
|
||||
// explore pills df-pills + compact-pills + state
|
||||
// explore menu currently-open menu structure
|
||||
// explore snapshot <name> write to docs/testing/ui-snapshots/<name>.json
|
||||
// explore diff <a> <b> diff two snapshots
|
||||
// explore find <regex> search renderer for matching text/aria-label
|
||||
//
|
||||
// Why a hand-rolled dispatcher: the surface is six cases. A flag parser
|
||||
// adds a dependency and obscures which command takes which positional.
|
||||
// Keep the routing visible.
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 success (including a clean diff)
|
||||
// 1 caller error (bad args, missing file)
|
||||
// 2 runtime error (no debugger, no claude.ai webContents)
|
||||
// 3 diff non-empty AND `--exit-on-diff` was set — opt-in, off by
|
||||
// default so `explore diff` from a script can read entries
|
||||
// without conflating "drift" with "tool blew up".
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { InspectorClient } from '../src/lib/inspector.js';
|
||||
import { capture, capturePills, captureOpenMenu } from './snapshot.js';
|
||||
import type { Snapshot } from './snapshot.js';
|
||||
import { diff, formatDiff } from './diff.js';
|
||||
import { findInRenderer, formatHits } from './find.js';
|
||||
import {
|
||||
collapsePersistentEntries,
|
||||
walkRenderer,
|
||||
WALKER_VERSION,
|
||||
} from './walker.js';
|
||||
import type { Inventory } from './walker.js';
|
||||
|
||||
const INSPECTOR_PORT = 9229;
|
||||
// Resolve relative to this source file so the CLI works regardless of
|
||||
// cwd (npm script vs. ad-hoc tsx invocation from elsewhere).
|
||||
const TESTING_DIR = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'docs',
|
||||
'testing',
|
||||
);
|
||||
const SNAPSHOT_DIR = resolve(TESTING_DIR, 'ui-snapshots');
|
||||
const INVENTORY_PATH = resolve(TESTING_DIR, 'ui-inventory.json');
|
||||
const INVENTORY_META_PATH = resolve(TESTING_DIR, 'ui-inventory.meta.json');
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
const cmd = argv[0];
|
||||
const rest = argv.slice(1);
|
||||
try {
|
||||
switch (cmd) {
|
||||
case undefined:
|
||||
await runFullSnapshot();
|
||||
return;
|
||||
case 'pills':
|
||||
await runPills();
|
||||
return;
|
||||
case 'menu':
|
||||
await runMenu();
|
||||
return;
|
||||
case 'snapshot':
|
||||
await runSnapshot(rest);
|
||||
return;
|
||||
case 'diff':
|
||||
await runDiff(rest);
|
||||
return;
|
||||
case 'find':
|
||||
await runFind(rest);
|
||||
return;
|
||||
case 'walk':
|
||||
await runWalk(rest);
|
||||
return;
|
||||
case 'collapse':
|
||||
await runCollapse(rest);
|
||||
return;
|
||||
case '-h':
|
||||
case '--help':
|
||||
case 'help':
|
||||
printUsage();
|
||||
return;
|
||||
default:
|
||||
console.error(`unknown subcommand: ${cmd}`);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`explore: ${msg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
async function runFullSnapshot(): Promise<void> {
|
||||
const client = await connect();
|
||||
try {
|
||||
const snap = await capture(client);
|
||||
console.log(JSON.stringify(snap, null, 2));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runPills(): Promise<void> {
|
||||
const client = await connect();
|
||||
try {
|
||||
const pills = await capturePills(client);
|
||||
console.log(JSON.stringify(pills, null, 2));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runMenu(): Promise<void> {
|
||||
const client = await connect();
|
||||
try {
|
||||
const menu = await captureOpenMenu(client);
|
||||
if (!menu) {
|
||||
console.log('null');
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(menu, null, 2));
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runSnapshot(args: string[]): Promise<void> {
|
||||
const name = args[0];
|
||||
if (!name) {
|
||||
console.error('snapshot: missing <name> argument');
|
||||
console.error('usage: explore snapshot <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
||||
console.error(
|
||||
`snapshot: name ${JSON.stringify(name)} contains characters ` +
|
||||
`outside [a-zA-Z0-9._-] — choose a slug-safe name`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await connect();
|
||||
let snap: Snapshot;
|
||||
try {
|
||||
snap = await capture(client);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
if (!existsSync(SNAPSHOT_DIR)) {
|
||||
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
||||
}
|
||||
const outPath = resolve(SNAPSHOT_DIR, `${name}.json`);
|
||||
writeFileSync(outPath, JSON.stringify(snap, null, 2) + '\n', 'utf8');
|
||||
console.log(`wrote ${outPath}`);
|
||||
}
|
||||
|
||||
async function runDiff(args: string[]): Promise<void> {
|
||||
const opts = { json: false, exitOnDiff: false };
|
||||
const positional: string[] = [];
|
||||
for (const a of args) {
|
||||
if (a === '--json') opts.json = true;
|
||||
else if (a === '--exit-on-diff') opts.exitOnDiff = true;
|
||||
else positional.push(a);
|
||||
}
|
||||
if (positional.length !== 2) {
|
||||
console.error('diff: expected exactly two snapshot names or paths');
|
||||
console.error('usage: explore diff <a> <b> [--json] [--exit-on-diff]');
|
||||
process.exit(1);
|
||||
}
|
||||
const a = readSnapshot(positional[0]!);
|
||||
const b = readSnapshot(positional[1]!);
|
||||
const result = diff(a, b);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(formatDiff(result));
|
||||
}
|
||||
if (opts.exitOnDiff && result.entries.length > 0) {
|
||||
process.exit(3);
|
||||
}
|
||||
}
|
||||
|
||||
// `walk` parses its own flags; --max-elements 0 prints usage and exits
|
||||
// (a cheap dry-run for "is the CLI loadable" without touching CDP).
|
||||
async function runWalk(args: string[]): Promise<void> {
|
||||
const opts: {
|
||||
maxElements: number;
|
||||
maxDrillsPerSurface: number;
|
||||
checkpointEvery: number;
|
||||
allowlist: string | null;
|
||||
output: string;
|
||||
verbose: boolean;
|
||||
help: boolean;
|
||||
} = {
|
||||
maxElements: 1000,
|
||||
maxDrillsPerSurface: 50,
|
||||
checkpointEvery: 100,
|
||||
allowlist: null,
|
||||
output: INVENTORY_PATH,
|
||||
verbose: false,
|
||||
help: false,
|
||||
};
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const a = args[i]!;
|
||||
if (a === '-h' || a === '--help') {
|
||||
opts.help = true;
|
||||
} else if (a === '--max-elements') {
|
||||
const n = Number(args[i + 1]);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
console.error('walk: --max-elements requires a non-negative integer');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.maxElements = n;
|
||||
i += 1;
|
||||
} else if (a === '--checkpoint-every') {
|
||||
const n = Number(args[i + 1]);
|
||||
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
|
||||
console.error(
|
||||
'walk: --checkpoint-every requires a non-negative integer (0 disables)',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
opts.checkpointEvery = n;
|
||||
i += 1;
|
||||
} else if (
|
||||
a === '--max-drills-per-surface' ||
|
||||
a === '--max-elements-per-surface'
|
||||
) {
|
||||
// v4 renamed the flag from --max-elements-per-surface (which
|
||||
// truncated emissions) to --max-drills-per-surface (which only
|
||||
// caps queue pushes; all entries are still emitted). Keep the
|
||||
// old name as a deprecated alias.
|
||||
if (a === '--max-elements-per-surface') {
|
||||
process.stderr.write(
|
||||
'walk: --max-elements-per-surface is deprecated; ' +
|
||||
'use --max-drills-per-surface (semantics changed: now ' +
|
||||
'caps drilling fan-out, not emission count)\n',
|
||||
);
|
||||
}
|
||||
const n = Number(args[i + 1]);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
console.error(`walk: ${a} requires a non-negative integer`);
|
||||
process.exit(1);
|
||||
}
|
||||
opts.maxDrillsPerSurface = n;
|
||||
i += 1;
|
||||
} else if (a === '--allowlist') {
|
||||
const p = args[i + 1];
|
||||
if (!p) {
|
||||
console.error('walk: --allowlist requires a path');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.allowlist = p;
|
||||
i += 1;
|
||||
} else if (a === '--output') {
|
||||
const p = args[i + 1];
|
||||
if (!p) {
|
||||
console.error('walk: --output requires a path');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.output = resolve(p);
|
||||
i += 1;
|
||||
} else if (a === '--verbose') {
|
||||
opts.verbose = true;
|
||||
} else {
|
||||
console.error(`walk: unknown argument: ${a}`);
|
||||
printWalkUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (opts.help || opts.maxElements === 0) {
|
||||
printWalkUsage();
|
||||
return;
|
||||
}
|
||||
let allowlist: string[] = [];
|
||||
if (opts.allowlist) {
|
||||
const raw = readFileSync(opts.allowlist, 'utf8');
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { exemptions?: string[] };
|
||||
allowlist = parsed.exemptions ?? [];
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`walk: allowlist ${opts.allowlist}: invalid JSON — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const outDir = dirname(opts.output);
|
||||
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
||||
const metaPath =
|
||||
opts.output === INVENTORY_PATH
|
||||
? INVENTORY_META_PATH
|
||||
: opts.output.replace(/\.json$/, '.meta.json');
|
||||
|
||||
// Atomic writer: write to <path>.tmp, then rename. Survives a kill
|
||||
// between writes — readers always see either the prior complete file
|
||||
// or the new one, never a half-written buffer. Used for both the
|
||||
// in-flight checkpoint writes and the final write. `partial` is
|
||||
// recorded in meta.json (true on intermediate writes, false on the
|
||||
// final write) so downstream readers can tell whether the inventory
|
||||
// is complete; the inventory file itself stays shape-compatible.
|
||||
const writeCheckpoint = (
|
||||
inventory: Inventory,
|
||||
isPartial: boolean,
|
||||
): void => {
|
||||
const invTmp = `${opts.output}${INVENTORY_TMP_SUFFIX}`;
|
||||
writeFileSync(
|
||||
invTmp,
|
||||
JSON.stringify(inventory, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
renameSync(invTmp, opts.output);
|
||||
const meta = {
|
||||
capturedAt: inventory.capturedAt,
|
||||
appVersion: inventory.appVersion,
|
||||
walkerVersion: WALKER_VERSION,
|
||||
startUrl: inventory.startUrl,
|
||||
totalElements: inventory.totalElements,
|
||||
deniedActions: inventory.deniedActions,
|
||||
partial: isPartial,
|
||||
denylistDescription:
|
||||
'Default destructive-action labels (see DEFAULT_DENYLIST in walker.ts) ' +
|
||||
'plus optional allowlist exemptions.',
|
||||
allowlistEntries: allowlist,
|
||||
};
|
||||
const metaTmp = `${metaPath}${INVENTORY_TMP_SUFFIX}`;
|
||||
writeFileSync(metaTmp, JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
renameSync(metaTmp, metaPath);
|
||||
};
|
||||
|
||||
const client = await connect();
|
||||
let inventory: Inventory;
|
||||
try {
|
||||
inventory = await walkRenderer(client, {
|
||||
maxElements: opts.maxElements,
|
||||
maxDrillsPerSurface: opts.maxDrillsPerSurface,
|
||||
allowlist,
|
||||
verbose: opts.verbose,
|
||||
checkpointEvery: opts.checkpointEvery,
|
||||
checkpointWriter:
|
||||
opts.checkpointEvery > 0
|
||||
? (inv) => writeCheckpoint(inv, true)
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
writeCheckpoint(inventory, false);
|
||||
console.log(
|
||||
`wrote ${opts.output} (${inventory.totalElements} entries, ` +
|
||||
`${inventory.deniedActions} denylisted)`,
|
||||
);
|
||||
console.log(`wrote ${metaPath}`);
|
||||
}
|
||||
|
||||
// Suffix used by the atomic-write helper. Kept module-level so any
|
||||
// future readers know which dotfile to ignore in tooling/gitignore.
|
||||
const INVENTORY_TMP_SUFFIX = '.tmp';
|
||||
|
||||
// `collapse [<path>]` re-runs the post-walk persistent-element
|
||||
// collapse against an existing inventory file. Use case: a partial
|
||||
// checkpoint (walker aborted mid-walk) skipped the in-loop collapse
|
||||
// and so has 0 persistent entries — this command salvages it without
|
||||
// re-running the walker. Also useful if collapse heuristics change
|
||||
// and we want to refresh an existing inventory.
|
||||
async function runCollapse(args: string[]): Promise<void> {
|
||||
let path = INVENTORY_PATH;
|
||||
let help = false;
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const a = args[i]!;
|
||||
if (a === '-h' || a === '--help') help = true;
|
||||
else if (!a.startsWith('-')) path = resolve(a);
|
||||
else {
|
||||
console.error(`collapse: unknown argument: ${a}`);
|
||||
printCollapseUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (help) {
|
||||
printCollapseUsage();
|
||||
return;
|
||||
}
|
||||
if (!existsSync(path)) {
|
||||
console.error(`collapse: inventory not found: ${path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
let inventory: Inventory;
|
||||
try {
|
||||
inventory = JSON.parse(readFileSync(path, 'utf8')) as Inventory;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`collapse: invalid JSON in ${path} — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// v7-only gate. The v6 → v7 fingerprint cutover invalidated all
|
||||
// older inventory shapes; re-running the persistent collapse on a
|
||||
// v6 inventory would mint v7-key collisions against v6 selectors
|
||||
// and drop unrelated entries. Re-walk first.
|
||||
const wv = inventory.walkerVersion;
|
||||
if (wv !== '7') {
|
||||
console.error(
|
||||
`collapse: walkerVersion ${wv} is not supported (need v7; ` +
|
||||
`re-walk after the v6 → v7 fingerprint cutover)`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const before = inventory.entries.length;
|
||||
const result = collapsePersistentEntries(inventory.entries);
|
||||
const after = result.entries.length;
|
||||
const dropped = before - after;
|
||||
const collapsedAt = new Date().toISOString();
|
||||
const updated: Inventory = {
|
||||
...inventory,
|
||||
walkerVersion: WALKER_VERSION,
|
||||
totalElements: after,
|
||||
entries: result.entries,
|
||||
capturedAt: inventory.capturedAt,
|
||||
};
|
||||
|
||||
// Atomic write inventory + meta. Mirror the walk subcommand: write
|
||||
// to .tmp, rename. Meta gets `partial: false` (collapse closes out
|
||||
// a partial checkpoint) and `collapsedAt`; everything else carries
|
||||
// through from the existing meta where present.
|
||||
const invTmp = `${path}${INVENTORY_TMP_SUFFIX}`;
|
||||
writeFileSync(invTmp, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
||||
renameSync(invTmp, path);
|
||||
|
||||
const metaPath =
|
||||
path === INVENTORY_PATH
|
||||
? INVENTORY_META_PATH
|
||||
: path.replace(/\.json$/, '.meta.json');
|
||||
let existingMeta: Record<string, unknown> = {};
|
||||
if (existsSync(metaPath)) {
|
||||
try {
|
||||
existingMeta = JSON.parse(readFileSync(metaPath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
// Carry the inventory through even if meta is malformed; meta
|
||||
// is recoverable, the entries are not.
|
||||
}
|
||||
}
|
||||
const meta = {
|
||||
...existingMeta,
|
||||
capturedAt: updated.capturedAt,
|
||||
appVersion: updated.appVersion,
|
||||
walkerVersion: WALKER_VERSION,
|
||||
startUrl: updated.startUrl,
|
||||
totalElements: updated.totalElements,
|
||||
deniedActions: updated.deniedActions,
|
||||
partial: false,
|
||||
collapsedAt,
|
||||
};
|
||||
const metaTmp = `${metaPath}${INVENTORY_TMP_SUFFIX}`;
|
||||
writeFileSync(metaTmp, JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
renameSync(metaTmp, metaPath);
|
||||
|
||||
console.log(
|
||||
`collapse: read ${before} entries → wrote ${after} entries ` +
|
||||
`(${dropped} dropped via persistent collapse, ` +
|
||||
`${result.persistentSurvivors} shells emitted)`,
|
||||
);
|
||||
console.log(`wrote ${path}`);
|
||||
console.log(`wrote ${metaPath}`);
|
||||
}
|
||||
|
||||
function printCollapseUsage(): void {
|
||||
console.log(
|
||||
[
|
||||
'usage: explore collapse [<path>]',
|
||||
'',
|
||||
'Re-run the post-walk persistent-element collapse against an',
|
||||
'existing inventory file. Useful for salvaging a partial',
|
||||
'checkpoint that aborted before the in-loop collapse step.',
|
||||
'',
|
||||
' <path> inventory file to collapse in place (default:',
|
||||
' docs/testing/ui-inventory.json). Must be v5+.',
|
||||
' -h, --help print this help',
|
||||
'',
|
||||
'Writes the collapsed inventory and updated meta.json',
|
||||
'atomically (.tmp + rename). Meta gains `collapsedAt` and',
|
||||
'clears `partial` to false.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function printWalkUsage(): void {
|
||||
console.log(
|
||||
[
|
||||
'usage: explore walk [options]',
|
||||
'',
|
||||
'options:',
|
||||
' --max-elements N safety cap on total entries',
|
||||
' (default 1000; 0 prints this help',
|
||||
' and exits)',
|
||||
' --max-drills-per-surface N max number of children to drill into',
|
||||
' from one surface (default 50). All',
|
||||
' children are still emitted to the',
|
||||
' inventory; this only bounds the BFS',
|
||||
' queue fan-out per surface.',
|
||||
' (Alias: --max-elements-per-surface,',
|
||||
' deprecated — v3 truncated emissions,',
|
||||
' v4 only caps drilling.)',
|
||||
' --checkpoint-every N atomically write the inventory every N',
|
||||
' newly-emitted entries (default 100;',
|
||||
' 0 disables). Intermediate writes set',
|
||||
' meta.json `partial: true`; the final',
|
||||
' write clears it to false.',
|
||||
' --allowlist PATH JSON file:',
|
||||
' {"exemptions": ["entry.id", ...]} to',
|
||||
' remove from the default denylist',
|
||||
' --output PATH write inventory to PATH (default',
|
||||
' docs/testing/ui-inventory.json)',
|
||||
' --verbose log every click + surface to stderr',
|
||||
' -h, --help print this help',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
async function runFind(args: string[]): Promise<void> {
|
||||
const opts = { json: false, limit: 100 };
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const a = args[i]!;
|
||||
if (a === '--json') opts.json = true;
|
||||
else if (a === '--limit') {
|
||||
const n = Number(args[i + 1]);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
console.error('find: --limit requires a positive integer');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.limit = n;
|
||||
i += 1;
|
||||
} else positional.push(a);
|
||||
}
|
||||
const pat = positional[0];
|
||||
if (!pat) {
|
||||
console.error('find: missing <regex> argument');
|
||||
console.error('usage: explore find <regex> [--json] [--limit N]');
|
||||
process.exit(1);
|
||||
}
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(pat, 'i');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`find: invalid regex: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const client = await connect();
|
||||
try {
|
||||
const hits = await findInRenderer(client, re, { limit: opts.limit });
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(hits, null, 2));
|
||||
} else {
|
||||
console.log(formatHits(hits));
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot resolver: accept either a bare name (looked up in the
|
||||
// snapshot dir, .json appended) or an explicit path. Bare names are
|
||||
// the common case from CI / the README; explicit paths help when
|
||||
// diffing a snapshot against an out-of-tree fixture.
|
||||
function readSnapshot(nameOrPath: string): Snapshot {
|
||||
const candidates = [
|
||||
nameOrPath,
|
||||
resolve(SNAPSHOT_DIR, nameOrPath),
|
||||
resolve(SNAPSHOT_DIR, `${nameOrPath}.json`),
|
||||
];
|
||||
const found = candidates.find((p) => existsSync(p));
|
||||
if (!found) {
|
||||
console.error(`snapshot not found: tried ${candidates.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const raw = readFileSync(found, 'utf8');
|
||||
try {
|
||||
return JSON.parse(raw) as Snapshot;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`snapshot ${found}: invalid JSON — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(): Promise<InspectorClient> {
|
||||
try {
|
||||
return await InspectorClient.connect(INSPECTOR_PORT);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`could not attach to debugger on :${INSPECTOR_PORT} — ${msg}. ` +
|
||||
`Enable the main-process debugger via the in-app menu first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(
|
||||
[
|
||||
'usage:',
|
||||
' explore full snapshot to stdout',
|
||||
' explore pills df-pills + compact-pills + state',
|
||||
' explore menu currently-open menu structure',
|
||||
' explore snapshot <name> write snapshot to ui-snapshots/<name>.json',
|
||||
' explore diff <a> <b> [--json] [--exit-on-diff]',
|
||||
' compare two snapshots',
|
||||
' explore find <regex> [--json] [--limit N]',
|
||||
' search renderer text + aria-label',
|
||||
' explore walk [options] BFS walker → docs/testing/ui-inventory.json',
|
||||
' (see `explore walk --help` for options)',
|
||||
' explore collapse [<path>] re-run persistent-element collapse against',
|
||||
' an existing inventory (salvages partial',
|
||||
' checkpoints; see `explore collapse --help`)',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`explore: ${msg}`);
|
||||
process.exit(2);
|
||||
});
|
||||
86
tools/test-harness/explore/find.ts
Normal file
86
tools/test-harness/explore/find.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// Renderer search by regex over text content + aria-label.
|
||||
//
|
||||
// Why text+aria together: a "Send" button might have aria-label="Send"
|
||||
// but textContent="" (icon child); a heading might be the inverse.
|
||||
// Searching both lets the human ask "where does the word X appear?"
|
||||
// without first guessing which surface labels it.
|
||||
//
|
||||
// We restrict the candidate set to interactive + landmark elements
|
||||
// (button, [role], a, h1-h6, [aria-label]) rather than walking the
|
||||
// entire document — claude.ai's chat history dumps thousands of
|
||||
// <span>/<p> nodes that swamp signal. If a future need wants the
|
||||
// broader sweep, add a `--all` flag here rather than expanding the
|
||||
// default.
|
||||
|
||||
import type { InspectorClient } from '../src/lib/inspector.js';
|
||||
|
||||
export interface FindHit {
|
||||
tag: string;
|
||||
role: string | null;
|
||||
ariaLabel: string | null;
|
||||
text: string;
|
||||
matchedField: 'text' | 'ariaLabel' | 'both';
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// Regex source + flags travel as JSON strings into the renderer eval —
|
||||
// same encoding pattern as openPill / clickMenuItem in lib/claudeai.ts.
|
||||
export async function findInRenderer(
|
||||
client: InspectorClient,
|
||||
pattern: RegExp,
|
||||
opts: { limit?: number } = {},
|
||||
): Promise<FindHit[]> {
|
||||
const limit = opts.limit ?? 100;
|
||||
const reSrc = JSON.stringify(pattern.source);
|
||||
const reFlags = JSON.stringify(pattern.flags);
|
||||
return await client.evalInRenderer<FindHit[]>(
|
||||
'claude.ai',
|
||||
`(() => {
|
||||
const re = new RegExp(${reSrc}, ${reFlags});
|
||||
const sel = 'button, a, h1, h2, h3, h4, h5, h6, ' +
|
||||
'[role], [aria-label]';
|
||||
const nodes = Array.from(document.querySelectorAll(sel));
|
||||
const hits = [];
|
||||
for (const el of nodes) {
|
||||
const text = (el.textContent || '').trim().slice(0, 200);
|
||||
const aria = el.getAttribute('aria-label');
|
||||
const textHit = text.length > 0 && re.test(text);
|
||||
const ariaHit = aria !== null && re.test(aria);
|
||||
if (!textHit && !ariaHit) continue;
|
||||
hits.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
role: el.getAttribute('role'),
|
||||
ariaLabel: aria,
|
||||
text,
|
||||
matchedField: textHit && ariaHit
|
||||
? 'both'
|
||||
: (textHit ? 'text' : 'ariaLabel'),
|
||||
visible: !!el.getClientRects().length,
|
||||
});
|
||||
if (hits.length >= ${limit}) break;
|
||||
}
|
||||
return hits;
|
||||
})()`,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatHits(hits: FindHit[]): string {
|
||||
if (hits.length === 0) return 'No matches.';
|
||||
const lines: string[] = [];
|
||||
for (const h of hits) {
|
||||
const vis = h.visible ? '' : ' [hidden]';
|
||||
const role = h.role ? ` role=${h.role}` : '';
|
||||
const aria = h.ariaLabel !== null ? ` aria-label=${q(h.ariaLabel)}` : '';
|
||||
lines.push(
|
||||
`${h.tag}${role}${aria} (${h.matchedField})${vis}` +
|
||||
(h.text ? `\n text: ${h.text}` : ''),
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(`${hits.length} match(es).`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function q(s: string): string {
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
523
tools/test-harness/explore/gen-render-specs.ts
Normal file
523
tools/test-harness/explore/gen-render-specs.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
// Generate the U01 UI-visibility Playwright spec from the captured
|
||||
// inventory at docs/testing/ui-inventory.json. Reads the inventory +
|
||||
// its meta sidecar offline (no live app needed), groups entries by
|
||||
// canonical surface, and emits a single .spec.ts file with one
|
||||
// `test()` per inventory entry under one `test.describe()` per
|
||||
// surface.
|
||||
//
|
||||
// The generated spec asserts each entry's recorded fingerprint still
|
||||
// resolves to a visible element on the live signed-in renderer. It's
|
||||
// the inventory's "do these things still render" sibling — H05
|
||||
// detects shape drift across snapshots, U01 detects per-entry render
|
||||
// failures across the whole inventory.
|
||||
//
|
||||
// Pure file in/out: no network, no inspector. The spec it emits is
|
||||
// where the live app gets touched. Run via `npm run gen:render-specs`.
|
||||
//
|
||||
// Refuses to operate on a stale walker version or a partial inventory
|
||||
// — generating a passing spec from a half-walked DOM would silently
|
||||
// shrink the assertion surface to whatever the walker happened to
|
||||
// reach before crashing.
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { WALKER_VERSION } from './walker.js';
|
||||
import type { Inventory, InventoryEntry, NavStep } from './walker.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const TESTING_DIR = resolve(HERE, '..', '..', '..', 'docs', 'testing');
|
||||
const DEFAULT_INVENTORY = resolve(TESTING_DIR, 'ui-inventory.json');
|
||||
const DEFAULT_META = resolve(TESTING_DIR, 'ui-inventory.meta.json');
|
||||
const DEFAULT_OUTPUT = resolve(
|
||||
HERE,
|
||||
'..',
|
||||
'src',
|
||||
'runners',
|
||||
'U01_ui_visibility.spec.ts',
|
||||
);
|
||||
|
||||
interface MetaSidecar {
|
||||
walkerVersion: string;
|
||||
partial: boolean;
|
||||
capturedAt: string;
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
interface CliOpts {
|
||||
inventory: string;
|
||||
output: string;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
function parseCli(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = {
|
||||
inventory: DEFAULT_INVENTORY,
|
||||
output: DEFAULT_OUTPUT,
|
||||
help: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
switch (a) {
|
||||
case '-h':
|
||||
case '--help':
|
||||
opts.help = true;
|
||||
break;
|
||||
case '--inventory': {
|
||||
const v = argv[++i];
|
||||
if (!v) {
|
||||
process.stderr.write('--inventory requires a path\n');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.inventory = resolve(v);
|
||||
break;
|
||||
}
|
||||
case '--output': {
|
||||
const v = argv[++i];
|
||||
if (!v) {
|
||||
process.stderr.write('--output requires a path\n');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.output = resolve(v);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
process.stderr.write(`gen-render-specs: unknown argument: ${a}\n`);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
'Usage: tsx explore/gen-render-specs.ts [options]\n' +
|
||||
'\n' +
|
||||
'Generates src/runners/U01_ui_visibility.spec.ts from\n' +
|
||||
'docs/testing/ui-inventory.json. Refuses to run if the inventory\n' +
|
||||
'is partial or was produced by a walker older than v' +
|
||||
WALKER_VERSION +
|
||||
'.\n' +
|
||||
'\n' +
|
||||
'Options:\n' +
|
||||
' --inventory <path> Override default inventory path\n' +
|
||||
' (default: docs/testing/ui-inventory.json)\n' +
|
||||
' --output <path> Override default spec output path\n' +
|
||||
' (default: src/runners/U01_ui_visibility.spec.ts)\n' +
|
||||
' -h, --help Print this help and exit\n',
|
||||
);
|
||||
}
|
||||
|
||||
function loadInventory(path: string): Inventory {
|
||||
if (!existsSync(path)) {
|
||||
process.stderr.write(`gen-render-specs: inventory not found: ${path}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as Inventory;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`gen-render-specs: failed to parse inventory: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function loadMeta(invPath: string): MetaSidecar {
|
||||
const metaPath = invPath.replace(/\.json$/, '.meta.json');
|
||||
const fallbackPath =
|
||||
invPath === DEFAULT_INVENTORY ? DEFAULT_META : metaPath;
|
||||
const path = existsSync(metaPath) ? metaPath : fallbackPath;
|
||||
if (!existsSync(path)) {
|
||||
process.stderr.write(
|
||||
`gen-render-specs: meta sidecar not found at ${metaPath} ` +
|
||||
'(needed for partial/walkerVersion gating)\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as MetaSidecar;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`gen-render-specs: failed to parse meta: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse on stale walker versions or partial inventories. The point of
|
||||
// this generator is to emit a spec that asserts the FULL inventory
|
||||
// renders; gating on these two flags is what stops a half-walked
|
||||
// checkpoint from quietly shrinking the assertion set.
|
||||
function validate(inv: Inventory, meta: MetaSidecar): void {
|
||||
const seen = Number.parseInt(inv.walkerVersion, 10);
|
||||
const required = Number.parseInt(WALKER_VERSION, 10);
|
||||
if (Number.isNaN(seen) || seen < required) {
|
||||
process.stderr.write(
|
||||
`gen-render-specs: walkerVersion ${inv.walkerVersion} < ${WALKER_VERSION}; ` +
|
||||
'inventory shape may be incompatible. Re-walk with the current ' +
|
||||
'explore CLI before regenerating the spec.\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (meta.partial === true) {
|
||||
process.stderr.write(
|
||||
'gen-render-specs: inventory meta reports partial=true (walk did ' +
|
||||
'not finish). Refusing to generate a spec from a half-walked DOM ' +
|
||||
'— complete the walk first or pass --inventory to a known-good file.\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic surface→entries grouping. Sort surfaces alphabetically
|
||||
// and entries within each surface by id, so a re-run produces an
|
||||
// identical spec file when the inventory hasn't changed (the file is
|
||||
// checked in; no-op regeneration shouldn't mint diffs).
|
||||
function groupBySurface(
|
||||
entries: InventoryEntry[],
|
||||
): { surface: string; entries: InventoryEntry[] }[] {
|
||||
const buckets = new Map<string, InventoryEntry[]>();
|
||||
for (const e of entries) {
|
||||
const list = buckets.get(e.surface) ?? [];
|
||||
list.push(e);
|
||||
buckets.set(e.surface, list);
|
||||
}
|
||||
const surfaces = [...buckets.keys()].sort();
|
||||
return surfaces.map((surface) => {
|
||||
const list = buckets.get(surface)!.slice();
|
||||
list.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
return { surface, entries: list };
|
||||
});
|
||||
}
|
||||
|
||||
// Strip any navigationPath step that would CLICK the entry under
|
||||
// test, when that entry is denylisted. Per the spec brief: never click
|
||||
// denylisted controls, just assert they exist. In practice the
|
||||
// recorded path's last click is the surface-opener (entry's own id is
|
||||
// `surface.role.label`, distinct from any path step), so this filter
|
||||
// usually no-ops — but it's the safety net the brief calls for.
|
||||
function safeNavigationPath(entry: InventoryEntry): NavStep[] {
|
||||
if (!entry.denylisted) return entry.navigationPath;
|
||||
return entry.navigationPath.filter(
|
||||
(s) => !(s.action === 'click' && s.id === entry.id),
|
||||
);
|
||||
}
|
||||
|
||||
// JS string literal for embedding in generated source. Use JSON.stringify
|
||||
// — handles all the escapes (backslash, quotes, newlines, unicode) that
|
||||
// hand-rolling would miss on entries with weird labels.
|
||||
function js(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Sanitize a surface name into a `test.describe()` block label that
|
||||
// reads cleanly. Surfaces are dot-separated paths like
|
||||
// `root.button.search.option.x`; the raw form is fine for grouping
|
||||
// but we annotate the count so the report shows scope at a glance.
|
||||
function describeLabel(surface: string, count: number): string {
|
||||
return `surface: ${surface} (${count} ${count === 1 ? 'entry' : 'entries'})`;
|
||||
}
|
||||
|
||||
function testTitle(entry: InventoryEntry): string {
|
||||
const tags: string[] = [entry.kind];
|
||||
if (entry.denylisted) tags.push('denylist');
|
||||
const tagStr = tags.length ? ` [${tags.join(',')}]` : '';
|
||||
return `${entry.id}${tagStr} — ${entry.role}: ${entry.label}`;
|
||||
}
|
||||
|
||||
function generateSpec(
|
||||
inv: Inventory,
|
||||
meta: MetaSidecar,
|
||||
groups: { surface: string; entries: InventoryEntry[] }[],
|
||||
): string {
|
||||
const out: string[] = [];
|
||||
out.push(
|
||||
'// AUTO-GENERATED FROM docs/testing/ui-inventory.json',
|
||||
'// DO NOT EDIT — regenerate with `npm run gen:render-specs`',
|
||||
`// Source inventory: walker v${inv.walkerVersion} (account-portable ariaPath ` +
|
||||
`fingerprints), captured ${inv.capturedAt}, app ${inv.appVersion}`,
|
||||
`// Entries: ${inv.totalElements} ` +
|
||||
`(${inv.deniedActions} denylisted), ` +
|
||||
`${groups.length} surfaces`,
|
||||
`// Meta: partial=${meta.partial}`,
|
||||
'',
|
||||
"import { test, expect } from '@playwright/test';",
|
||||
'',
|
||||
"import { launchClaude } from '../lib/electron.js';",
|
||||
"import type { ClaudeApp } from '../lib/electron.js';",
|
||||
"import { createIsolation } from '../lib/isolation.js';",
|
||||
"import { InspectorClient } from '../lib/inspector.js';",
|
||||
"import { captureSessionEnv } from '../lib/diagnostics.js';",
|
||||
'import {',
|
||||
'\tcurrentUrl,',
|
||||
'\tfindByFingerprint,',
|
||||
'\tredrivePath,',
|
||||
'\twaitForStable,',
|
||||
"} from '../../explore/walker.js';",
|
||||
"import type { InventoryEntry } from '../../explore/walker.js';",
|
||||
'',
|
||||
'// U01 — UI visibility sweep.',
|
||||
'//',
|
||||
'// One Playwright test per inventory entry. Each test re-drives the',
|
||||
"// entry's recorded navigationPath against the live signed-in",
|
||||
"// renderer, then asserts the entry's fingerprint resolves to a",
|
||||
'// visible element. The full inventory acts as a render contract:',
|
||||
'// any entry that no longer renders (selector drift, route change,',
|
||||
'// permission change) shows up as exactly one failed test, with the',
|
||||
'// triage payload (entry JSON + observed DOM neighbourhood)',
|
||||
'// attached to that test only.',
|
||||
'//',
|
||||
'// Skip semantics mirror H05: the suite skips cleanly if the host',
|
||||
"// isn't signed in (claude.ai webContents never reaches the",
|
||||
"// userLoaded level). Default path: kill any running host Claude,",
|
||||
"// copy the auth-relevant subset of ~/.config/Claude into a",
|
||||
"// hermetic tmpdir, and launch against that copy. Host config is",
|
||||
"// left untouched after the kill+seed. CLAUDE_TEST_USE_HOST_CONFIG=1",
|
||||
"// opts out and shares the host's actual config directory (no",
|
||||
"// kill+seed) — use only when you've manually closed the host first.",
|
||||
'//',
|
||||
"// Denylisted entries: we still assert they render, but the",
|
||||
"// generator strips any navigationPath step that would CLICK the",
|
||||
'// denylisted entry itself. Per the spec brief: never trigger',
|
||||
'// destructive controls from a render check.',
|
||||
'//',
|
||||
'// Persistent entries: each persistent entry is asserted on its',
|
||||
'// canonical surface only (the `surface` field). The cross-surface',
|
||||
'// `surfaces[]` list is intentionally unused here — a strict',
|
||||
'// "renders on every surface it was observed" mode is a future',
|
||||
'// follow-up.',
|
||||
'//',
|
||||
'// Instance entries: assert that AT LEAST ONE element matching the',
|
||||
"// fingerprint exists. We don't assert the recorded instanceCount",
|
||||
'// — list lengths legitimately fluctuate across sessions.',
|
||||
'',
|
||||
"// Per-test budget covers a path redrive (~1 nav + ~N clicks * 1.5s)",
|
||||
'// plus a fingerprint resolve. Generous to ride out a slow first',
|
||||
'// route load; later tests in the same suite reuse the warmed app.',
|
||||
'test.setTimeout(120_000);',
|
||||
'',
|
||||
'const useHostConfig = process.env.CLAUDE_TEST_USE_HOST_CONFIG === \'1\';',
|
||||
'',
|
||||
"// Single shared launch + inspector across the whole suite. N",
|
||||
'// tests at one launch each would burn 30+ minutes on cold-start',
|
||||
'// alone. We pay for setup once, then each test re-drives from the',
|
||||
'// recorded startUrl so prior-test side effects (open menus, route',
|
||||
'// changes) get reset before the next assertion runs.',
|
||||
'let app: ClaudeApp | null = null;',
|
||||
'let sharedInspector: InspectorClient | null = null;',
|
||||
'let sharedStartUrl: string | null = null;',
|
||||
'let suiteSkipReason: string | null = null;',
|
||||
'',
|
||||
"test.describe('U01 — UI visibility sweep (auto-generated)', () => {",
|
||||
'\ttest.beforeAll(async () => {',
|
||||
'\t\t// Default path: kill any host Claude, copy auth-relevant',
|
||||
"\t\t// subset of ~/.config/Claude into a hermetic tmpdir, launch",
|
||||
"\t\t// against that copy. Host config is left untouched after the",
|
||||
"\t\t// kill+seed. CLAUDE_TEST_USE_HOST_CONFIG=1 opts out — shares",
|
||||
"\t\t// the host's actual config directory (no kill+seed); use only",
|
||||
"\t\t// when you've manually closed the host first.",
|
||||
'\t\tif (useHostConfig) {',
|
||||
'\t\t\tapp = await launchClaude({ isolation: null });',
|
||||
'\t\t} else {',
|
||||
'\t\t\tconst seeded = await createIsolation({ seedFromHost: true });',
|
||||
'\t\t\tapp = await launchClaude({ isolation: seeded });',
|
||||
'\t\t}',
|
||||
"\t\tconst ready = await app.waitForReady('userLoaded');",
|
||||
'\t\tif (!ready.postLoginUrl) {',
|
||||
"\t\t\tsuiteSkipReason = 'claude.ai never reached a post-login URL — host ' +",
|
||||
"\t\t\t\t'profile is not signed in. Sign in via the host app first.';",
|
||||
'\t\t\treturn;',
|
||||
'\t\t}',
|
||||
'\t\tsharedInspector = ready.inspector;',
|
||||
'\t\tsharedStartUrl = await currentUrl(sharedInspector);',
|
||||
'\t\tawait waitForStable(sharedInspector);',
|
||||
'\t});',
|
||||
'',
|
||||
'\ttest.afterAll(async () => {',
|
||||
'\t\tif (sharedInspector) {',
|
||||
'\t\t\ttry {',
|
||||
'\t\t\t\tsharedInspector.close();',
|
||||
'\t\t\t} catch {',
|
||||
'\t\t\t\t// inspector may already be closed by app.close()',
|
||||
'\t\t\t}',
|
||||
'\t\t\tsharedInspector = null;',
|
||||
'\t\t}',
|
||||
'\t\tif (app) {',
|
||||
'\t\t\tawait app.close();',
|
||||
'\t\t\tapp = null;',
|
||||
'\t\t}',
|
||||
'\t});',
|
||||
'',
|
||||
'\t// why: shared per-test runner. Each generated `test()` packs the',
|
||||
'\t// entry as a literal and calls this — keeps the file scannable',
|
||||
'\t// (one block per entry) without duplicating the assertion logic',
|
||||
"\t// 383 times. Throws on its own when the suite was skipped so",
|
||||
"\t// each test's status reflects the actual render check, not a",
|
||||
'\t// mis-attributed setup failure.',
|
||||
'\tasync function runEntry(',
|
||||
'\t\tentry: InventoryEntry,',
|
||||
"\t\ttestInfo: import('@playwright/test').TestInfo,",
|
||||
'\t): Promise<void> {',
|
||||
'\t\tif (suiteSkipReason) {',
|
||||
'\t\t\ttestInfo.skip(true, suiteSkipReason);',
|
||||
'\t\t\treturn;',
|
||||
'\t\t}',
|
||||
'\t\tif (!sharedInspector || !sharedStartUrl) {',
|
||||
'\t\t\tthrow new Error(',
|
||||
"\t\t\t\t'U01: beforeAll did not initialize the inspector — check the ' +",
|
||||
"\t\t\t\t\t'session-env attachment for the launch failure.',",
|
||||
'\t\t\t);',
|
||||
'\t\t}',
|
||||
"\t\ttestInfo.annotations.push({ type: 'severity', description: 'Should' });",
|
||||
'\t\ttestInfo.annotations.push({',
|
||||
"\t\t\ttype: 'surface',",
|
||||
'\t\t\tdescription: entry.surface,',
|
||||
'\t\t});',
|
||||
'\t\ttestInfo.annotations.push({',
|
||||
"\t\t\ttype: 'kind',",
|
||||
'\t\t\tdescription: entry.kind,',
|
||||
'\t\t});',
|
||||
'',
|
||||
'\t\ttry {',
|
||||
'\t\t\tawait redrivePath(sharedInspector, sharedStartUrl, entry.navigationPath);',
|
||||
'\t\t} catch (err) {',
|
||||
'\t\t\tconst msg = err instanceof Error ? err.message : String(err);',
|
||||
"\t\t\tawait testInfo.attach('redrive-failure', {",
|
||||
'\t\t\t\tbody: JSON.stringify(',
|
||||
'\t\t\t\t\t{',
|
||||
'\t\t\t\t\t\tentry,',
|
||||
'\t\t\t\t\t\terror: msg,',
|
||||
'\t\t\t\t\t\tnote:',
|
||||
"\t\t\t\t\t\t\t'redrivePath threw before we could assert visibility — ' +",
|
||||
"\t\t\t\t\t\t\t'usually a stale fingerprint along the path. Re-walk the ' +",
|
||||
"\t\t\t\t\t\t\t'inventory and regenerate.',",
|
||||
'\t\t\t\t\t},',
|
||||
'\t\t\t\t\tnull,',
|
||||
'\t\t\t\t\t2,',
|
||||
'\t\t\t\t),',
|
||||
"\t\t\t\tcontentType: 'application/json',",
|
||||
'\t\t\t});',
|
||||
'\t\t\tthrow err;',
|
||||
'\t\t}',
|
||||
'\t\tawait waitForStable(sharedInspector);',
|
||||
'',
|
||||
'\t\tconst result = await findByFingerprint(',
|
||||
'\t\t\tsharedInspector,',
|
||||
'\t\t\tentry.fingerprint,',
|
||||
'\t\t\tentry.kind,',
|
||||
'\t\t);',
|
||||
'\t\tif (!result.found) {',
|
||||
"\t\t\tawait testInfo.attach('fingerprint-miss', {",
|
||||
'\t\t\t\tbody: JSON.stringify(',
|
||||
'\t\t\t\t\t{',
|
||||
'\t\t\t\t\t\tentry,',
|
||||
'\t\t\t\t\t\treason: result.reason,',
|
||||
'\t\t\t\t\t\tobservedOuterHTML: result.outerHTMLSnippet,',
|
||||
'\t\t\t\t\t},',
|
||||
'\t\t\t\t\tnull,',
|
||||
'\t\t\t\t\t2,',
|
||||
'\t\t\t\t),',
|
||||
"\t\t\t\tcontentType: 'application/json',",
|
||||
'\t\t\t});',
|
||||
'\t\t}',
|
||||
"\t\t// Soft drift: primary aria-tree match failed but a relaxed-",
|
||||
"\t\t// scope fallback recovered. Test still passes — but a",
|
||||
"\t\t// drift-warning attachment surfaces it so the sweep summary",
|
||||
"\t\t// can flag re-walk before drift compounds.",
|
||||
'\t\tif (result.found && result.drift) {',
|
||||
"\t\t\tawait testInfo.attach('drift-warning', {",
|
||||
'\t\t\t\tbody: JSON.stringify(',
|
||||
'\t\t\t\t\t{',
|
||||
'\t\t\t\t\t\tentryId: entry.id,',
|
||||
'\t\t\t\t\t\texpected: entry.fingerprint.ariaPath,',
|
||||
'\t\t\t\t\t\tmatchedVia: result.strategy,',
|
||||
'\t\t\t\t\t\tdrift: result.drift,',
|
||||
"\t\t\t\t\t\tnote:",
|
||||
"\t\t\t\t\t\t\t'primary aria-tree match failed; recovered via fallback. ' +",
|
||||
"\t\t\t\t\t\t\t'Re-walk inventory before drift compounds.',",
|
||||
'\t\t\t\t\t},',
|
||||
'\t\t\t\t\tnull,',
|
||||
'\t\t\t\t\t2,',
|
||||
'\t\t\t\t),',
|
||||
"\t\t\t\tcontentType: 'application/json',",
|
||||
'\t\t\t});',
|
||||
"\t\t\ttestInfo.annotations.push({",
|
||||
"\t\t\t\ttype: 'drift',",
|
||||
'\t\t\t\tdescription: result.strategy ?? \'unknown\',',
|
||||
'\t\t\t});',
|
||||
'\t\t}',
|
||||
'\t\texpect(',
|
||||
'\t\t\tresult.found,',
|
||||
'\t\t\t`fingerprint did not resolve: ${result.reason ?? \'unknown\'}`,',
|
||||
'\t\t).toBe(true);',
|
||||
'\t}',
|
||||
'',
|
||||
'\ttest.beforeAll(async ({}, testInfo) => {',
|
||||
"\t\tawait testInfo.attach('session-env', {",
|
||||
'\t\t\tbody: JSON.stringify(captureSessionEnv(), null, 2),',
|
||||
"\t\t\tcontentType: 'application/json',",
|
||||
'\t\t});',
|
||||
'\t});',
|
||||
'',
|
||||
);
|
||||
|
||||
// One describe per surface, one test per entry. Strings are
|
||||
// JSON-encoded so labels with quotes/backticks/unicode survive.
|
||||
for (const group of groups) {
|
||||
out.push(
|
||||
`\ttest.describe(${js(describeLabel(group.surface, group.entries.length))}, () => {`,
|
||||
);
|
||||
for (const entry of group.entries) {
|
||||
const safe: InventoryEntry = {
|
||||
...entry,
|
||||
navigationPath: safeNavigationPath(entry),
|
||||
};
|
||||
out.push(
|
||||
`\t\ttest(${js(testTitle(entry))}, async ({}, testInfo) => {`,
|
||||
`\t\t\tconst entry: InventoryEntry = ${js(safe)};`,
|
||||
'\t\t\tawait runEntry(entry, testInfo);',
|
||||
'\t\t});',
|
||||
);
|
||||
}
|
||||
out.push('\t});', '');
|
||||
}
|
||||
|
||||
out.push('});', '');
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function atomicWrite(path: string, body: string): void {
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, body, 'utf8');
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
if (opts.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
const inv = loadInventory(opts.inventory);
|
||||
const meta = loadMeta(opts.inventory);
|
||||
validate(inv, meta);
|
||||
|
||||
const groups = groupBySurface(inv.entries);
|
||||
const body = generateSpec(inv, meta, groups);
|
||||
atomicWrite(opts.output, body);
|
||||
|
||||
const testCount = inv.entries.length;
|
||||
process.stdout.write(
|
||||
`gen-render-specs: wrote ${opts.output}\n` +
|
||||
` ${testCount} test() across ${groups.length} test.describe() ` +
|
||||
`(${inv.deniedActions} denylisted)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
202
tools/test-harness/explore/probe-claudeai-ax.ts
Normal file
202
tools/test-harness/explore/probe-claudeai-ax.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Live AX-tree probe for the claudeai.ts migration. Connects to the
|
||||
// host's main-process Node inspector on :9229 (must be enabled via
|
||||
// "Developer → Enable Main Process Debugger"), pulls the claude.ai
|
||||
// AX tree, and reports what the page-object discrimination shapes
|
||||
// will actually see.
|
||||
//
|
||||
// Read-only — no clicks, no state mutation.
|
||||
//
|
||||
// Run: cd tools/test-harness && npx tsx explore/probe-claudeai-ax.ts
|
||||
|
||||
import { InspectorClient } from '../src/lib/inspector.js';
|
||||
import { axTreeToSnapshot, type RawElement } from './walker.js';
|
||||
|
||||
const INSPECTOR_PORT = 9229;
|
||||
const ROW_MORE_OPTIONS_RE = /^More options for /;
|
||||
const MENU_ITEM_ROLES = new Set([
|
||||
'menuitem',
|
||||
'menuitemradio',
|
||||
'menuitemcheckbox',
|
||||
]);
|
||||
|
||||
function landmarkTrail(el: RawElement): string {
|
||||
const trail = el.ancestors
|
||||
.filter((a) => a.role !== null)
|
||||
.map((a) => (a.name ? `${a.role}[${a.name}]` : (a.role as string)));
|
||||
return trail.join(' › ') || '<no ancestors>';
|
||||
}
|
||||
|
||||
function fmtElement(el: RawElement): string {
|
||||
const name = el.accessibleName ?? '<no-name>';
|
||||
const popup = el.hasPopup ?? '-';
|
||||
return (
|
||||
` • role=${el.computedRole} hasPopup=${popup} ` +
|
||||
`name=${JSON.stringify(name).slice(0, 90)}\n` +
|
||||
` landmarks: ${landmarkTrail(el)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const inspector = await InspectorClient.connect(INSPECTOR_PORT);
|
||||
try {
|
||||
// What URL is the renderer on right now?
|
||||
const url = await inspector.evalInRenderer<string>(
|
||||
'claude.ai',
|
||||
'(() => location.href)()',
|
||||
);
|
||||
process.stdout.write(`renderer URL: ${url}\n\n`);
|
||||
|
||||
const nodes = await inspector.getAccessibleTree('claude.ai');
|
||||
process.stdout.write(`raw AX nodes: ${nodes.length}\n`);
|
||||
const elements = axTreeToSnapshot(nodes);
|
||||
process.stdout.write(
|
||||
`interactive elements (post-filter): ${elements.length}\n\n`,
|
||||
);
|
||||
|
||||
// Bucket by role for a quick overall shape.
|
||||
const byRole = new Map<string, number>();
|
||||
for (const el of elements) {
|
||||
byRole.set(el.computedRole, (byRole.get(el.computedRole) ?? 0) + 1);
|
||||
}
|
||||
process.stdout.write('role histogram:\n');
|
||||
for (const [role, n] of [...byRole.entries()].sort()) {
|
||||
process.stdout.write(` ${role}: ${n}\n`);
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
// THE KEY QUESTION: do any buttons report hasPopup === 'menu'?
|
||||
// If yes, the migration's discrimination shape is sound. If no,
|
||||
// claude.ai exposes the popover trigger via a different AX
|
||||
// signal and we need a different filter.
|
||||
const buttonsWithPopup = elements.filter(
|
||||
(el) => el.computedRole === 'button' && el.hasPopup !== null,
|
||||
);
|
||||
process.stdout.write(
|
||||
`buttons with hasPopup set (any value): ${buttonsWithPopup.length}\n`,
|
||||
);
|
||||
const popupValues = new Map<string, number>();
|
||||
for (const b of buttonsWithPopup) {
|
||||
const v = b.hasPopup ?? '<null>';
|
||||
popupValues.set(v, (popupValues.get(v) ?? 0) + 1);
|
||||
}
|
||||
for (const [v, n] of [...popupValues.entries()].sort()) {
|
||||
process.stdout.write(` hasPopup="${v}": ${n}\n`);
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
// What findCompactPills() would return.
|
||||
const compactPills = elements.filter(
|
||||
(el) =>
|
||||
el.computedRole === 'button' &&
|
||||
el.hasPopup === 'menu' &&
|
||||
el.accessibleName !== null &&
|
||||
el.accessibleName.length > 0 &&
|
||||
!ROW_MORE_OPTIONS_RE.test(el.accessibleName),
|
||||
);
|
||||
process.stdout.write(
|
||||
`findCompactPills() would return ${compactPills.length} candidate(s):\n`,
|
||||
);
|
||||
for (const el of compactPills) process.stdout.write(`${fmtElement(el)}\n`);
|
||||
process.stdout.write('\n');
|
||||
|
||||
// What the row-more-options filter is dropping.
|
||||
const rowMore = elements.filter(
|
||||
(el) =>
|
||||
el.computedRole === 'button' &&
|
||||
el.hasPopup === 'menu' &&
|
||||
el.accessibleName !== null &&
|
||||
ROW_MORE_OPTIONS_RE.test(el.accessibleName),
|
||||
);
|
||||
process.stdout.write(
|
||||
`row-more-options filter dropped ${rowMore.length} button(s) ` +
|
||||
`(showing first 5):\n`,
|
||||
);
|
||||
for (const el of rowMore.slice(0, 5)) {
|
||||
process.stdout.write(`${fmtElement(el)}\n`);
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
// Top-level tabs: activateTab() looks for `role: 'button'` with
|
||||
// accessibleName === 'Chat' | 'Cowork' | 'Code'. Probe each one.
|
||||
process.stdout.write('top-level tab probe:\n');
|
||||
for (const name of ['Chat', 'Cowork', 'Code']) {
|
||||
const matches = elements.filter(
|
||||
(el) =>
|
||||
el.computedRole === 'button' && el.accessibleName === name,
|
||||
);
|
||||
process.stdout.write(` "${name}": ${matches.length} match(es)\n`);
|
||||
for (const el of matches) {
|
||||
process.stdout.write(
|
||||
` landmarks: ${landmarkTrail(el)} hasPopup=${el.hasPopup ?? '-'}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
// Open menu? Anything in MENU_ITEM_ROLES right now would mean a
|
||||
// menu happens to be open at probe time — useful context for
|
||||
// callers reading the output.
|
||||
const items = elements.filter((el) =>
|
||||
MENU_ITEM_ROLES.has(el.computedRole),
|
||||
);
|
||||
process.stdout.write(
|
||||
`menuitem* elements currently in tree: ${items.length}` +
|
||||
(items.length > 0 ? ' (a menu is open — surprise context)' : '') +
|
||||
'\n\n',
|
||||
);
|
||||
|
||||
// Diagnostic: is `properties[]` even being returned? Dump the
|
||||
// raw shape of the first button node and any node that has a
|
||||
// non-empty properties array, so we can tell whether
|
||||
// (a) Chromium isn't surfacing aria-haspopup, or
|
||||
// (b) properties[] is just absent from the response.
|
||||
const firstButton = nodes.find((n) => n.role?.value === 'button');
|
||||
if (firstButton) {
|
||||
process.stdout.write('first raw button AxNode (full JSON):\n');
|
||||
process.stdout.write(`${JSON.stringify(firstButton, null, 2)}\n\n`);
|
||||
}
|
||||
|
||||
const nodesWithProps = nodes.filter(
|
||||
(n) => Array.isArray(n.properties) && n.properties.length > 0,
|
||||
);
|
||||
process.stdout.write(
|
||||
`raw nodes with non-empty properties[]: ${nodesWithProps.length}\n`,
|
||||
);
|
||||
// Histogram of property names actually present.
|
||||
const propNames = new Map<string, number>();
|
||||
for (const n of nodesWithProps) {
|
||||
const props = n.properties as { name?: string }[];
|
||||
for (const p of props) {
|
||||
if (typeof p.name === 'string') {
|
||||
propNames.set(p.name, (propNames.get(p.name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, n] of [...propNames.entries()].sort()) {
|
||||
process.stdout.write(` property "${name}": ${n}\n`);
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
// Spot-check the model picker if visible — it should be the
|
||||
// canonical "menu trigger" on every surface.
|
||||
const modelLikely = elements.filter(
|
||||
(el) =>
|
||||
el.accessibleName !== null &&
|
||||
/^(Opus|Sonnet|Haiku|Claude)\b/i.test(el.accessibleName),
|
||||
);
|
||||
process.stdout.write(
|
||||
`model-picker-like elements (name starts with Opus/Sonnet/Haiku/Claude): ` +
|
||||
`${modelLikely.length}\n`,
|
||||
);
|
||||
for (const el of modelLikely.slice(0, 5)) {
|
||||
process.stdout.write(`${fmtElement(el)}\n`);
|
||||
}
|
||||
} finally {
|
||||
inspector.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`probe failed: ${err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
276
tools/test-harness/explore/snapshot.ts
Normal file
276
tools/test-harness/explore/snapshot.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
// Renderer-state capture for the explore CLI.
|
||||
//
|
||||
// Why a separate module: the snapshot shape is the contract diff.ts
|
||||
// reads against. Keeping the capture here (rather than inline in the
|
||||
// dispatcher) means a future format bump only touches two files and
|
||||
// the schema lives next to its sole producer.
|
||||
//
|
||||
// All discovery is by structural shape — never by minified Tailwind
|
||||
// class names. We anchor on:
|
||||
// - df-pills: button.df-pill[aria-label] (3 expected: Chat/Cowork/Code)
|
||||
// - compact pills: button[aria-haspopup="menu"] containing
|
||||
// span.truncate.max-w-[Npx] (env pill, Select-folder pill, …)
|
||||
// - aria-labeled buttons: any <button[aria-label]> for general drift
|
||||
// visibility (sidebar "more" buttons, header actions, modals).
|
||||
// - open menu: the role=menu currently in the DOM, plus its items.
|
||||
// - modals: role=dialog elements with aria-label/aria-labelledby.
|
||||
//
|
||||
// All renderer evals run in a single round-trip to keep snapshots
|
||||
// deterministic — async work between probes can shift the DOM.
|
||||
|
||||
import type { InspectorClient } from '../src/lib/inspector.js';
|
||||
|
||||
export interface DfPill {
|
||||
ariaLabel: string | null;
|
||||
text: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface CompactPillSnap {
|
||||
ariaLabel: string | null;
|
||||
text: string;
|
||||
maxW: string;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export interface AriaButton {
|
||||
ariaLabel: string;
|
||||
text: string;
|
||||
expanded: boolean | null;
|
||||
hasPopup: string | null;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
role: string;
|
||||
text: string;
|
||||
ariaChecked: string | null;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface OpenMenu {
|
||||
ariaLabelledBy: string | null;
|
||||
ariaLabel: string | null;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
export interface ModalSnap {
|
||||
ariaLabel: string | null;
|
||||
ariaLabelledBy: string | null;
|
||||
headingText: string | null;
|
||||
buttonLabels: string[];
|
||||
}
|
||||
|
||||
export interface PageState {
|
||||
url: string;
|
||||
title: string;
|
||||
readyState: string;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
capturedAt: string;
|
||||
claudeAiUrl: string;
|
||||
appVersion: string | null;
|
||||
pageState: PageState;
|
||||
dfPills: DfPill[];
|
||||
compactPills: CompactPillSnap[];
|
||||
ariaLabeledButtons: AriaButton[];
|
||||
openMenu: OpenMenu | null;
|
||||
modals: ModalSnap[];
|
||||
}
|
||||
|
||||
// Capture the renderer DOM into the canonical snapshot shape.
|
||||
// `claudeAiUrl` is recorded separately from pageState.url because the
|
||||
// pageState reflects the moment of capture and is useful for diff
|
||||
// triage; the top-level url anchors which webContents we hit.
|
||||
export async function capture(client: InspectorClient): Promise<Snapshot> {
|
||||
const target = await pickClaudeAiWebContents(client);
|
||||
const appVersion = await readAppVersion(client);
|
||||
const dom = await client.evalInRenderer<{
|
||||
pageState: PageState;
|
||||
dfPills: DfPill[];
|
||||
compactPills: CompactPillSnap[];
|
||||
ariaLabeledButtons: AriaButton[];
|
||||
openMenu: OpenMenu | null;
|
||||
modals: ModalSnap[];
|
||||
}>('claude.ai', RENDERER_CAPTURE_BODY);
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
claudeAiUrl: target,
|
||||
appVersion,
|
||||
pageState: dom.pageState,
|
||||
dfPills: dom.dfPills,
|
||||
compactPills: dom.compactPills,
|
||||
ariaLabeledButtons: dom.ariaLabeledButtons,
|
||||
openMenu: dom.openMenu,
|
||||
modals: dom.modals,
|
||||
};
|
||||
}
|
||||
|
||||
// Just the pills slice — used by `explore pills`. Reuses the same eval
|
||||
// body to avoid drift between subcommands.
|
||||
export async function capturePills(
|
||||
client: InspectorClient,
|
||||
): Promise<{
|
||||
dfPills: DfPill[];
|
||||
compactPills: CompactPillSnap[];
|
||||
pageState: PageState;
|
||||
}> {
|
||||
const dom = await client.evalInRenderer<{
|
||||
pageState: PageState;
|
||||
dfPills: DfPill[];
|
||||
compactPills: CompactPillSnap[];
|
||||
ariaLabeledButtons: AriaButton[];
|
||||
openMenu: OpenMenu | null;
|
||||
modals: ModalSnap[];
|
||||
}>('claude.ai', RENDERER_CAPTURE_BODY);
|
||||
return {
|
||||
dfPills: dom.dfPills,
|
||||
compactPills: dom.compactPills,
|
||||
pageState: dom.pageState,
|
||||
};
|
||||
}
|
||||
|
||||
// Just the open menu — used by `explore menu`.
|
||||
export async function captureOpenMenu(
|
||||
client: InspectorClient,
|
||||
): Promise<OpenMenu | null> {
|
||||
const dom = await client.evalInRenderer<{ openMenu: OpenMenu | null }>(
|
||||
'claude.ai',
|
||||
`(() => { ${OPEN_MENU_FN} return { openMenu: openMenu() }; })()`,
|
||||
);
|
||||
return dom.openMenu;
|
||||
}
|
||||
|
||||
async function pickClaudeAiWebContents(
|
||||
client: InspectorClient,
|
||||
): Promise<string> {
|
||||
const list = await client.evalInMain<Array<{ url: string }>>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
return webContents.getAllWebContents().map(w => ({ url: w.getURL() }));
|
||||
`);
|
||||
const target = list.find((w) => w.url.includes('claude.ai'));
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
'snapshot: no claude.ai webContents — open the app to a ' +
|
||||
'logged-in state first',
|
||||
);
|
||||
}
|
||||
return target.url;
|
||||
}
|
||||
|
||||
// app.getVersion() is the cleanest source of truth — same value the
|
||||
// app.asar serves at runtime. Returns null if the call shape ever
|
||||
// changes upstream rather than failing the whole snapshot.
|
||||
async function readAppVersion(
|
||||
client: InspectorClient,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return await client.evalInMain<string>(`
|
||||
const { app } = process.mainModule.require('electron');
|
||||
return app.getVersion();
|
||||
`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Single shared renderer-eval body. Definitions are inlined as IIFEs so
|
||||
// the whole capture is one round-trip. Truncation limits (text 200,
|
||||
// list 200) are wide enough for current claude.ai but bounded so a
|
||||
// future infinite-scroll regression doesn't blow up the JSON file.
|
||||
const OPEN_MENU_FN = `
|
||||
function openMenu() {
|
||||
const menu = document.querySelector('[role=menu][data-open]')
|
||||
|| document.querySelector('[role=menu]');
|
||||
if (!menu) return null;
|
||||
const items = Array.from(menu.querySelectorAll(
|
||||
'[role=menuitem], [role=menuitemradio], [role=menuitemcheckbox]'
|
||||
)).slice(0, 200).map(el => ({
|
||||
role: el.getAttribute('role') || '',
|
||||
text: (el.textContent || '').trim().slice(0, 200),
|
||||
ariaChecked: el.getAttribute('aria-checked'),
|
||||
disabled: el.hasAttribute('data-disabled')
|
||||
|| el.getAttribute('aria-disabled') === 'true',
|
||||
}));
|
||||
return {
|
||||
ariaLabelledBy: menu.getAttribute('aria-labelledby'),
|
||||
ariaLabel: menu.getAttribute('aria-label'),
|
||||
items,
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
const RENDERER_CAPTURE_BODY = `
|
||||
(() => {
|
||||
${OPEN_MENU_FN}
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const dfPills = buttons
|
||||
.filter(b => /\\bdf-pill\\b/.test(b.className))
|
||||
.map(b => ({
|
||||
ariaLabel: b.getAttribute('aria-label'),
|
||||
text: (b.textContent || '').trim().slice(0, 200),
|
||||
visible: !!b.getClientRects().length,
|
||||
}));
|
||||
const compactPills = buttons.flatMap(b => {
|
||||
if (b.getAttribute('aria-haspopup') !== 'menu') return [];
|
||||
const span = b.querySelector('span.truncate');
|
||||
if (!span) return [];
|
||||
const m = span.className.match(/max-w-\\[[^\\]]+\\]/);
|
||||
if (!m) return [];
|
||||
return [{
|
||||
ariaLabel: b.getAttribute('aria-label'),
|
||||
text: (span.textContent || '').trim().slice(0, 200),
|
||||
maxW: m[0],
|
||||
expanded: b.getAttribute('aria-expanded') === 'true',
|
||||
}];
|
||||
});
|
||||
const ariaLabeledButtons = buttons
|
||||
.filter(b => b.hasAttribute('aria-label'))
|
||||
.slice(0, 200)
|
||||
.map(b => ({
|
||||
ariaLabel: b.getAttribute('aria-label') || '',
|
||||
text: (b.textContent || '').trim().slice(0, 200),
|
||||
expanded: b.hasAttribute('aria-expanded')
|
||||
? b.getAttribute('aria-expanded') === 'true'
|
||||
: null,
|
||||
hasPopup: b.getAttribute('aria-haspopup'),
|
||||
visible: !!b.getClientRects().length,
|
||||
}));
|
||||
const modals = Array.from(
|
||||
document.querySelectorAll('[role=dialog]')
|
||||
).slice(0, 20).map(d => {
|
||||
const heading = d.querySelector(
|
||||
'h1, h2, h3, [role=heading]'
|
||||
);
|
||||
const btnLabels = Array.from(d.querySelectorAll('button'))
|
||||
.slice(0, 50)
|
||||
.map(b => {
|
||||
const al = b.getAttribute('aria-label');
|
||||
if (al) return al;
|
||||
return (b.textContent || '').trim().slice(0, 80);
|
||||
})
|
||||
.filter(s => s.length > 0);
|
||||
return {
|
||||
ariaLabel: d.getAttribute('aria-label'),
|
||||
ariaLabelledBy: d.getAttribute('aria-labelledby'),
|
||||
headingText: heading
|
||||
? (heading.textContent || '').trim().slice(0, 200)
|
||||
: null,
|
||||
buttonLabels: btnLabels,
|
||||
};
|
||||
});
|
||||
return {
|
||||
pageState: {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
readyState: document.readyState,
|
||||
},
|
||||
dfPills,
|
||||
compactPills,
|
||||
ariaLabeledButtons,
|
||||
openMenu: openMenu(),
|
||||
modals,
|
||||
};
|
||||
})()
|
||||
`;
|
||||
240
tools/test-harness/explore/walk-isolated.ts
Normal file
240
tools/test-harness/explore/walk-isolated.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// Drive a v7 walk inside the test harness's launch-with-isolation
|
||||
// path so the run lives in a per-launch tmpdir (auth seeded from the
|
||||
// host config) rather than the running host app's own profile.
|
||||
//
|
||||
// Why a separate driver instead of `explore walk`: the standalone CLI
|
||||
// connects to whatever Node inspector is already on :9229 — i.e. the
|
||||
// running host Claude Desktop. That path mutates the host profile
|
||||
// (visited surfaces, navigation history, route changes) and races
|
||||
// with the human at the keyboard. The launchClaude path here mirrors
|
||||
// what H05 / U01 do: kill any running host instance, copy auth into
|
||||
// a tmpdir, spawn a fresh Electron with isolated XDG_CONFIG_HOME,
|
||||
// attach the inspector via SIGUSR1, and tear everything down on
|
||||
// exit.
|
||||
//
|
||||
// Usage (matches `explore walk` flag set):
|
||||
// npx tsx explore/walk-isolated.ts --verbose --max-elements 2000
|
||||
//
|
||||
// Flags:
|
||||
// --max-elements N global cap (default 1000)
|
||||
// --max-drills-per-surface N per-surface drilling fan-out cap (default 50)
|
||||
// --checkpoint-every N write inventory every N entries (default 100)
|
||||
// --output PATH inventory output (default docs/testing/
|
||||
// ui-inventory.json)
|
||||
// --allowlist PATH JSON file with `exemptions: string[]`
|
||||
// --no-seed don't copy host auth — fresh sign-in
|
||||
// required (rare; default seeds from host)
|
||||
// --verbose walker chatter to stderr
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { launchClaude } from '../src/lib/electron.js';
|
||||
import { createIsolation } from '../src/lib/isolation.js';
|
||||
import { walkRenderer, WALKER_VERSION } from './walker.js';
|
||||
import type { Inventory } from './walker.js';
|
||||
|
||||
const TESTING_DIR = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'docs',
|
||||
'testing',
|
||||
);
|
||||
const INVENTORY_PATH = resolve(TESTING_DIR, 'ui-inventory.json');
|
||||
const INVENTORY_META_PATH = resolve(TESTING_DIR, 'ui-inventory.meta.json');
|
||||
const INVENTORY_TMP_SUFFIX = '.tmp';
|
||||
|
||||
interface Options {
|
||||
maxElements: number;
|
||||
maxDrillsPerSurface: number;
|
||||
checkpointEvery: number;
|
||||
allowlist: string | null;
|
||||
output: string;
|
||||
verbose: boolean;
|
||||
seed: boolean;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Options {
|
||||
const opts: Options = {
|
||||
maxElements: 1000,
|
||||
maxDrillsPerSurface: 50,
|
||||
checkpointEvery: 100,
|
||||
allowlist: null,
|
||||
output: INVENTORY_PATH,
|
||||
verbose: false,
|
||||
seed: true,
|
||||
help: false,
|
||||
};
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const a = args[i]!;
|
||||
if (a === '-h' || a === '--help') opts.help = true;
|
||||
else if (a === '--verbose') opts.verbose = true;
|
||||
else if (a === '--no-seed') opts.seed = false;
|
||||
else if (a === '--max-elements') {
|
||||
const n = Number(args[++i]);
|
||||
if (!Number.isFinite(n) || n < 0) die('--max-elements N (N≥0)');
|
||||
opts.maxElements = n;
|
||||
} else if (a === '--max-drills-per-surface') {
|
||||
const n = Number(args[++i]);
|
||||
if (!Number.isFinite(n) || n < 0) die('--max-drills-per-surface N');
|
||||
opts.maxDrillsPerSurface = n;
|
||||
} else if (a === '--checkpoint-every') {
|
||||
const n = Number(args[++i]);
|
||||
if (!Number.isInteger(n) || n < 0) die('--checkpoint-every N');
|
||||
opts.checkpointEvery = n;
|
||||
} else if (a === '--allowlist') {
|
||||
const p = args[++i];
|
||||
if (!p) die('--allowlist PATH');
|
||||
opts.allowlist = p;
|
||||
} else if (a === '--output') {
|
||||
const p = args[++i];
|
||||
if (!p) die('--output PATH');
|
||||
opts.output = resolve(p);
|
||||
} else {
|
||||
die(`unknown flag: ${a}`);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function die(msg: string): never {
|
||||
process.stderr.write(`walk-isolated: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
[
|
||||
'usage: npx tsx explore/walk-isolated.ts [flags]',
|
||||
'',
|
||||
'flags:',
|
||||
' --max-elements N global cap (default 1000)',
|
||||
' --max-drills-per-surface N drilling fan-out cap (default 50)',
|
||||
' --checkpoint-every N partial-write cadence (default 100; 0 disables)',
|
||||
' --output PATH inventory output path',
|
||||
' --allowlist PATH JSON { exemptions: string[] }',
|
||||
' --no-seed skip host-config auth seeding',
|
||||
' --verbose walker chatter on stderr',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
if (opts.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
let allowlist: string[] = [];
|
||||
if (opts.allowlist) {
|
||||
const raw = readFileSync(opts.allowlist, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { exemptions?: string[] };
|
||||
allowlist = parsed.exemptions ?? [];
|
||||
}
|
||||
|
||||
const outDir = dirname(opts.output);
|
||||
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
||||
const metaPath =
|
||||
opts.output === INVENTORY_PATH
|
||||
? INVENTORY_META_PATH
|
||||
: opts.output.replace(/\.json$/, '.meta.json');
|
||||
|
||||
const writeCheckpoint = (inventory: Inventory, isPartial: boolean): void => {
|
||||
const invTmp = `${opts.output}${INVENTORY_TMP_SUFFIX}`;
|
||||
writeFileSync(invTmp, JSON.stringify(inventory, null, 2) + '\n', 'utf8');
|
||||
renameSync(invTmp, opts.output);
|
||||
const meta = {
|
||||
capturedAt: inventory.capturedAt,
|
||||
appVersion: inventory.appVersion,
|
||||
walkerVersion: WALKER_VERSION,
|
||||
startUrl: inventory.startUrl,
|
||||
totalElements: inventory.totalElements,
|
||||
deniedActions: inventory.deniedActions,
|
||||
partial: isPartial,
|
||||
isolation: 'launchClaude (test-harness path)',
|
||||
seededFromHost: opts.seed,
|
||||
allowlistEntries: allowlist,
|
||||
};
|
||||
const metaTmp = `${metaPath}${INVENTORY_TMP_SUFFIX}`;
|
||||
writeFileSync(metaTmp, JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
renameSync(metaTmp, metaPath);
|
||||
};
|
||||
|
||||
process.stderr.write(
|
||||
`walk-isolated: creating isolation (seedFromHost=${opts.seed})\n`,
|
||||
);
|
||||
const isolation = await createIsolation({ seedFromHost: opts.seed });
|
||||
let app: Awaited<ReturnType<typeof launchClaude>> | null = null;
|
||||
try {
|
||||
process.stderr.write('walk-isolated: spawning Claude Desktop\n');
|
||||
app = await launchClaude({ isolation });
|
||||
process.stderr.write(
|
||||
'walk-isolated: waiting for claude.ai webContents (90s budget)\n',
|
||||
);
|
||||
const { inspector, claudeAiUrl } = await app.waitForReady('claudeAi');
|
||||
if (!claudeAiUrl) {
|
||||
throw new Error(
|
||||
'claude.ai webContents never loaded — host likely not signed in. ' +
|
||||
'Open Claude Desktop, sign in, fully close, and re-run.',
|
||||
);
|
||||
}
|
||||
process.stderr.write(`walk-isolated: at ${claudeAiUrl}\n`);
|
||||
|
||||
const inventory = await walkRenderer(inspector, {
|
||||
maxElements: opts.maxElements,
|
||||
maxDrillsPerSurface: opts.maxDrillsPerSurface,
|
||||
allowlist,
|
||||
verbose: opts.verbose,
|
||||
checkpointEvery: opts.checkpointEvery,
|
||||
checkpointWriter:
|
||||
opts.checkpointEvery > 0
|
||||
? (inv) => writeCheckpoint(inv, true)
|
||||
: undefined,
|
||||
});
|
||||
writeCheckpoint(inventory, false);
|
||||
process.stdout.write(
|
||||
`wrote ${opts.output} (${inventory.totalElements} entries, ` +
|
||||
`${inventory.deniedActions} denylisted)\n`,
|
||||
);
|
||||
process.stdout.write(`wrote ${metaPath}\n`);
|
||||
} finally {
|
||||
if (app) {
|
||||
try {
|
||||
await app.close();
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`walk-isolated: app.close() failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await isolation.cleanup();
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`walk-isolated: isolation.cleanup() failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`walk-isolated: ${msg}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
2405
tools/test-harness/explore/walker.ts
Normal file
2405
tools/test-harness/explore/walker.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user