diff --git a/.agents/skills/check-changes/SKILL.md b/.agents/skills/check-changes/SKILL.md index 7259d9a8..6b1a671f 100644 --- a/.agents/skills/check-changes/SKILL.md +++ b/.agents/skills/check-changes/SKILL.md @@ -32,11 +32,21 @@ release. Open `CHANGES.md` and extract every bullet under `## Development`. 4. **Cross-reference.** - For each commit that represents a user-visible change (type `feat`, `fix`, - `refactor`, or `perf`), check whether a corresponding bullet exists in the - Development section. A bullet does not need to quote the commit message - verbatim -- it only needs to describe the same change at a short summary - level. + `CHANGES.md` opens with a comment block stating what qualifies for an entry. + Read it first; it is authoritative and the rules below restate only what + this check needs. + + A commit needs a bullet when it adds something a user can use, changes + behaviour they would be surprised by, removes something, or fixes a bug + that was BROKEN IN A RELEASED VERSION. A bullet does not need to quote the + commit message -- it only needs to describe the same change. + + A commit does NOT need one when it is refactoring, a test, CI, an internal + rename, or a dependency bump with no user-visible effect. Nor when it fixes + a bug that never shipped: if the last released version did not have the + defect, the changelog has nothing to say about it. That case is common on a + long branch and is the one most often reported as a false gap -- check the + bug's origin before flagging a missing entry, not just the commit's type. 5. **Check each bullet against the length rule.** Per the "Changelog Entries" section of CONTRIBUTING.md, a bullet is at most @@ -45,15 +55,23 @@ release. ones that exceed it, with their count. Do not measure released `## Version` sections -- a section is frozen once cut and is never rewritten. -6. **Report findings.** +6. **Check the section against the entry-count limits.** + `CHANGES.md`'s comment block sets a soft limit of **10** bullets per version + section and a hard limit of **50**, counting every bullet across its + subsections. Report the count. Over the soft limit, name the entries that + look mergeable or cuttable. Over the hard limit, say plainly that the + release must not ship until they are merged or cut. + +7. **Report findings.** - List commits with NO changelog entry (missing entries -- these must be added). - List changelog bullets that appear to describe nothing in the commit history (stale or speculative entries -- flag for author review). - List bullets over the 25-word limit, each with its count, and propose a shorter wording. + - Report the section's bullet count against the soft and hard limits. - If everything matches, report "CHANGES.md is current." -7. **Do not edit CHANGES.md automatically.** +8. **Do not edit CHANGES.md automatically.** Propose the missing bullets in your report and let the developer apply them. The developer owns the changelog wording. diff --git a/.agents/skills/check-docs/SKILL.md b/.agents/skills/check-docs/SKILL.md index 57c0ac47..7b0afff3 100644 --- a/.agents/skills/check-docs/SKILL.md +++ b/.agents/skills/check-docs/SKILL.md @@ -45,8 +45,12 @@ Verify that all project documentation is accurate and up to date. (e.g., `1.0.0`) must match the version in root `package.json`. 6. **Check CHANGES.md structure.** - Confirm the file starts with a `## Development` section and that previous - release sections follow the `## Version X.Y.Z` heading format (no date). + Confirm the file carries its rules comment block, that `## Development` is + the first heading after it, and that previous release sections follow the + `## Version X.Y.Z` heading format (no date). The block sits above + `## Development` on purpose: `release-notes.mjs` extracts only from a + `## Version` heading to the next `## `, so anything above the first one + never reaches a GitHub release. 7. **Report.** List every issue found (missing files, broken nav links, stale commands, diff --git a/.agents/skills/check-fixture-repo/SKILL.md b/.agents/skills/check-fixture-repo/SKILL.md index 5a88ea49..ec9a845a 100644 --- a/.agents/skills/check-fixture-repo/SKILL.md +++ b/.agents/skills/check-fixture-repo/SKILL.md @@ -24,7 +24,7 @@ its own README explains what each fixture drives. ## Isolation -The suite never touches the developer's machine state. `e2e/src/cli.ts` is the +The suite never touches the developer's machine state. `e2e/cli/src/cli.ts` is the only way a spec can invoke the CLI, and it always sets **both**: - `XDG_CONFIG_HOME`, which relocates `state.json` and `config.yaml` @@ -42,13 +42,13 @@ the machine -- which is why the harness owns them rather than each spec. ### 1. Run the suite ```bash -pnpm test:e2e +pnpm test:e2e:cli ``` That is the whole check. The script behind it (`scripts/e2e-prepare.mjs`) initializes the fixture submodule, force-pulls it to the tip of its branch, and builds `target/debug/skillkeeper`; Jest then runs the -specs in `e2e/tests/`. +specs in `e2e/cli/tests/`. Set `SKILLKEEPER_E2E_PIN_FIXTURE=1` to run against the fixture commit this repository pins instead of pulling. CI does that for reproducibility; locally @@ -61,11 +61,13 @@ to look: | spec | covers | a failure means | |---|---|---| -| `e2e/tests/fixture.spec.ts` | the submodule is checked out, ASCII-only, and still has the manifests and file modes the rest of the suite assumes | the **fixture** drifted | -| `e2e/tests/skills.spec.ts` | resolution schemes, `.skid.yml` identity, nested body paths, selective `+x`, guidance precedence, hook merge and consent, the delimited-text region, and both silent-failure modes of the resolver | the **product** changed | -| `e2e/tests/mcp.spec.ts` | preset discovery including the group-scoped file, parameter substitution, both ledger files, the `.gitignore` guard for the secrets file, rules rendering, instance-name allocation, the Codex stdio-only skip, and removal | the **product** changed | -| `e2e/tests/repair.spec.ts` | `verify` -> `repair` -> `verify`, directory pruning, the bounds that keep repair inside the repaired skill, and uninstall reversing hooks and guidance | the **product** changed | -| `e2e/tests/requires.spec.ts` | skill dependencies: every `repo lint` code the `requires` group triggers, the single-document `--json` form, both target-misuse exits, the transitive install closure, and the uninstall breakage report | the **product** changed | +| `e2e/cli/tests/fixture.spec.ts` | the submodule is checked out, ASCII-only, and still has the manifests and file modes the rest of the suite assumes | the **fixture** drifted | +| `e2e/cli/tests/skills.spec.ts` | resolution schemes, `.skid.yml` identity, nested body paths, selective `+x`, guidance precedence, hook merge and consent, the delimited-text region, and both silent-failure modes of the resolver | the **product** changed | +| `e2e/cli/tests/mcp.spec.ts` | preset discovery including the group-scoped file, parameter substitution, both ledger files, the `.gitignore` guard for the secrets file, rules rendering, instance-name allocation, the Codex stdio-only skip, and removal | the **product** changed | +| `e2e/cli/tests/mcp-oauth.spec.ts` | the oauth preset's exact per-agent native shape, the copilot skip, that no agent's config ever carries a client secret, and `repo lint` on the deliberately invalid oauth preset | the **product** changed | +| `e2e/cli/tests/mcp-parameters.spec.ts` | link rendering, description truncation, option-value validation on install, and every mcp lint warning a description or parameter can trigger | the **product** changed | +| `e2e/cli/tests/repair.spec.ts` | `verify` -> `repair` -> `verify`, directory pruning, the bounds that keep repair inside the repaired skill, and uninstall reversing hooks and guidance | the **product** changed | +| `e2e/cli/tests/requires.spec.ts` | skill dependencies: every `repo lint` code the `requires` group triggers, the single-document `--json` form, both target-misuse exits, the transitive install closure, and the uninstall breakage report | the **product** changed | If `fixture.spec.ts` fails, fix or re-pin the fixture. If it passes and another spec fails, the CLI's behaviour moved and the fixture is telling you so. @@ -90,7 +92,7 @@ git -C examples/test-repo status --porcelain # expect clean ``` Every spec runs the CLI with throwaway `HOME` and `XDG_CONFIG_HOME` -(`e2e/src/cli.ts`), so a dirty tree here means a test wrote somewhere it should +(`e2e/cli/src/cli.ts`), so a dirty tree here means a test wrote somewhere it should not have -- a blocker, and a harness defect rather than a product one. Note that a force-pull may legitimately leave the submodule pointer moved; that @@ -103,7 +105,7 @@ commit the bump on its own. Jest already reports per-test results, so summarize rather than restate: ``` -pnpm test:e2e: PASS / FAIL (N passed, N failed of 51) +pnpm test:e2e:cli: PASS / FAIL (N passed, N failed of 67) failing spec(s): -> attributed to: fixture drift / product change / harness defect working tree clean after: yes / no diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml index 5870bd42..6ed40c61 100644 --- a/.github/workflows/backmerge.yml +++ b/.github/workflows/backmerge.yml @@ -21,7 +21,7 @@ jobs: open-backmerge-pr: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13ea5718..bede7f26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # Fetch LFS blobs (png/ttf/woff2): the icon generator and the font # assets read real bytes, not the LFS pointer text. @@ -82,6 +82,9 @@ jobs: - name: Type-check run: pnpm typecheck + - name: Check formatting + run: pnpm format:check + - name: Test with coverage (90% gate) # vitest run --coverage enforces the thresholds defined in vitest.config.ts. # The build fails if lines or branches fall below 90%. @@ -97,7 +100,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # Fetch LFS blobs (png/ttf/woff2): the icon generator and the font # assets read real bytes, not the LFS pointer text. @@ -228,7 +231,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # Nothing this job reads is LFS-tracked today, but fetch the blobs # anyway: if a fixture or an asset the CLI touches ever becomes @@ -262,10 +265,74 @@ jobs: # The suite drives only the CLI crate, which does not link the webview, so # the Tauri system libraries the `rust` job installs are not needed here. + # Explicitly the CLI half, not `pnpm test:e2e`: this job has no + # Playwright browser install, so the desktop half would fail with + # "browserType.launch: Executable doesn't exist" here. The desktop half + # runs in its own `desktop-e2e` job below, which does install Chromium. - name: Run the end-to-end suite # Pin the fixture to the commit this repository records: a CI run must be # reproducible and fail for reasons in the diff, not because the fixture # moved. Locally the same script force-pulls instead. env: SKILLKEEPER_E2E_PIN_FIXTURE: '1' - run: pnpm test:e2e + run: pnpm test:e2e:cli + + desktop-e2e: + name: Desktop UI (Playwright) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + lfs: false + submodules: false + + - name: Enable pnpm via corepack + run: corepack enable + + - name: Set up Node.js with pnpm cache + uses: actions/setup-node@v5 + with: + node-version: '24' + cache: pnpm + + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + + - name: Generate localization catalogs + run: pnpm run i18n + + # Keyed on the whole lockfile, not just the Playwright package version: + # a browser build is ~150MB, so caching it is worth doing, but the key + # deliberately errs coarse. Keying on a parsed-out Playwright version + # instead would risk a bump the parsing misses reusing a stale, + # mismatched browser binary; hashing the full lockfile instead means an + # occasional needless re-download on an unrelated dependency bump, + # which is the safe direction to be wrong in. + - name: Cache the Playwright browser + id: playwright-cache + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + # No separate "build the renderer" step here: playwright.config.ts + # chains `frontend:build` into the webServer command so every run + # boots a fresh bundle, and a build failure surfaces as the webServer + # failing to come up. A second, earlier build step would only repeat + # that work. + - name: Run the desktop UI suite + run: pnpm test:e2e:desktop + + - name: Upload the report on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1e100002..03e32b42 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -66,7 +66,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # No LFS and no submodules on purpose. CodeQL reads source, not # binary assets, and examples/test-repo is a fixture whose contents @@ -106,7 +106,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: lfs: false submodules: false diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b63ac1fc..2ac3db94 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -44,7 +44,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # mike needs full history plus the gh-pages branch it maintains. fetch-depth: 0 @@ -54,7 +54,7 @@ jobs: lfs: true - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - name: Configure git identity for mike run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 669afd4c..00dec0fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # check-tag-branch.mjs asks whether the tag is reachable from main or # develop, which needs the full history and those branch refs -- a @@ -143,7 +143,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # The desktop build needs real binary assets (LFS-tracked fonts and # icon source PNGs), not LFS pointer files. @@ -266,7 +266,7 @@ jobs: } >> "$GITHUB_ENV" - name: Build the app (Tauri) - uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 + uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 env: # macOS signing is injected via $GITHUB_ENV by "Configure Apple code # signing" above, and only when a certificate secret is set. The @@ -454,7 +454,7 @@ jobs: # dir (linux/windows) and the cross-target dir (macOS --target). # ----------------------------------------------------------------------- - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: skillkeeper-${{ github.ref_name }}-${{ matrix.target }} path: | @@ -488,7 +488,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Set up Node.js 22 uses: actions/setup-node@v5 @@ -503,7 +503,7 @@ jobs: run: node scripts/release-notes.mjs - name: Download all artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: path: dist-all diff --git a/.gitignore b/.gitignore index 12d327b4..a96206ce 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,17 @@ Thumbs.db # Storybook apps/desktop/storybook-static/ +# Playwright: `outputDir`'s default walks up from the config file's directory +# (e2e/desktop/) to the nearest package.json and appends test-results/ to +# THAT directory -- today that lands at the repository root (e2e/desktop/ has +# no package.json of its own), but that is one `e2e/desktop/package.json` away +# from moving to e2e/desktop/test-results/ (see e2e/cli/package.json for a +# sibling suite that already has one). No leading slash, so these match +# wherever it actually lands rather than assuming which. +test-results/ +playwright-report/ +blob-report/ + # Documentation site (mkdocs + uv): uv caches the tools globally, but keep any # local Python virtualenv and the rendered static site out of git. .venv/ diff --git a/AGENTS.md b/AGENTS.md index 031b0e3e..1c57784f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,16 +88,22 @@ All of these must pass before a pull request is ready. ### End-to-end suite ```bash -pnpm test:e2e # Jest, drives the built CLI against examples/test-repo +pnpm test:e2e:cli # Jest, drives the built CLI against examples/test-repo ``` Separate from the gate above because it needs the fixture submodule and a `cargo build`. It covers what the in-memory fakes cannot: the real binary against a real working tree. Run it after touching resolution, install, hooks, guidance, -or MCP. Specs live in `e2e/tests/`, the harness in `e2e/src/cli.ts`; the runner is +or MCP. Specs live in `e2e/cli/tests/`, the harness in `e2e/cli/src/cli.ts`; the runner is Jest (not Vitest) and the suite is scoped to CommonJS -- see [docs/development/development.md](./docs/development/development.md#end-to-end-tests). +There is a second, independent suite, `pnpm test:e2e:desktop` (Playwright, +drives the renderer against a scripted backend; no filesystem, git, or +network). `pnpm test:e2e` runs both in sequence. See the same section of +[docs/development/development.md](./docs/development/development.md#end-to-end-tests) +for its layout. + **Build the CLI with `cargo build -p skillkeeper-cli`, never a bare `cargo build`.** Two crates in this workspace produce a binary named `skillkeeper`: the CLI declares it as `[[bin]]`, and the desktop app crate @@ -108,7 +114,7 @@ Jest (not Vitest) and the suite is scoped to CommonJS -- see Run the desktop app from that path expecting the CLI and you get a GUI process waiting in the window event loop: no output, no error, indistinguishable from a hang. Building the CLI package puts the right binary back, cache or not, so -`pnpm test:e2e` is safe on this (it builds the CLI package and then asserts the +`pnpm test:e2e:cli` is safe on this (it builds the CLI package and then asserts the binary answers `--version`). A hand-run `./target/debug/skillkeeper` right after working on the desktop app is where this bites. @@ -317,7 +323,9 @@ more accurately. Design specs and implementation plans live in `.superpowers/` which is git-ignored. When planning a multi-step task, write a plan there first. The `superpowers:writing-plans` skill guides the process. CHANGES.md entries are -planned at the plan stage, not after the fact. +planned at the plan stage, not after the fact -- and planned against the rules +block at the top of `CHANGES.md`, which decides what earns an entry at all. +Most of a plan's tasks earn none. --- diff --git a/CHANGES.md b/CHANGES.md index 98dfd81d..9d66580e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,7 +1,52 @@ # SkillKeeper Changelog + + ## Development +## Version 0.7.1 + +### Changed + +- Adding a repository that fails now keeps the dialog open and states the + reason, instead of closing and leaving only a notification. + ## Version 0.7.0 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a3aad65..b140ce7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,10 @@ Every user-visible change gets a bullet under `## Development` in [`CHANGES.md`](CHANGES.md), grouped under `### Added`, `### Changed`, or `### Fixed`. +`CHANGES.md` opens with a comment block stating what qualifies for an entry and +what does not. That block is authoritative; read it before adding a bullet. The +rules below are additional, not a summary of it. + **Rules:** - Keep each bullet to 25 words or fewer, counted as whitespace-separated diff --git a/Cargo.lock b/Cargo.lock index 2b46cfb6..47a2c391 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1278,7 +1278,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -4235,7 +4235,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skillkeeper" -version = "0.7.0" +version = "0.7.1" dependencies = [ "aes 0.9.3", "argon2", @@ -4270,14 +4270,14 @@ dependencies = [ [[package]] name = "skillkeeper-agents" -version = "0.7.0" +version = "0.7.1" dependencies = [ "skillkeeper-core", ] [[package]] name = "skillkeeper-cli" -version = "0.7.0" +version = "0.7.1" dependencies = [ "clap", "serde", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "skillkeeper-config" -version = "0.7.0" +version = "0.7.1" dependencies = [ "serde", "serde_json", @@ -4302,7 +4302,7 @@ dependencies = [ [[package]] name = "skillkeeper-core" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hex", "regex", @@ -4312,7 +4312,7 @@ dependencies = [ "serde_yaml_ng", "sha2 0.11.0", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "ts-rs", ] @@ -4811,7 +4811,7 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "url", ] @@ -4897,7 +4897,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4912,7 +4912,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", ] [[package]] @@ -5098,9 +5098,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap 2.14.0", "serde_core", diff --git a/Cargo.toml b/Cargo.toml index 1c061c9d..a73910f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ [workspace.package] edition = "2021" -version = "0.7.0" +version = "0.7.1" license = "Apache-2.0" [workspace.dependencies] diff --git a/LICENSE b/LICENSE index e30f41c4..361154f0 100644 --- a/LICENSE +++ b/LICENSE @@ -228,15 +228,15 @@ this list current and accurate. | Package | Ecosystem | Version | License | Copyright | |---|---|---|---|---| -| aes | cargo | 0.9.2 | MIT OR Apache-2.0 | Copyright (c) RustCrypto Developers | -| argon2 | cargo | 0.5.3 | MIT OR Apache-2.0 | Copyright (c) RustCrypto Developers | +| aes | cargo | 0.9.3 | MIT OR Apache-2.0 | Copyright (c) RustCrypto Developers | +| argon2 | cargo | 0.6.0 | MIT OR Apache-2.0 | Copyright (c) RustCrypto Developers | | cbc | cargo | 0.2.1 | MIT OR Apache-2.0 | Copyright (c) RustCrypto Developers | | cipher | cargo | 0.5.2 | MIT OR Apache-2.0 | Copyright (c) RustCrypto Developers | | clap | cargo | 4.6.6 | MIT OR Apache-2.0 | Copyright (c) clap contributors | | gettext | cargo | 0.4.0 | MIT | Copyright (c) Justinas Stankevicius | | hex | cargo | 0.4.3 | MIT OR Apache-2.0 | Copyright (c) KokaKiwi | | hmac | cargo | 0.13.0 | MIT OR Apache-2.0 | Copyright (c) RustCrypto Developers | -| interprocess | cargo | 2.4.3 | 0BSD OR Apache-2.0 | Copyright (c) Goat | +| interprocess | cargo | 2.4.4 | 0BSD OR Apache-2.0 | Copyright (c) Goat | | objc2 | cargo | 0.6.4 | MIT | Copyright (c) Mads Marquart | | objc2-app-kit | cargo | 0.3.2 | Zlib OR Apache-2.0 OR MIT | Copyright (c) Mads Marquart | | objc2-foundation | cargo | 0.3.2 | MIT | Copyright (c) Mads Marquart | @@ -250,19 +250,19 @@ this list current and accurate. | sha2 | cargo | 0.11.0 | MIT OR Apache-2.0 | Copyright (c) RustCrypto Developers | | ssh-key | cargo | 0.6.7 | Apache-2.0 OR MIT | Copyright (c) RustCrypto Developers | | tauri | cargo | 2.11.5 | Apache-2.0 OR MIT | Copyright (c) Tauri Programme within The Commons Conservancy | -| tauri-plugin-clipboard-manager | cargo | 2.3.2 | Apache-2.0 OR MIT | Copyright (c) Tauri Programme within The Commons Conservancy | -| tauri-plugin-dialog | cargo | 2.7.2 | Apache-2.0 OR MIT | Copyright (c) Tauri Programme within The Commons Conservancy | +| tauri-plugin-clipboard-manager | cargo | 2.3.3 | Apache-2.0 OR MIT | Copyright (c) Tauri Programme within The Commons Conservancy | +| tauri-plugin-dialog | cargo | 2.7.3 | Apache-2.0 OR MIT | Copyright (c) Tauri Programme within The Commons Conservancy | | thiserror | cargo | 2.0.20 | MIT OR Apache-2.0 | Copyright (c) David Tolnay | -| toml | cargo | 0.8.2 | MIT OR Apache-2.0 | Copyright (c) Alex Crichton and toml-rs contributors | +| toml | cargo | 1.1.5+spec-1.1.0 | MIT OR Apache-2.0 | Copyright (c) Alex Crichton and toml-rs contributors | | ts-rs | cargo | 12.0.1 | MIT | Copyright (c) Moritz Bischof | | ureq | cargo | 3.4.0 | MIT OR Apache-2.0 | Copyright (c) Martin Algesten and Jacob Hoffman-Andrews | -| uuid | cargo | 1.24.1 | Apache-2.0 OR MIT | Copyright (c) Ashley Mannix and uuid contributors | +| uuid | cargo | 1.26.0 | Apache-2.0 OR MIT | Copyright (c) Ashley Mannix and uuid contributors | | zeroize | cargo | 1.9.0 | Apache-2.0 OR MIT | Copyright (c) The RustCrypto Project Developers | | @tauri-apps/api | npm | 2.11.1 | Apache-2.0 OR MIT | Copyright (c) Tauri Programme within The Commons Conservancy | -| @tauri-apps/plugin-clipboard-manager | npm | 2.3.2 | MIT OR Apache-2.0 | Copyright (c) Tauri Programme within The Commons Conservancy | +| @tauri-apps/plugin-clipboard-manager | npm | 2.3.3 | MIT OR Apache-2.0 | Copyright (c) Tauri Programme within The Commons Conservancy | | @xterm/addon-fit | npm | 0.11.0 | MIT | Copyright (c) 2019 The xterm.js authors | | @xterm/xterm | npm | 6.0.0 | MIT | Copyright (c) 2017-2019 The xterm.js authors | -| motion | npm | 13.1.0 | MIT | Copyright (c) Matt Perry | +| motion | npm | 13.2.0 | MIT | Copyright (c) Matt Perry | | react | npm | 19.2.8 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | | react-dom | npm | 19.2.8 | MIT | Copyright (c) Meta Platforms, Inc. and affiliates. | | zustand | npm | 5.0.15 | MIT | Copyright (c) Paul Henschel | diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 59152491..ae4e3d41 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@skillkeeper/desktop", - "version": "0.7.0", + "version": "0.7.1", "private": true, "description": "Install and manage skills and hooks for AI coding agents", "author": { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 3f509a3b..e1945134 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "SkillKeeper", - "version": "0.7.0", + "version": "0.7.1", "identifier": "dev.lorem.skillkeeper", "build": { "beforeDevCommand": "pnpm exec vite", diff --git a/apps/desktop/src/renderer/app/App.tsx b/apps/desktop/src/renderer/app/App.tsx index c9134725..98750c34 100644 --- a/apps/desktop/src/renderer/app/App.tsx +++ b/apps/desktop/src/renderer/app/App.tsx @@ -292,6 +292,7 @@ export function App() {
@@ -302,7 +303,13 @@ export function App() { drag/traffic-light zone, so it renders a draggable panel there. */} {NAV_ITEMS.map(({ id, key }) => ( - } active={activeView === id} onClick={() => goTo(id)}> + } + active={activeView === id} + onClick={() => goTo(id)} + data-testid={`nav-${id}`} + > {t(key)} ))} @@ -314,6 +321,7 @@ export function App() { icon={} className={cx('sk-sidebar-item--group', skillsOpen && 'sk-sidebar-item--group--open')} onClick={() => setSkillsOpen((open) => !open)} + data-testid="nav-group-skills" > {t('nav.skills')} @@ -332,6 +340,7 @@ export function App() { className="sk-sidebar-item--sub" active={activeView === 'skills-components'} onClick={() => goTo('skills-components')} + data-testid="nav-skills-components" > {t('skills.componentsTitle')} @@ -339,6 +348,7 @@ export function App() { className="sk-sidebar-item--sub" active={activeView === 'skills-management'} onClick={() => goTo('skills-management')} + data-testid="nav-skills-management" > {t('skills.managementTitle')} @@ -356,6 +366,7 @@ export function App() { icon={} className={cx('sk-sidebar-item--group', mcpOpen && 'sk-sidebar-item--group--open')} onClick={() => setMcpOpen((open) => !open)} + data-testid="nav-group-mcp" > {t('nav.mcp')} @@ -374,6 +385,7 @@ export function App() { className="sk-sidebar-item--sub" active={activeView === 'mcp-components'} onClick={() => goTo('mcp-components')} + data-testid="nav-mcp-components" > {t('mcp.componentsTitle')} @@ -381,6 +393,7 @@ export function App() { className="sk-sidebar-item--sub" active={activeView === 'mcp-management'} onClick={() => goTo('mcp-management')} + data-testid="nav-mcp-management" > {t('mcp.managementTitle')} @@ -392,6 +405,7 @@ export function App() { icon={} active={activeView === 'settings'} onClick={() => goTo('settings')} + data-testid="nav-settings" > {t('nav.settings')} diff --git a/apps/desktop/src/renderer/entities/project/ui/ProjectCard.tsx b/apps/desktop/src/renderer/entities/project/ui/ProjectCard.tsx index 434b449c..486b3b92 100644 --- a/apps/desktop/src/renderer/entities/project/ui/ProjectCard.tsx +++ b/apps/desktop/src/renderer/entities/project/ui/ProjectCard.tsx @@ -101,7 +101,7 @@ export function ProjectCard({ }: ProjectCardProps) { const washHue = hueFromName(project.name); return ( - + {/* Decorative left wash: a blurred, scaled copy of the project icon when there is one, else a soft colour field keyed to the project name. It fades to transparent toward the centre. The name-keyed gradient is @@ -135,7 +135,9 @@ export function ProjectCard({
- {truncateEnd(project.name, NAME_MAX)} + + {truncateEnd(project.name, NAME_MAX)} + {missing === true && ( - + )} @@ -191,7 +197,9 @@ export function ProjectCard({ )} {agentsLabel !== undefined && ( - {agentsLabel} + + {agentsLabel} + )} diff --git a/apps/desktop/src/renderer/entities/repository/ui/RepositoryCard.tsx b/apps/desktop/src/renderer/entities/repository/ui/RepositoryCard.tsx index 3b202d58..1077c01a 100644 --- a/apps/desktop/src/renderer/entities/repository/ui/RepositoryCard.tsx +++ b/apps/desktop/src/renderer/entities/repository/ui/RepositoryCard.tsx @@ -119,7 +119,9 @@ export function RepositoryCard({
- {repository.name} + + {repository.name} + {indicatorKey !== null && ( diff --git a/apps/desktop/src/renderer/entities/skill/lib/skillTree.tsx b/apps/desktop/src/renderer/entities/skill/lib/skillTree.tsx index c25e8b4c..64a79296 100644 --- a/apps/desktop/src/renderer/entities/skill/lib/skillTree.tsx +++ b/apps/desktop/src/renderer/entities/skill/lib/skillTree.tsx @@ -174,13 +174,39 @@ export function buildRepoTree(available: readonly AvailableSkill[], repos: reado const children = nestByGroup(skills, { groupOf: (s) => s.group, compare: byName, - makeLeaves: (s) => [{ id: repoSkillKey(repo.id, s.group, s.name), label: s.name, icon: skillIcon }], - makeGroup: (path, label, kids) => ({ - id: repoGroupNodeId(repo.id, path), - label, - icon: groupIcon, - children: kids, - }), + makeLeaves: (s) => { + const id = repoSkillKey(repo.id, s.group, s.name); + return [ + { + id, + label: s.name, + icon: skillIcon, + // e2e (flows 2/3/11, `skills.spec.ts`): the browse tree's skill row. + rowTestId: 'skill-row', + // The leaf's own unique tree-node id, never a bare name -- see + // `mcpTree.tsx`'s "ROW IDENTITY" comment. Two repositories can + // each offer a skill of the same name (or the same skill can show + // once under Global and once under a project), and a bare name + // would make `[data-skill-id="..."]` match more than one row in + // exactly those cases. + identity: { attr: 'skill-id', value: id }, + }, + ]; + }, + makeGroup: (path, label, kids) => { + const id = repoGroupNodeId(repo.id, path); + return { + id, + label, + icon: groupIcon, + children: kids, + // e2e (flow 2): a group or nested group in the browse tree. + rowTestId: 'skill-group', + // The group's own unique tree-node id, not the repo-relative path + // alone -- same reasoning as the leaf identity above. + identity: { attr: 'group-id', value: id }, + }; + }, }); nodes.push({ id: repoNodeId(repo.id), label: repo.name, icon: repoIcon, selectable: false, children }); @@ -213,13 +239,26 @@ export function buildProjectTree( const children = nestByGroup(skills, { groupOf: (s) => s.group, compare: byName, - makeLeaves: (s) => [ - { - id: projectSkillKey(scope.id, repo.id, s.group, s.name), - label: s.name, - icon: skillIcon, - }, - ], + makeLeaves: (s) => { + const id = projectSkillKey(scope.id, repo.id, s.group, s.name); + return [ + { + id, + label: s.name, + icon: skillIcon, + // e2e (flows 3/11): `buildProjectTree` is used ONLY by + // `SkillInstallModal`'s own step-2 tree, so its leaf carries the + // modal-scoped checkbox id rather than the browse tree's `skill-row`. + rowTestId: 'skill-install-checkbox', + // The leaf's own unique tree-node id, never a bare name -- see + // `mcpTree.tsx`'s "ROW IDENTITY" comment. This tree spans ALL + // selected repositories (`SkillInstallModal`'s step 2), so two + // repositories offering a same-named skill would otherwise make + // `[data-skill-id="..."]` match more than one row. + identity: { attr: 'skill-id', value: id }, + }, + ]; + }, makeGroup: (path, label, kids) => ({ id: projectGroupNodeId(scope.id, repo.id, path), label, @@ -452,6 +491,16 @@ export function buildProjectModel( label: entry.name, icon: skillIcon, muted: status === 'orphan', + // e2e (flow 2, `skills.spec.ts`): the Skills Management page's browse + // tree's skill row -- same kind as `buildRepoTree`'s leaf, since both + // are a row representing a skill in a catalog tree. + rowTestId: 'skill-row', + // The leaf's own unique tree-node id, never a bare name -- see + // `mcpTree.tsx`'s "ROW IDENTITY" comment. This tree spans Global and + // every tracked project, so the same skill name can legitimately + // show once per scope; a bare name would make + // `[data-skill-id="..."]` match more than one row. + identity: { attr: 'skill-id', value: leafId }, }; }; @@ -459,13 +508,21 @@ export function buildProjectModel( groupOf: (it) => it.entry.group, compare: (a, b) => a.entry.name.localeCompare(b.entry.name), makeLeaves: (it) => [makeLeaf(it)], - makeGroup: (path, label, kids) => ({ - id: projectGroupNodeId(scope.id, repoId, path), - label, - icon: groupIcon, - muted: kids.every((k) => k.muted === true), - children: kids, - }), + makeGroup: (path, label, kids) => { + const id = projectGroupNodeId(scope.id, repoId, path); + return { + id, + label, + icon: groupIcon, + muted: kids.every((k) => k.muted === true), + children: kids, + // e2e (flow 2): a group or nested group in the browse tree. + rowTestId: 'skill-group', + // The group's own unique tree-node id, not the repo-relative path + // alone -- same reasoning as the leaf identity above. + identity: { attr: 'group-id', value: id }, + }; + }, }); repoNodes.push({ diff --git a/apps/desktop/src/renderer/features/mcpInstall/ui/McpInstallModal.tsx b/apps/desktop/src/renderer/features/mcpInstall/ui/McpInstallModal.tsx index a6c4ee33..f2b806b6 100644 --- a/apps/desktop/src/renderer/features/mcpInstall/ui/McpInstallModal.tsx +++ b/apps/desktop/src/renderer/features/mcpInstall/ui/McpInstallModal.tsx @@ -175,10 +175,16 @@ export function McpInstallModal({ onClose={busy ? () => {} : onClose} title={t('mcp.installTitle', { name: preset.name })} className="sk-mcp-install" + data-testid="mcp-install-modal" >
{serverSpans !== undefined && ( - + )}
diff --git a/apps/desktop/src/renderer/features/mcpInstall/ui/McpUpdateParamsModal.tsx b/apps/desktop/src/renderer/features/mcpInstall/ui/McpUpdateParamsModal.tsx index fa1d844a..358d64da 100644 --- a/apps/desktop/src/renderer/features/mcpInstall/ui/McpUpdateParamsModal.tsx +++ b/apps/desktop/src/renderer/features/mcpInstall/ui/McpUpdateParamsModal.tsx @@ -100,7 +100,13 @@ export function McpUpdateParamsModal({ } return ( - +
{t('mcp.field.parameters')} @@ -110,8 +116,19 @@ export function McpUpdateParamsModal({ const paramSpans = spansForParam(preset, descriptionSpans, param); const value = values[param] ?? ''; return ( -
diff --git a/apps/desktop/src/renderer/features/projectAdd/ui/ProjectAddButton.tsx b/apps/desktop/src/renderer/features/projectAdd/ui/ProjectAddButton.tsx index 6c39e9eb..14229df9 100644 --- a/apps/desktop/src/renderer/features/projectAdd/ui/ProjectAddButton.tsx +++ b/apps/desktop/src/renderer/features/projectAdd/ui/ProjectAddButton.tsx @@ -31,7 +31,7 @@ export function ProjectAddButton() { } return ( - ); diff --git a/apps/desktop/src/renderer/features/repoAdd/lib/notificationCutoff.test.ts b/apps/desktop/src/renderer/features/repoAdd/lib/notificationCutoff.test.ts new file mode 100644 index 00000000..573b3f7d --- /dev/null +++ b/apps/desktop/src/renderer/features/repoAdd/lib/notificationCutoff.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import type { NotificationEntry } from '@/app/store'; +import { newestErrorSince } from './notificationCutoff'; + +function entry(id: string, level: NotificationEntry['level']): NotificationEntry { + return { id, level, text: id, at: '2026-01-01T00:00:00.000Z' }; +} + +describe('newestErrorSince', () => { + it('finds the newest error logged after the given id', () => { + const notifications = [entry('a', 'info'), entry('b', 'error'), entry('c', 'info'), entry('d', 'error')]; + expect(newestErrorSince(notifications, 'b')).toEqual(entry('d', 'error')); + }); + + it('returns undefined when nothing after the given id is an error', () => { + const notifications = [entry('a', 'error'), entry('b', 'info')]; + expect(newestErrorSince(notifications, 'a')).toBeUndefined(); + }); + + it('searches the whole log when there was nothing logged before', () => { + const notifications = [entry('a', 'info'), entry('b', 'error')]; + expect(newestErrorSince(notifications, undefined)).toEqual(entry('b', 'error')); + }); + + // Regression: `store.ts`'s notification log caps at `NOTIFICATION_LOG_LIMIT` + // (500) -- `notify`'s `set()` does `.slice(-500)`, so once the log is full, + // appending an entry drops the oldest one instead of growing the array. A + // caller that snapshotted the pre-submit LENGTH (instead of an id) and later + // sliced from that same numeric index would find the array's length + // unchanged (still exactly the cap) and read back an empty slice -- silently + // losing the very error it was looking for. Anchoring on the id of the last + // entry present before the submit must keep working even though that entry + // has since shifted to a lower index. + it('still finds the error once the log is full and has shifted past the cap', () => { + const full = Array.from({ length: 500 }, (_, i) => entry(`old-${i}`, 'info')); + const lastIdBefore = full[full.length - 1]?.id; + // Simulate `notify` appending one more entry and re-capping to 500: the + // oldest entry is dropped and everything else shifts one index left. + const afterSubmit = [...full.slice(1), entry('new-error', 'error')]; + expect(afterSubmit).toHaveLength(500); + expect(newestErrorSince(afterSubmit, lastIdBefore)).toEqual(entry('new-error', 'error')); + }); +}); diff --git a/apps/desktop/src/renderer/features/repoAdd/lib/notificationCutoff.ts b/apps/desktop/src/renderer/features/repoAdd/lib/notificationCutoff.ts new file mode 100644 index 00000000..46780514 --- /dev/null +++ b/apps/desktop/src/renderer/features/repoAdd/lib/notificationCutoff.ts @@ -0,0 +1,28 @@ +import type { NotificationEntry } from '@/app/store'; + +/** + * The newest `level: 'error'` entry logged strictly after `lastIdBefore` -- + * the id of whatever was the newest entry in the notification log immediately + * before an operation started (`undefined` when the log was empty then). + * + * This exists instead of a saved snapshot of the log's LENGTH because + * `store.ts`'s notification log is capped (`NOTIFICATION_LOG_LIMIT`, 500): + * `notify`'s `set()` call does `.slice(-NOTIFICATION_LOG_LIMIT)`, so once the + * log is full, appending one entry drops the oldest instead of growing the + * array. A caller that snapshotted the pre-operation LENGTH and later sliced + * the post-operation array from that same numeric index gets `[]` the moment + * the log is full at the time it snapshots -- the array's length never grows + * past the cap, so "everything after index `lengthBefore`" is empty even + * though a new entry really was appended (see this module's test). Anchoring + * on the last entry's id instead survives the log shifting underneath it: the + * entry may have moved to a lower index (or fallen out of the log entirely, + * if enough entries were appended since), but everything genuinely new is + * still everything after it. + */ +export function newestErrorSince( + notifications: readonly NotificationEntry[], + lastIdBefore: string | undefined, +): NotificationEntry | undefined { + const cutAt = lastIdBefore === undefined ? 0 : Math.max(0, notifications.findIndex((n) => n.id === lastIdBefore) + 1); + return notifications.slice(cutAt).find((n) => n.level === 'error'); +} diff --git a/apps/desktop/src/renderer/features/repoAdd/ui/RepoAddButton.tsx b/apps/desktop/src/renderer/features/repoAdd/ui/RepoAddButton.tsx index ee21a33c..568e9457 100644 --- a/apps/desktop/src/renderer/features/repoAdd/ui/RepoAddButton.tsx +++ b/apps/desktop/src/renderer/features/repoAdd/ui/RepoAddButton.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useSkillkeeperStore } from '@/app/store'; import { useTranslator } from '@/systems/i18n'; import { deriveRepoName, MAX_REPO_NAME_LENGTH } from '@/entities/repository'; +import { resolveNotification } from '@/systems/notifications'; import { Button, Modal, TextField } from '@/shared/ui'; import { asSchemeUrl, scpPortMistake } from '../lib/remoteHint'; +import { newestErrorSince } from '../lib/notificationCutoff'; import './RepoAddButton.scss'; /** @@ -25,11 +27,23 @@ export function RepoAddButton() { const [url, setUrl] = useState(''); const [name, setName] = useState(''); const [nameEdited, setNameEdited] = useState(false); + const [submitting, setSubmitting] = useState(false); + // Set only when `addRepository` itself failed (the `repositories_add` + // command, before any row could exist) -- a clone failure that happens + // AFTER the repository record was created leaves the row in place with its + // own error indicator instead (see RepositoryCard's error dot), and this + // form has already closed by then. + const [submitError, setSubmitError] = useState(null); + // Bumped on cancel/reopen so a submit's `.then`/`.catch` -- resolving after + // the user has already dismissed or restarted the form -- never applies its + // (now stale) outcome to a different attempt's state. + const submitToken = useRef(0); const reset = (): void => { setUrl(''); setName(''); setNameEdited(false); + setSubmitError(null); }; // Open prefilled when another page requests adding a repo (e.g. an unlinked @@ -44,12 +58,15 @@ export function RepoAddButton() { }, [addRepoRequest, clearAddRepoRequest]); const cancel = (): void => { + submitToken.current += 1; setOpen(false); + setSubmitting(false); reset(); }; const onUrlChange = (value: string): void => { setUrl(value); + setSubmitError(null); if (!nameEdited) setName(deriveRepoName(value)); }; @@ -60,20 +77,68 @@ export function RepoAddButton() { const showError = url.trim() !== '' && !valid; const submit = (): void => { - if (!valid) return; - void addRepository(url.trim(), name.trim()); - setOpen(false); - reset(); + if (!valid || submitting) return; + const trimmedUrl = url.trim(); + const trimmedName = name.trim(); + const token = (submitToken.current += 1); + setSubmitting(true); + setSubmitError(null); + // `addRepository` (app/store/store.ts) chains add -> clone -> describe and + // never rejects on a backend failure -- it calls `notify` and resolves. + // The only way to tell success from an add-level failure back here is to + // check, once it settles, whether a row for THIS submit actually landed: + // `notify`'s `set()` calls happen synchronously inside that same async + // chain, so by the time this await resolves the store already reflects + // the outcome. Matching by url ALONE is not enough -- a "duplicate" add + // failure (the real backend's answer for a URL already tracked, see + // `add` in `apps/desktop/src-tauri/src/commands/repositories.rs`) leaves + // a pre-existing row with that same url in place, which a bare url match + // would misread as this submit's own success. Snapshotting the ids + // present before the call and requiring the matched row's id to be NEW + // rules that out. + const idsBefore = new Set(useSkillkeeperStore.getState().repositories.map((r) => r.id)); + // The id of the newest notification logged so far, not the log's length: + // `notify` (`app/store/store.ts`) caps the log at `NOTIFICATION_LOG_LIMIT` + // (500), so once it is full the array's length stays put while entries + // shift left as new ones are appended -- a length snapshot would then read + // back an empty slice for a genuinely new entry (see `newestErrorSince`'s + // doc comment). Anchoring on this entry's id survives that shift. + const lastNotificationIdBefore = useSkillkeeperStore.getState().notifications.at(-1)?.id; + void addRepository(trimmedUrl, trimmedName) + .then(() => { + // The user cancelled or restarted the form before this settled -- + // applying its outcome now would stomp a different attempt's state. + if (submitToken.current !== token) return; + const state = useSkillkeeperStore.getState(); + const wasAdded = state.repositories.some((r) => r.url === trimmedUrl && !idsBefore.has(r.id)); + setSubmitting(false); + if (wasAdded) { + setOpen(false); + reset(); + return; + } + // Not added: the failure is the newest error `notify`d since this + // submit started (the add call notifies with the raw backend error, + // never a rejection -- see the comment above). + const failure = newestErrorSince(state.notifications, lastNotificationIdBefore); + setSubmitError(failure !== undefined ? resolveNotification(failure, t) : ''); + }) + .catch((err: unknown) => { + if (submitToken.current !== token) return; + setSubmitting(false); + setSubmitError(err instanceof Error ? err.message : String(err)); + }); }; return ( <> - -
+
onUrlChange(e.target.value)} @@ -91,11 +156,16 @@ export function RepoAddButton() { setName(e.target.value); }} /> + {submitError !== null && ( +

+ {submitError} +

+ )}
-
diff --git a/apps/desktop/src/renderer/features/skillInstall/ui/SkillInstallModal.tsx b/apps/desktop/src/renderer/features/skillInstall/ui/SkillInstallModal.tsx index c6434014..f6e4cab3 100644 --- a/apps/desktop/src/renderer/features/skillInstall/ui/SkillInstallModal.tsx +++ b/apps/desktop/src/renderer/features/skillInstall/ui/SkillInstallModal.tsx @@ -152,6 +152,10 @@ export function SkillInstallModal({ open, onClose, skillKeys }: SkillInstallModa ); else detail = undefined; @@ -205,6 +209,7 @@ export function SkillInstallModal({ open, onClose, skillKeys }: SkillInstallModa onClose={busy ? () => {} : onClose} title={t('skills.install.title')} className={step === 'tree' ? 'sk-skill-modal sk-skill-modal--wide' : 'sk-skill-modal'} + data-testid="skill-install-modal" > {step === 'project' ? (
@@ -261,7 +266,16 @@ export function SkillInstallModal({ open, onClose, skillKeys }: SkillInstallModa />
{busy && progress !== null && ( -
+ // e2e (flow 3, `skills.spec.ts`): the section a `skills:progress` + // event driven through `app.emit` lands in -- the spec reads the + // nested `ProgressBar`'s `aria-valuenow` (via its `role`, not a + // second test id: one id naming this whole section is enough, and + // a separate `skill-install-result` id here would just name the + // same element twice under two ids for two halves of one check). + // The FLOW's actual result -- did the install succeed -- is + // `skill-install-modal` becoming hidden once `save()` resolves; + // this section only proves the emitted event was received. +
0 ? progress.done / progress.total : undefined} label={t('skills.install.installing')} @@ -276,7 +290,12 @@ export function SkillInstallModal({ open, onClose, skillKeys }: SkillInstallModa -
diff --git a/apps/desktop/src/renderer/pages/Mcp/ManagementPage.scss b/apps/desktop/src/renderer/pages/Mcp/ManagementPage.scss index 4f1c1653..8d633022 100644 --- a/apps/desktop/src/renderer/pages/Mcp/ManagementPage.scss +++ b/apps/desktop/src/renderer/pages/Mcp/ManagementPage.scss @@ -25,6 +25,25 @@ margin: 0 var(--sk-space-3); } +// The e2e `mcp-page` test id (ManagementPage.tsx) sits on a wrapper `
` +// around the page body's content (empty state, or tree + footer) -- +// introduced only to give both branches one common anchor. That wrapper is a +// new element in what was `Page`'s own `.sk-page__body` flex context +// (`shared/ui/Page/Page.scss`): without this class it becomes a plain block +// box, and `.sk-list-footer`'s `margin-top: auto` below stops pinning the +// footer to the bottom of a short tree, since `margin: auto` only resolves +// against a flex/grid container. Replicates `.sk-page__body`'s own flex +// properties exactly (not its padding, which stays on the real +// `.sk-page__body` parent) so the wrapper is transparent to layout -- a test +// id must never change what renders. Mirrors +// `pages/Skills/SkillsPage.scss`'s `.sk-skills-page-body`. +.sk-mcp-page-body { + display: flex; + flex-direction: column; + gap: var(--sk-space-5); + flex: 1 0 auto; +} + // Spacer between the (sticky) toolbar and the tree. Mirrors McpPage.scss's/ // ComponentsPage.scss's own tree spacer. .sk-mcp-management-tree { diff --git a/apps/desktop/src/renderer/pages/Mcp/ManagementPage.tsx b/apps/desktop/src/renderer/pages/Mcp/ManagementPage.tsx index a14a3a6b..26a42ae8 100644 --- a/apps/desktop/src/renderer/pages/Mcp/ManagementPage.tsx +++ b/apps/desktop/src/renderer/pages/Mcp/ManagementPage.tsx @@ -126,11 +126,11 @@ export function ManagementPage() { }, [projects]); const decorated = useMemo(() => { - function renderBadge(label: string, tone: 'accent' | 'neutral', onClick: () => void): ReactNode { + function renderBadge(label: string, tone: 'accent' | 'neutral', onClick: () => void, testId?: string): ReactNode { return ( e.stopPropagation()}> - @@ -148,13 +148,14 @@ export function ManagementPage() { case 'repo-preset': return ( - {renderBadge(t('mcp.installMcp'), 'accent', () => openInstall(item.preset))} + {renderBadge(t('mcp.installMcp'), 'accent', () => openInstall(item.preset), 'mcp-install-open')} ); case 'installed': return ( - {item.updatable && renderBadge(t('mcp.update'), 'accent', () => startMcpUpdate(item.installs))} + {item.updatable && + renderBadge(t('mcp.update'), 'accent', () => startMcpUpdate(item.installs), 'mcp-update-open')} {renderBadge(t('mcp.delete'), 'neutral', () => requestDeleteInstalls(name, item.installs))} ); @@ -311,45 +312,57 @@ export function ManagementPage() {
} > - {/* An empty tree has two causes now that the Global root can be filtered - out too (before this it was always present, so `baseTree` was never - empty): there is nothing installed at all, or the filters excluded - everything there is. Only the first is "no MCP servers yet"; the second - must say so and carry a reset, since this page has no in-tree footer - reset to fall back on at all. */} - {baseTree.length === 0 ? ( - filtering ? ( -
-

{t('mcp.emptyFiltered')}

- -
- ) : ( -

{t('mcp.empty')}

- ) - ) : ( - <> - setMcpUi({ expandedIds: ids })} - ariaLabel={t('mcp.managementTitle')} - /> - {searching && ( -
- setQuery('')} - /> + {/* e2e (flows 7, 8, 12, `mcp.spec.ts`): a stable anchor for "the MCP + Management page is showing", mirroring `pages/Skills/ManagementPage.tsx`'s + `skills-page` -- one wrapper around both branches below rather than + one testid per branch, since there is no single element common to + both that would otherwise need it. `.sk-mcp-page-body` + (ManagementPage.scss) replicates `Page`'s own `.sk-page__body` flex + layout so this wrapper is transparent to rendering -- a test id must + never change what renders; see that class's own doc comment (it + also explains why `.sk-list-footer`'s bottom-pinning depended on + this). */} +
+ {/* An empty tree has two causes now that the Global root can be filtered + out too (before this it was always present, so `baseTree` was never + empty): there is nothing installed at all, or the filters excluded + everything there is. Only the first is "no MCP servers yet"; the second + must say so and carry a reset, since this page has no in-tree footer + reset to fall back on at all. */} + {baseTree.length === 0 ? ( + filtering ? ( +
+

{t('mcp.emptyFiltered')}

+
- )} - - )} + ) : ( +

{t('mcp.empty')}

+ ) + ) : ( + <> + setMcpUi({ expandedIds: ids })} + ariaLabel={t('mcp.managementTitle')} + /> + {searching && ( +
+ setQuery('')} + /> +
+ )} + + )} +
{modals} diff --git a/apps/desktop/src/renderer/pages/Mcp/lib/mcpTree.tsx b/apps/desktop/src/renderer/pages/Mcp/lib/mcpTree.tsx index 46fbad9e..1e0121c0 100644 --- a/apps/desktop/src/renderer/pages/Mcp/lib/mcpTree.tsx +++ b/apps/desktop/src/renderer/pages/Mcp/lib/mcpTree.tsx @@ -50,6 +50,25 @@ * `` is the full `/`-joined group prefix up to that level (e.g. * `platform` then `platform/lint`), not just the last segment, so a branch * at each nesting level gets a distinct id. + * + * ROW IDENTITY (e2e): every leaf `buildMcpProjectTree` emits carries + * `rowTestId: 'mcp-server-row'` and `identity: { attr: 'mcp-name', value: + * }` -- the id, never a bare + * display name (a preset's `.name`, or an install's `identity.source`). + * Two different leaves routinely share the same name in this tree: a repo + * preset's per-project "install this again" row (`rowsFor`'s `presetLeaf`) + * renders beside its own matched "installed" row the moment one instance + * exists, and the SAME preset can render once per scope root shown (Global + * and every tracked project). A name-keyed identity would make + * `.filter({ has: page.locator('[data-mcp-name="..."]') })` match more than + * one row in exactly those (common) cases; the leaf's own id is already + * guaranteed unique by construction (see the disjoint id-family list above), + * so reusing it costs nothing and removes the ambiguity outright rather than + * relying on a scenario/flow never triggering it. A spec/fixture computing an + * expected value calls the same id-builder (`mcpProjectPresetLeafId`, + * `mcpInstalledLeafId` + `instanceKey`, `mcpUnlinkedLeafId`, or + * `mcpManualLeafId`) this module exports for exactly that purpose, rather + * than guessing the format. */ import { Icon } from '@/shared/ui'; import type { TreeNode } from '@/shared/ui'; @@ -166,8 +185,12 @@ function identityKey(identity: McpInstall['identity']): string { /** A stable grouping key for one logical installed instance: the same * (identity, instance-config name) pair across every agent it is installed - * for collapses into one row. */ -function instanceKey(identity: McpInstall['identity'], instanceName: string): string { + * for collapses into one row. Exported (mirrors `repoMcpPresetId`'s own + * precedent) so an e2e fixture/spec can compute the exact + * `mcpInstalledLeafId`/`mcpUnlinkedLeafId` a scenario's install will render + * as -- e.g. `mcpInstalledLeafId(scope.id, instanceKey(install.identity, + * install.instanceName))` -- instead of guessing the format. */ +export function instanceKey(identity: McpInstall['identity'], instanceName: string): string { return `${identityKey(identity)}|${instanceName}`; } @@ -309,7 +332,17 @@ export function buildMcpProjectTree( .map((p) => { const id = mcpManualLeafId(p.id); items.set(id, { kind: 'manual-preset', preset: p }); - return { id, label: p.name, icon: mcpIcon }; + // e2e: the top-level catalog leaf for a manual preset, shown once + // regardless of scope. `id` (not `p.name`) is the identity value -- + // see this function's own doc comment on row identity for why every + // leaf below uses its own id rather than a bare display name. + return { + id, + label: p.name, + icon: mcpIcon, + rowTestId: 'mcp-server-row', + identity: { attr: 'mcp-name', value: id }, + }; }); const byRepo = new Map(); @@ -334,7 +367,21 @@ export function buildMcpProjectTree( const rowsFor = (p: RepoPreset): TreeNode[] => { const presetLeafId = mcpProjectPresetLeafId(scope.id, p.id); items.set(presetLeafId, { kind: 'repo-preset', preset: p }); - const presetLeaf: TreeNode = { id: presetLeafId, label: p.name, icon: mcpIcon }; + const presetLeaf: TreeNode = { + id: presetLeafId, + label: p.name, + icon: mcpIcon, + // e2e (flow 7, `mcp.spec.ts`): the Management page's per-scope + // "install this preset" row. `presetLeafId` (`scope.id` + + // `p.id`) is already the row's own unique tree-node id -- using it + // as the identity value, rather than the bare (non-unique) preset + // name, is what lets this row and the matched "installed" row + // rendered beside it (same preset, same scope, once an instance + // exists) both carry `mcp-server-row` without either becoming + // ambiguous to `.filter({ has: ... })`. + rowTestId: 'mcp-server-row', + identity: { attr: 'mcp-name', value: presetLeafId }, + }; const matches = projectInstalls.filter((inst) => identityMatchesRepoPreset(inst.identity, p)); const byInstance = new Map(); @@ -354,7 +401,16 @@ export function buildMcpProjectTree( const id = mcpInstalledLeafId(scope.id, key); const updatable = mcpInstallHasUpdate(first, presets); items.set(id, { kind: 'installed', installs: group, updatable }); - return { id, label: instanceDisplayName(first.identity.source, first.instanceName), icon: mcpIconInstalled }; + return { + id, + label: instanceDisplayName(first.identity.source, first.instanceName), + icon: mcpIconInstalled, + // e2e: the installed row for a repo preset's instance, distinct + // from `presetLeaf` above by its own unique id (see this + // function's doc comment). + rowTestId: 'mcp-server-row', + identity: { attr: 'mcp-name', value: id }, + }; }); return [presetLeaf, ...instanceLeaves]; @@ -411,7 +467,17 @@ export function buildMcpProjectTree( const id = mcpInstalledLeafId(scope.id, key); const updatable = mcpInstallHasUpdate(first, presets); items.set(id, { kind: 'installed', installs: group, updatable }); - return { id, label: instanceDisplayName(first.identity.source, first.instanceName), icon: mcpIconInstalled }; + return { + id, + label: instanceDisplayName(first.identity.source, first.instanceName), + icon: mcpIconInstalled, + // e2e (flows 8, 12, `mcp.spec.ts`): the Management page's installed + // row for a manual preset's instance -- the Update badge's target. + // `id` (not `first.identity.source`) is the identity value -- see + // this function's own doc comment on row identity. + rowTestId: 'mcp-server-row', + identity: { attr: 'mcp-name', value: id }, + }; }); // Unlinked: installs matching no current preset, bucketed by source/remote. @@ -436,7 +502,16 @@ export function buildMcpProjectTree( const id = mcpUnlinkedLeafId(scope.id, key); items.set(id, { kind: 'unlinked', installs: group }); const label = instanceDisplayName(first.identity.source, first.instanceName); - const leaf: TreeNode = { id, label, icon: mcpIcon, muted: true }; + // e2e: the unlinked-instance row -- `id` (not the display label) is + // the identity value, see this function's doc comment. + const leaf: TreeNode = { + id, + label, + icon: mcpIcon, + muted: true, + rowTestId: 'mcp-server-row', + identity: { attr: 'mcp-name', value: id }, + }; const bucket = byGroupKey.get(groupKey); if (bucket !== undefined) bucket.rows.push({ leaf, sortLabel: label }); else byGroupKey.set(groupKey, { label: unlinkedGroupLabel(first.identity), rows: [{ leaf, sortLabel: label }] }); diff --git a/apps/desktop/src/renderer/pages/Projects/ProjectsPage.tsx b/apps/desktop/src/renderer/pages/Projects/ProjectsPage.tsx index 86f04b04..6ea9f7cb 100644 --- a/apps/desktop/src/renderer/pages/Projects/ProjectsPage.tsx +++ b/apps/desktop/src/renderer/pages/Projects/ProjectsPage.tsx @@ -134,7 +134,7 @@ export function ProjectsPage() { } > -
+
{repositories.length === 0 ? ( -

{t('repositories.empty')}

+

+ {t('repositories.empty')} +

) : ( <> -
+
{filtered.map((r, i) => ( - + - + - + - + , - , ] @@ -253,52 +259,60 @@ export function SkillsComponentsPage() { } dock={dock} > - {baseTree.length === 0 ? ( -

{t('skills.emptyRepositories')}

- ) : ( - <> - - setRepoChecked([ - ...applyCheckChange( - { explicit: repoChecked, restored: NO_RESTORED }, - NO_BASELINE, - graph, - selection.shown, - next, - ).explicit, - ]) - } - defaultExpandedIds={expandedIds} - onExpandedChange={(ids) => setSkillsUi({ expandedIds: ids })} - ariaLabel={t('skills.componentsTitle')} - /> - {(searching || filtering) && ( -
- {searching && ( - setQuery('')} - /> - )} - {filtering && ( -
- -
- )} -
- )} - - )} + {/* e2e (flows 3/11, `skills.spec.ts`): a stable anchor for "the Skills + Components page is showing" -- see `ManagementPage.tsx`'s matching + `skills-page` wrapper for why this is one wrapper rather than one + testid per branch. `sk-skills-page-body` (SkillsPage.scss) replicates + `Page`'s own `.sk-page__body` flex layout so this wrapper is + transparent to rendering -- see that class's own doc comment. */} +
+ {baseTree.length === 0 ? ( +

{t('skills.emptyRepositories')}

+ ) : ( + <> + + setRepoChecked([ + ...applyCheckChange( + { explicit: repoChecked, restored: NO_RESTORED }, + NO_BASELINE, + graph, + selection.shown, + next, + ).explicit, + ]) + } + defaultExpandedIds={expandedIds} + onExpandedChange={(ids) => setSkillsUi({ expandedIds: ids })} + ariaLabel={t('skills.componentsTitle')} + /> + {(searching || filtering) && ( +
+ {searching && ( + setQuery('')} + /> + )} + {filtering && ( +
+ +
+ )} +
+ )} + + )} +
setInstallOpen(false)} skillKeys={repoChecked} /> ); diff --git a/apps/desktop/src/renderer/pages/Skills/ManagementPage.tsx b/apps/desktop/src/renderer/pages/Skills/ManagementPage.tsx index dde83b46..194e74ea 100644 --- a/apps/desktop/src/renderer/pages/Skills/ManagementPage.tsx +++ b/apps/desktop/src/renderer/pages/Skills/ManagementPage.tsx @@ -716,57 +716,68 @@ export function SkillsManagementPage() { } dock={dock} > - {/* An empty tree has two causes now that the Global root can be filtered - out too (before this it was always present, so `baseTree` was never - empty): nothing is tracked at all, or the filters excluded everything - that is. Only the first is "no projects tracked yet"; the second must - say so and carry its own reset, since the footer that normally holds - one is inside the non-empty branch. */} - {baseTree.length === 0 ? ( - filtering ? ( -
-

{t('skills.emptyFiltered')}

- -
- ) : ( -

{t('skills.emptyProjects')}

- ) - ) : ( - <> - setSkillsUi({ expandedIds: ids })} - ariaLabel={t('skills.managementTitle')} - /> - {(searching || filtering) && ( -
- {searching && ( - setQuery('')} - /> - )} - {filtering && ( -
- -
- )} + {/* e2e (flow 2, `skills.spec.ts`): a stable anchor for "the Skills + Management page is showing", mirroring `repositories-page` -- one + wrapper around both branches below rather than one testid per + branch, since (unlike Repositories) there is no single element common + to both that would otherwise need it. `sk-skills-page-body` + (SkillsPage.scss) replicates `Page`'s own `.sk-page__body` flex + layout so this wrapper is transparent to rendering -- a test id must + never change what renders; see that class's own doc comment (it also + explains why `.sk-list-footer`'s bottom-pinning depended on this). */} +
+ {/* An empty tree has two causes now that the Global root can be filtered + out too (before this it was always present, so `baseTree` was never + empty): nothing is tracked at all, or the filters excluded everything + that is. Only the first is "no projects tracked yet"; the second must + say so and carry its own reset, since the footer that normally holds + one is inside the non-empty branch. */} + {baseTree.length === 0 ? ( + filtering ? ( +
+

{t('skills.emptyFiltered')}

+
- )} - - )} + ) : ( +

{t('skills.emptyProjects')}

+ ) + ) : ( + <> + setSkillsUi({ expandedIds: ids })} + ariaLabel={t('skills.managementTitle')} + /> + {(searching || filtering) && ( +
+ {searching && ( + setQuery('')} + /> + )} + {filtering && ( +
+ +
+ )} +
+ )} + + )} +
` around the page body's content (empty state, or tree + +// footer) -- introduced only to give both branches one common anchor. That +// wrapper is a new element in what was `Page`'s own `.sk-page__body` flex +// context (`Page.scss`): without this class it becomes a plain block box, and +// `.sk-list-footer`'s `margin-top: auto` (below) stops pinning the footer to +// the bottom of a short tree, since `margin: auto` only resolves against a +// flex/grid container. Replicates `.sk-page__body`'s own flex properties +// exactly (not its padding, which stays on the real `.sk-page__body` parent) +// so the wrapper is transparent to layout -- a test id must never change what +// renders. +.sk-skills-page-body { + display: flex; + flex-direction: column; + gap: var(--sk-space-5); + flex: 1 0 auto; +} + // Spacer between the (sticky) header and the tree. .sk-skills-tree { margin-top: var(--sk-space-4); diff --git a/apps/desktop/src/renderer/shared/ui/Badge/Badge.tsx b/apps/desktop/src/renderer/shared/ui/Badge/Badge.tsx index de7cb7a7..618cb173 100644 --- a/apps/desktop/src/renderer/shared/ui/Badge/Badge.tsx +++ b/apps/desktop/src/renderer/shared/ui/Badge/Badge.tsx @@ -13,11 +13,23 @@ export interface BadgeProps { /** Allow the label text to be selected. Off by default. */ readonly selectable?: boolean; readonly className?: string; + /** Test id for the badge element. Generic passthrough -- Badge has no + * product knowledge of it, a caller sets it for the flows that need it. */ + readonly 'data-testid'?: string; } -export function Badge({ children, tone = 'neutral', selectable = false, className }: BadgeProps) { +export function Badge({ + children, + tone = 'neutral', + selectable = false, + className, + 'data-testid': testId, +}: BadgeProps) { return ( - + {children} ); diff --git a/apps/desktop/src/renderer/shared/ui/Card/Card.tsx b/apps/desktop/src/renderer/shared/ui/Card/Card.tsx index 845f11fd..7de7a632 100644 --- a/apps/desktop/src/renderer/shared/ui/Card/Card.tsx +++ b/apps/desktop/src/renderer/shared/ui/Card/Card.tsx @@ -12,14 +12,17 @@ export interface CardProps { /** Use a translucent glass surface instead of the solid one. */ readonly glass?: boolean; readonly className?: string; + /** Test id for the card element. Generic passthrough -- Card has no product + * knowledge of it, a caller sets it for the flows that need it. */ + readonly 'data-testid'?: string; } -export function Card({ children, glass, className }: CardProps) { +export function Card({ children, glass, className, 'data-testid': testId }: CardProps) { const ref = useRef(null); // Refract the backdrop when the glass variant is on; no-op otherwise. useGlassRefraction(ref, { enabled: glass === true }); return ( -
+
{children}
); diff --git a/apps/desktop/src/renderer/shared/ui/ChangeBadge/ChangeBadge.tsx b/apps/desktop/src/renderer/shared/ui/ChangeBadge/ChangeBadge.tsx index e6a2abb8..fa5d878d 100644 --- a/apps/desktop/src/renderer/shared/ui/ChangeBadge/ChangeBadge.tsx +++ b/apps/desktop/src/renderer/shared/ui/ChangeBadge/ChangeBadge.tsx @@ -51,6 +51,10 @@ export interface ChangeBadgeProps { */ readonly tabIndex?: number; readonly className?: string; + /** Test id for the badge. Generic passthrough -- ChangeBadge has no product + * knowledge of it, a caller sets it for the flows that need it (e.g. the + * skill-install modal's "required" marker on a dependency row). */ + readonly 'data-testid'?: string; } // Black = knocked out of the mask, so the glyph shows the background through. @@ -78,7 +82,7 @@ const GLYPH: Record = { ), }; -export function ChangeBadge({ kind, label, onClick, tabIndex, className }: ChangeBadgeProps) { +export function ChangeBadge({ kind, label, onClick, tabIndex, className, 'data-testid': testId }: ChangeBadgeProps) { // Unique per instance so multiple badges never collide on the mask id. const maskId = `sk-change-badge-${useId().replace(/[^a-zA-Z0-9]/g, '')}`; const glyph = ( @@ -94,7 +98,7 @@ export function ChangeBadge({ kind, label, onClick, tabIndex, className }: Chang return ( {onClick === undefined ? ( - + {glyph} ) : ( @@ -103,6 +107,7 @@ export function ChangeBadge({ kind, label, onClick, tabIndex, className }: Chang tabIndex={tabIndex} className={cx(classes, 'sk-change-badge--button')} aria-label={label} + data-testid={testId} onClick={(e) => { // The badge owns this click; the row behind it must not also act on // it (e.g. a TreeView leaf row toggles its checkbox on click). diff --git a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx b/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx index 1c29f606..7944f100 100644 --- a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx +++ b/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx @@ -34,6 +34,10 @@ export interface DescriptionTextProps { * value. */ readonly onOpenLink: (url: string) => void; readonly className?: string; + /** Test id for the rendered span. Generic passthrough -- DescriptionText + * has no product knowledge of it, a caller sets it only for the flows + * that need it. */ + readonly 'data-testid'?: string; } /** One span plus a stable React key. Keyed by position: spans never reorder @@ -45,9 +49,9 @@ export function spansToKeyedParts(spans: readonly DescriptionSpan[]): KeyedDescr return spans.map((span, index) => ({ ...span, key: String(index) })); } -export function DescriptionText({ spans, onOpenLink, className }: DescriptionTextProps) { +export function DescriptionText({ spans, onOpenLink, className, 'data-testid': testId }: DescriptionTextProps) { return ( - + {spansToKeyedParts(spans).map((part) => part.kind === 'text' ? ( {part.text} diff --git a/apps/desktop/src/renderer/shared/ui/Form/FormSection.tsx b/apps/desktop/src/renderer/shared/ui/Form/FormSection.tsx index bf7ba4aa..925c4853 100644 --- a/apps/desktop/src/renderer/shared/ui/Form/FormSection.tsx +++ b/apps/desktop/src/renderer/shared/ui/Form/FormSection.tsx @@ -15,12 +15,33 @@ export interface FormSectionProps { /** FormRows (or any rows). */ readonly children: ReactNode; readonly className?: string; + /** Test id for this section container (a KIND, e.g. `settings-section` -- + * never a per-instance value). Generic passthrough: FormSection has no + * product knowledge of it, a caller sets it only for the flows that need + * it. */ + readonly 'data-testid'?: string; + /** An identity for this section (e.g. `general`), rendered as + * `data-section-id` on the title -- a CHILD of the section, never the + * section itself, per the e2e identity-is-a-separate-attribute convention + * (mirrors `TreeNode.identity`). Only meaningful when `title` is set. */ + readonly sectionId?: string; } -export function FormSection({ title, footer, children, className }: FormSectionProps) { +export function FormSection({ + title, + footer, + children, + className, + 'data-testid': testId, + sectionId, +}: FormSectionProps) { return ( -
- {title !== undefined &&

{title}

} +
+ {title !== undefined && ( +

+ {title} +

+ )}
{children}
diff --git a/apps/desktop/src/renderer/shared/ui/Modal/Modal.tsx b/apps/desktop/src/renderer/shared/ui/Modal/Modal.tsx index 6065facd..f66688f6 100644 --- a/apps/desktop/src/renderer/shared/ui/Modal/Modal.tsx +++ b/apps/desktop/src/renderer/shared/ui/Modal/Modal.tsx @@ -31,6 +31,9 @@ export interface ModalProps { readonly title?: ReactNode; readonly children?: ReactNode; readonly className?: string; + /** Test id for the dialog element. Generic passthrough -- Modal has no + * product knowledge of it, a caller sets it for the flows that need it. */ + readonly 'data-testid'?: string; } // Height of a top/bottom fade block when that edge has hidden content. @@ -55,7 +58,7 @@ function updateFades(viewport: HTMLDivElement, scrim: HTMLDivElement): void { scrim.style.setProperty('--sk-modal-fade-bottom', hasHiddenBottom ? `${FADE_PX}px` : '0px'); } -export function Modal({ open, onClose, title, children, className }: ModalProps) { +export function Modal({ open, onClose, title, children, className, 'data-testid': testId }: ModalProps) { const scrimRef = useRef(null); const viewportRef = useRef(null); const dialogRef = useRef(null); @@ -119,6 +122,7 @@ export function Modal({ open, onClose, title, children, className }: ModalProps) animate="animate" exit="exit" onClick={(e) => e.stopPropagation()} + data-testid={testId} > {title !== undefined &&
{title}
}
{children}
diff --git a/apps/desktop/src/renderer/shared/ui/ProgressBar/ProgressBar.tsx b/apps/desktop/src/renderer/shared/ui/ProgressBar/ProgressBar.tsx index 1b68b5b8..f5b5cd71 100644 --- a/apps/desktop/src/renderer/shared/ui/ProgressBar/ProgressBar.tsx +++ b/apps/desktop/src/renderer/shared/ui/ProgressBar/ProgressBar.tsx @@ -11,9 +11,13 @@ export interface ProgressBarProps { /** Accessible label. */ readonly label?: string; readonly className?: string; + /** Test id for the progressbar element. Generic passthrough -- ProgressBar + * has no product knowledge of it, a caller sets it for the flows that need + * it (e.g. to read `aria-valuenow` once a scripted progress event lands). */ + readonly 'data-testid'?: string; } -export function ProgressBar({ value, label, className }: ProgressBarProps) { +export function ProgressBar({ value, label, className, 'data-testid': testId }: ProgressBarProps) { const indeterminate = value === undefined; const pct = indeterminate ? 0 : Math.max(0, Math.min(1, value)) * 100; return ( @@ -24,6 +28,7 @@ export function ProgressBar({ value, label, className }: ProgressBarProps) { aria-valuenow={indeterminate ? undefined : Math.round(pct)} aria-valuemin={indeterminate ? undefined : 0} aria-valuemax={indeterminate ? undefined : 100} + data-testid={testId} >
diff --git a/apps/desktop/src/renderer/shared/ui/Sidebar/SidebarItem.tsx b/apps/desktop/src/renderer/shared/ui/Sidebar/SidebarItem.tsx index bd482964..38955d88 100644 --- a/apps/desktop/src/renderer/shared/ui/Sidebar/SidebarItem.tsx +++ b/apps/desktop/src/renderer/shared/ui/Sidebar/SidebarItem.tsx @@ -13,15 +13,18 @@ export interface SidebarItemProps { readonly active?: boolean; readonly onClick?: () => void; readonly className?: string; + /** Test id for driving navigation from an E2E spec (e.g. `nav-repositories`). */ + readonly 'data-testid'?: string; } -export function SidebarItem({ icon, children, active, onClick, className }: SidebarItemProps) { +export function SidebarItem({ icon, children, active, onClick, className, 'data-testid': testId }: SidebarItemProps) { return ( {showUpdateNow && ( diff --git a/apps/desktop/src/renderer/systems/appUpdate/ui/UpdateReadyDialog.tsx b/apps/desktop/src/renderer/systems/appUpdate/ui/UpdateReadyDialog.tsx index 20d87d07..5ef28736 100644 --- a/apps/desktop/src/renderer/systems/appUpdate/ui/UpdateReadyDialog.tsx +++ b/apps/desktop/src/renderer/systems/appUpdate/ui/UpdateReadyDialog.tsx @@ -96,7 +96,9 @@ export function UpdateReadyDialog({ platform = bridgeClient.platform }: UpdateRe return ( -

{t('appUpdate.readyBody', { version })}

+

+ {t('appUpdate.readyBody', { version })} +

{path !== null &&

{t('appUpdate.readyPath', { path })}

}

{t('appUpdate.readyHint')}

{showMacFallback && ( diff --git a/apps/desktop/src/renderer/systems/notifications/ui/Toasts.tsx b/apps/desktop/src/renderer/systems/notifications/ui/Toasts.tsx index fd348ba2..359bfaa8 100644 --- a/apps/desktop/src/renderer/systems/notifications/ui/Toasts.tsx +++ b/apps/desktop/src/renderer/systems/notifications/ui/Toasts.tsx @@ -75,6 +75,14 @@ export function Toasts() { key={toast.id} type="button" className="sk-toasts__item" + // `Toasts` is generic, cross-cutting UI with no feature knowledge + // -- the testid names the KIND ("a toast"), never any one + // caller's identity, exactly like every other generic component + // in this suite. A spec that needs a specific toast (e.g. flow + // 12's mcp update preflight refusal, `mcp.spec.ts`) scopes further + // by the toast's own text, which `resolveNotification` renders + // below. + data-testid="toast" // A toast that carries documentation opens it, since the toast is // gone in five seconds and the log entry behind it is easy to miss. // Without one, clicking just dismisses, as before. diff --git a/docs/development/development.md b/docs/development/development.md index 570e5bf2..fbdbf258 100644 --- a/docs/development/development.md +++ b/docs/development/development.md @@ -30,7 +30,8 @@ git submodule update --init ``` Nothing compiles, lints, or unit-tests against it, so the ordinary commands below -work without it -- only `pnpm test:e2e` needs it. +work without it -- of the end-to-end suites, only `pnpm test:e2e:cli` needs it +(`pnpm test:e2e:desktop` does not, and `pnpm test:e2e` runs both). ## Monorepo structure @@ -62,7 +63,7 @@ TypeScript side (run from the repository root): |------------------|-------------------------------------------------| | `pnpm test` | Run the TypeScript tests (`vitest run`). | | `pnpm test:cov` | Run tests with v8 coverage report. | -| `pnpm test:e2e` | Run the end-to-end suite against the built CLI. | +| `pnpm test:e2e` | Run both end-to-end suites: the CLI, then the desktop UI. | | `pnpm lint` | Run ESLint. | | `pnpm typecheck` | Type-check the TypeScript packages. | | `pnpm format` | Run Prettier. | @@ -121,15 +122,29 @@ The TypeScript side uses Vitest. The **90% lines and branches** coverage gate ### End-to-end tests +The end-to-end work is two independent suites under `e2e/`, each covering a +layer the unit tests cannot: `e2e/cli` drives the real `skillkeeper` binary +against a real Git working tree, and `e2e/desktop` drives the renderer in a +real Chromium browser. Run both with: + +``` +pnpm test:e2e +``` + +which is `pnpm test:e2e:cli && pnpm test:e2e:desktop`. They can also be run +independently. + +#### CLI suite + In-memory fakes make the unit tests fast and deterministic, but they cannot catch a regression that lives in the wiring between the CLI, the agent adapters, and a real filesystem -- an adapter resolving the wrong destination root, say, or a -skill that resolves under `MemFs` but not on disk. The end-to-end suite covers -exactly that layer: it drives the real `skillkeeper` binary against a real Git -working tree and asserts on the files it produces. +skill that resolves under `MemFs` but not on disk. This suite covers exactly +that layer: it drives the real `skillkeeper` binary against a real Git working +tree and asserts on the files it produces. ``` -pnpm test:e2e +pnpm test:e2e:cli ``` That script initializes the `examples/test-repo` submodule, force-pulls it to the @@ -141,15 +156,18 @@ in the diff rather than because the fixture moved. Layout: ``` -e2e/ - package.json scopes the directory to CommonJS (the repo root is ESM) - tsconfig.json its own TypeScript scope; ts-jest type-checks as it transpiles - src/cli.ts the Sandbox harness: the only way a spec invokes the CLI +e2e/cli/ + package.json scopes the directory to CommonJS (the repo root is ESM) + tsconfig.json its own TypeScript scope; ts-jest type-checks as it transpiles + src/cli.ts the Sandbox harness: the only way a spec invokes the CLI tests/ - fixture.spec.ts the submodule is present and still the shape the suite assumes - skills.spec.ts resolution schemes, executables, guidance, hooks - mcp.spec.ts preset discovery, parameters, ledgers, the Codex skip - repair.spec.ts verify -> repair -> verify, and the bounds on repair + fixture.spec.ts the submodule is present and still the shape the suite assumes + skills.spec.ts resolution schemes, executables, guidance, hooks + mcp.spec.ts preset discovery, parameters, ledgers, the Codex skip + mcp-oauth.spec.ts the oauth flow for an mcp server + mcp-parameters.spec.ts descriptions and option parameters on an mcp install + repair.spec.ts verify -> repair -> verify, and the bounds on repair + requires.spec.ts repo lint ``` Two things about the design are worth knowing before adding a spec: @@ -158,7 +176,7 @@ Two things about the design are worth knowing before adding a spec: overlap: Vitest runs pure logic in-process under the coverage gate, Jest drives a subprocess against the filesystem. Jest's config is `jest.config.cjs`, and the suite is deliberately CommonJS so no `--experimental-vm-modules` is needed. -- **Isolation belongs to the harness.** `Sandbox` (in `e2e/src/cli.ts`) always +- **Isolation belongs to the harness.** `Sandbox` (in `e2e/cli/src/cli.ts`) always sets throwaway `HOME` *and* `XDG_CONFIG_HOME`. The first relocates the agents' global roots (a global-scope Codex MCP install writes to `~/.codex/config.toml`, a project-scoped one to `/.codex/config.toml`, and @@ -170,6 +188,46 @@ The `check-fixture-repo` local skill wraps this suite and explains how to read a failure: whether the fixture drifted, the product changed, or the harness leaked. It is part of `pre-release-check`. +#### Desktop UI suite + +The desktop renderer has its own wiring risk: a component that only ever ran +against in-memory Vitest mocks can still call a Tauri command with the wrong +name or shape and fail solely in a real browser. This suite covers that layer +with Playwright, driving the renderer in Chromium against a scripted backend +that answers the same `invoke` calls the real Tauri backend would. It touches +no filesystem, git, or network, and never spawns the `skillkeeper` binary or a +real repository. + +``` +pnpm test:e2e:desktop +``` + +`e2e/desktop/playwright.config.ts` chains `vite build` into the `vite preview` +server it starts, so every run boots a fresh renderer bundle rather than a +stale one from an earlier build. + +Layout: + +``` +e2e/desktop/ + playwright.config.ts + tsconfig.json + harness/ + fixture.ts re-exports the scenario-driven test/expect every spec imports + scenario.ts the scripted backend's fixture data for one run + commands.ts the default command table a scenario's responses merge over + installHarness.ts installs the scripted `invoke` handler into the page + fixtures/ + base.ts the Playwright fixture implementation (the `app` object) + skills.ts, mcp.ts, projects.ts, repositories.ts, settings.ts + per-page scenario data + tests/ + boot.spec.ts the application mounts against the scripted backend + harness.spec.ts an unmocked command fails loudly rather than hanging + skills.spec.ts, mcp.spec.ts, projects.spec.ts, repositories.spec.ts, + settings.spec.ts one page each +``` + ## TypeScript The renderer and `packages/i18n` use TypeScript in strict mode with diff --git a/e2e/package.json b/e2e/cli/package.json similarity index 100% rename from e2e/package.json rename to e2e/cli/package.json diff --git a/e2e/src/cli.ts b/e2e/cli/src/cli.ts similarity index 96% rename from e2e/src/cli.ts rename to e2e/cli/src/cli.ts index ab7723c2..79fb3fbf 100644 --- a/e2e/src/cli.ts +++ b/e2e/cli/src/cli.ts @@ -20,8 +20,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:f import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -/** Repository root, from this file's location (e2e/src -> ../..). */ -export const REPO_ROOT = resolve(__dirname, '..', '..'); +/** Repository root, from this file's location (e2e/cli/src -> ../../..). */ +export const REPO_ROOT = resolve(__dirname, '..', '..', '..'); /** The fixture submodule's working tree. */ export const FIXTURE_DIR = join(REPO_ROOT, 'examples', 'test-repo'); @@ -43,7 +43,7 @@ export interface CliResult { export function assertCliBuilt(): void { if (!existsSync(CLI_BIN)) { throw new Error( - `CLI not built at ${CLI_BIN}. Run "pnpm test:e2e", which builds it first, ` + + `CLI not built at ${CLI_BIN}. Run "pnpm test:e2e:cli", which builds it first, ` + 'or "cargo build -p skillkeeper-cli".', ); } diff --git a/e2e/tests/fixture.spec.ts b/e2e/cli/tests/fixture.spec.ts similarity index 100% rename from e2e/tests/fixture.spec.ts rename to e2e/cli/tests/fixture.spec.ts diff --git a/e2e/tests/mcp-oauth.spec.ts b/e2e/cli/tests/mcp-oauth.spec.ts similarity index 100% rename from e2e/tests/mcp-oauth.spec.ts rename to e2e/cli/tests/mcp-oauth.spec.ts diff --git a/e2e/tests/mcp-parameters.spec.ts b/e2e/cli/tests/mcp-parameters.spec.ts similarity index 99% rename from e2e/tests/mcp-parameters.spec.ts rename to e2e/cli/tests/mcp-parameters.spec.ts index 1254c0d6..f4445b7e 100644 --- a/e2e/tests/mcp-parameters.spec.ts +++ b/e2e/cli/tests/mcp-parameters.spec.ts @@ -1,7 +1,7 @@ /** * MCP descriptions and options end to end: link rendering, description * truncation, option-value validation on install, and every mcp lint - * warning. Model of `e2e/tests/mcp.spec.ts`; reuses the same fixture and + * warning. Model of `e2e/cli/tests/mcp.spec.ts`; reuses the same fixture and * harness rather than building a second one. * * The fixtures this exercises are `docs-linked` (a linked description plus a diff --git a/e2e/tests/mcp.spec.ts b/e2e/cli/tests/mcp.spec.ts similarity index 100% rename from e2e/tests/mcp.spec.ts rename to e2e/cli/tests/mcp.spec.ts diff --git a/e2e/tests/repair.spec.ts b/e2e/cli/tests/repair.spec.ts similarity index 100% rename from e2e/tests/repair.spec.ts rename to e2e/cli/tests/repair.spec.ts diff --git a/e2e/tests/requires.spec.ts b/e2e/cli/tests/requires.spec.ts similarity index 100% rename from e2e/tests/requires.spec.ts rename to e2e/cli/tests/requires.spec.ts diff --git a/e2e/tests/skills.spec.ts b/e2e/cli/tests/skills.spec.ts similarity index 100% rename from e2e/tests/skills.spec.ts rename to e2e/cli/tests/skills.spec.ts diff --git a/e2e/tsconfig.json b/e2e/cli/tsconfig.json similarity index 93% rename from e2e/tsconfig.json rename to e2e/cli/tsconfig.json index d0b05cc0..ed89f81e 100644 --- a/e2e/tsconfig.json +++ b/e2e/cli/tsconfig.json @@ -10,7 +10,7 @@ "compilerOptions": { "target": "ES2023", "lib": ["ES2023"], - // node16 rather than the deprecated node10: e2e/package.json declares this + // node16 rather than the deprecated node10: e2e/cli/package.json declares this // directory CommonJS, so node16 emits require() while staying a current, // non-deprecated setting under TypeScript 6. "module": "node16", diff --git a/e2e/desktop/fixtures/base.ts b/e2e/desktop/fixtures/base.ts new file mode 100644 index 00000000..681beb58 --- /dev/null +++ b/e2e/desktop/fixtures/base.ts @@ -0,0 +1,265 @@ +/** + * The Playwright fixture every desktop spec is built on: a `scenario` test + * option, and an `app` object wired to whatever that scenario says. + * + * `harness/fixture.ts` re-exports `test`/`expect` from here under the path + * every spec actually imports (`'../harness/fixture'`). This file is the + * implementation, kept apart from `harness/` on purpose: `harness/` is the + * backend-scripting mechanism (`Scenario`, `installHarness`, the command + * table) and knows nothing about Playwright's `test.extend`; this file is + * testing-framework plumbing layered on top of that mechanism, not part of + * it. + */ +import { test as base, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { installHarness } from '../harness/installHarness.js'; +import { defaultScenario } from '../harness/scenario.js'; +import type { Scenario } from '../harness/scenario.js'; + +/** One entry of `window.__SKK_E2E_CALLS__` -- see `installHarness.ts`. */ +interface RecordedCall { + readonly cmd: string; + readonly args: unknown; +} + +/** One entry of `window.__SKK_E2E_UNMOCKED__` -- see `installHarness.ts`. */ +export interface UnmockedCommand { + readonly cmd: string; + readonly message: string; +} + +/** The spec-facing handle onto one test's scripted backend. */ +export interface App { + /** + * Navigates to the app's root. Call once per test, before any assertion or + * interaction. + * + * The animation/transition-zeroing stylesheet is NOT applied here -- it is + * installed once, per test, via `installAnimationZeroing`'s + * `page.addInitScript` (see that function's doc comment for why `goto` + * itself is too late for it). + */ + goto(): Promise; + /** + * Dispatches `payload` to every listener currently registered for `name`, + * through the same mocked event plugin `@tauri-apps/api/event`'s + * `listen()`/`emit()` use internally (see `installHarness.ts`'s + * `shouldMockEvents` note) -- e.g. to drive `skills:progress` at a moment + * the spec chooses, not whenever a real backend operation would have. + * + * LIMITATION, found by Task 5: this cannot land inside a window bounded by a + * SYNCHRONOUSLY-resolving mocked command. `installHarness.ts`'s mocked + * `invoke` (the real `@tauri-apps/api/mocks`, read from its own `.cjs` + * source) is a plain synchronous callback wrapped in an `async` function + * with no internal `await` -- so a store action chaining two or three such + * calls (e.g. `applySkills`'s `skills_apply` then `skills_list`) resolves + * end to end within a single microtask drain, faster than a second, + * separate Playwright round trip (this method included) can ever arrive: by + * the time its own `page.evaluate` call reaches the page, the listener the + * spec meant to reach has usually already been unregistered. Confirmed with + * a four-point diagnostic (`page.evaluate` sampling DOM state after zero, + * one, and several microtask/macrotask ticks) before concluding this, not + * assumed. There is no way to widen that window from a scenario today -- + * `Scenario.responses` values are plain, already-resolved data (see this + * file's own doc comment on why), not a deferred/gated response a spec + * could release on demand. + * + * Worked example / the only known way around it today: + * `e2e/desktop/tests/skills.spec.ts`'s "installing a skill" test inlines the + * exact same `plugin:event|emit` invoke call this method makes, sequenced + * against the triggering click by microtask ticks (`await + * Promise.resolve()`) inside ONE `page.evaluate` -- deterministic JS + * ordering, not a timing guess. Read that spec before reaching for a + * standalone `app.emit` call anywhere a store action might resolve this + * fast (the MCP install flow's `applyMcp`/`updateMcp` are likely candidates: + * same synchronous-mock shape). A proper fix -- a scenario-level + * gated/deferred response a spec can release on demand -- is intentionally + * NOT built here; it is scoped as its own task. + */ + emit(name: string, payload: unknown): Promise; + /** Every recorded invocation of `command`, as its `args`, in call order. + * Empty when the command was never called. */ + calls(command: string): Promise; + /** Every clipboard write recorded so far (see `installHarness.ts`'s + * clipboard stub), in write order. */ + clipboard(): Promise; + /** + * Every command the scripted backend could not answer so far, in call + * order, as recorded by `installHarness.ts`'s mocked `invoke` (see + * `window.__SKK_E2E_UNMOCKED__`). Reading this directly -- rather than only + * relying on the fixture's own post-test guard below -- is how a spec + * proves what the guard actually captured: the `cmd` name AND the exact + * `message` the thrown `Error` carried, so an assertion against + * `UNKNOWN_COMMAND_PREFIX` (`harness/commands.ts`) is checking the real + * runtime string, not a value re-derived to match it. + * + * Every other spec in this suite leaves this empty for its whole run -- + * the fixture's own teardown (below) fails the test the instant anything + * goes unmocked, so there is nothing left here to read afterwards. Calling + * this is only meaningful in a spec that also sets `expectUnmocked`, the + * one escape hatch that tells that teardown to expect specific commands + * here instead of failing on them. + */ + unmocked(): Promise; +} + +/** + * Zeroes animation/transition durations for every document this `page` ever + * navigates to, from the very first paint. + * + * `reducedMotion: 'reduce'` (see `playwright.config.ts`) is a media-query + * hint that `motion` only honours where a component asks for it -- this is + * belt-and-braces on top of that, so a click can never land mid-transition + * regardless of whether the component checked. + * + * This MUST be a `page.addInitScript`, not a post-navigation + * `page.addStyleTag`: `addInitScript` re-runs on every navigation, before any + * of the page's own scripts -- exactly the "beats every lazily imported + * route" guarantee `installHarness.ts` relies on for the backend mocks, and + * the same guarantee an entrance animation needs here. A style added AFTER + * `page.goto()` resolves (`load`, by default) arrives long after the + * document exists and after the app's own scripts started -- an entrance + * animation triggered on initial mount can start, and finish, before that + * style ever lands. Since `document.documentElement` may not exist yet at + * the moment an init script first runs, this falls back to `DOMContentLoaded` + * when it does not. + */ +async function installAnimationZeroing(page: Page): Promise { + await page.addInitScript(() => { + const css = `*, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + }`; + function insert(): void { + const style = document.createElement('style'); + style.textContent = css; + document.documentElement.appendChild(style); + } + if (document.documentElement) { + insert(); + } else { + document.addEventListener('DOMContentLoaded', insert, { once: true }); + } + }); +} + +function buildApp(page: Page): App { + return { + async goto() { + await page.goto('/'); + }, + async emit(name, payload) { + await page.evaluate( + ({ name, payload }) => { + const internals = ( + window as unknown as { + __TAURI_INTERNALS__: { invoke: (cmd: string, args: unknown) => Promise }; + } + ).__TAURI_INTERNALS__; + return internals.invoke('plugin:event|emit', { event: name, payload }); + }, + { name, payload }, + ); + }, + async calls(command) { + return page.evaluate((command) => { + const calls = ((window as unknown as Record).__SKK_E2E_CALLS__ ?? []) as RecordedCall[]; + return calls.filter((call) => call.cmd === command).map((call) => call.args); + }, command); + }, + async clipboard() { + return page.evaluate( + () => ((window as unknown as Record).__SKK_E2E_CLIPBOARD__ ?? []) as string[], + ); + }, + async unmocked() { + return page.evaluate( + () => ((window as unknown as Record).__SKK_E2E_UNMOCKED__ ?? []) as UnmockedCommand[], + ); + }, + }; +} + +/** True when `actual` and `expected` name exactly the same commands, ignoring + * order (repeated calls to the same never-mocked command all belong to one + * name) but NOT count of distinct names or membership -- unlike a subset or + * "at least" check, `['a']` against a recorded `['a', 'b']` is a mismatch, + * and so is `['a']` against a recorded `[]`. Used only by `expectUnmocked`'s + * teardown check below; see that option's doc comment for why an exact match + * is the point. */ +function sameCommandNames(actual: readonly string[], expected: readonly string[]): boolean { + const a = [...new Set(actual)].sort(); + const b = [...new Set(expected)].sort(); + return a.length === b.length && a.every((name, i) => name === b[i]); +} + +interface Fixtures { + /** The backend data for this test. Override per spec (or per describe + * block) with `test.use({ scenario: withScenario({ ... }) })`. */ + scenario: Scenario; + /** + * The exact set of command names this test expects `__SKK_E2E_UNMOCKED__` + * to hold once the test body finishes -- default `[]`, i.e. every ordinary + * spec still asserts the array is empty, unchanged from before this option + * existed. + * + * This is the ONLY way to stop the `app` fixture's teardown (below) from + * failing a test over an unmocked command; it is deliberately not a + * permissive "allow list" -- the teardown requires the recorded command + * names to match `expectUnmocked` EXACTLY (same names, same count; see + * `sameCommandNames`), so `expectUnmocked: ['x']` still fails the test if + * the harness recorded `['x', 'y']` (a second, unrelated command also went + * unmocked -- the hatch does not launder that away) or recorded nothing at + * all (the guard silently stopped firing -- the exact failure mode this + * option exists to make demonstrable, see `harness.spec.ts`'s "a command + * with no scripted answer" describe block). A hatch that only suppressed + * failure, rather than requiring an exact match, would itself become a + * place a future regression could hide unnoticed -- which is precisely the + * silence this suite's other assertions exist to rule out. + * + * Override per spec (or per describe block) with + * `test.use({ expectUnmocked: ['command_name'] })`, exactly like `scenario` + * above. Read what was actually recorded (both the command name and the + * exact message text) via `app.unmocked()`. + */ + expectUnmocked: readonly string[]; + app: App; +} + +export const test = base.extend({ + scenario: [defaultScenario(), { option: true }], + expectUnmocked: [[], { option: true }], + app: async ({ page, scenario, expectUnmocked }, use) => { + await installHarness(page, scenario); + await installAnimationZeroing(page); + await use(buildApp(page)); + // Fail loudly rather than let an unmocked command hide behind a caught + // rejection (see `installHarness.ts`'s `__SKK_E2E_UNMOCKED__` note). + // `defaultScenario()` is a complete startup (see `commands.ts`'s Task 3 + // additions for `terminal_start`), so a non-empty list here means either + // this spec's scenario left a command it actually exercises unanswered, + // or the renderer started calling a new one this harness has not caught + // up with -- either way, a passing test that hit this is the wrong + // outcome, not a flake to retry away (see `playwright.config.ts`'s + // `retries: 0`). `expectUnmocked` (default `[]`) is the one escape hatch, + // and it is checked for an EXACT match, not merely "at most these" -- see + // that option's own doc comment above. + const unmocked = ((await page.evaluate( + () => (window as unknown as Record).__SKK_E2E_UNMOCKED__ as UnmockedCommand[] | undefined, + )) ?? []) as UnmockedCommand[]; + const commandNames = unmocked.map((entry) => entry.cmd); + if (!sameCommandNames(commandNames, expectUnmocked)) { + if (expectUnmocked.length === 0) { + throw new Error(`e2e harness: unmocked command(s) reached the backend: ${commandNames.join(', ')}`); + } + throw new Error( + `e2e harness: expected exactly [${expectUnmocked.join(', ')}] to go unmocked, ` + + `but recorded [${commandNames.join(', ')}]`, + ); + } + }, +}); + +export { expect }; diff --git a/e2e/desktop/fixtures/mcp.ts b/e2e/desktop/fixtures/mcp.ts new file mode 100644 index 00000000..fc9a1388 --- /dev/null +++ b/e2e/desktop/fixtures/mcp.ts @@ -0,0 +1,299 @@ +/** + * Scenarios for the MCP pages (flows 7, 8 and 12). All three run on + * `nav-mcp-management` (`App.tsx`'s two-level MCP sidebar group, and the + * only MCP page `useMcpActions`'s Install/Update badges are wired into with + * the testids this task adds). + * + * Row identity: `pages/Mcp/lib/mcpTree.tsx`'s `mcp-server-row`/ + * `data-mcp-name` now carries each leaf's OWN unique tree-node id, never a + * bare preset/instance name (see that file's "ROW IDENTITY" doc comment for + * why a bare name is not unique). The functions below compute that same id + * by mirroring the same builders the tree itself uses + * (`repoMcpPresetId`, `mcpProjectPresetLeafId`, `mcpInstalledLeafId`, + * `instanceKey`, `identityKey`) rather than guessing the format -- NOT by + * importing them: those live in `app/store/store.ts` and + * `pages/Mcp/lib/mcpTree.tsx`, both `@/`-aliased and (the latter) JSX + * source, neither of which this directory's own, deliberately narrower + * `tsconfig.json` can resolve (see that file's own doc comment on why it + * has no path aliases and no `"jsx"` option -- it only ever reaches into the + * ts-rs-generated `services/bridge` tree, which needs neither). A drift + * between the mirror below and the real builders would only surface as a + * spec failure (the computed id would stop matching any row), not a type + * error -- kept intentionally close to the source's exact string + * concatenation for that reason. + * + * `mcp_installs` carries no default answer in `harness/commands.ts` (like + * `skills_list`, it is not part of `store.loadAll`'s startup round trip -- + * see that file's doc comment): `ManagementPage`'s own mount effect calls it + * unconditionally, so every scenario here answers it explicitly. + */ +import { withScenario, defaultScenario } from '../harness/scenario.js'; +import type { Scenario } from '../harness/scenario.js'; +import type { Repository, Project } from '../../../apps/desktop/src/renderer/services/bridge/generated/core/index.js'; +import type { DescriptionSpan } from '../../../apps/desktop/src/renderer/services/bridge/generated/core/DescriptionSpan.js'; +import type { McpPreset as ConfigMcpPreset } from '../../../apps/desktop/src/renderer/services/bridge/generated/config/McpPreset.js'; +import type { + AvailableMcp, + RawMcpServerDef, + McpInstall, + ApplyMcpResult, + UpdateMcpResult, +} from '../../../apps/desktop/src/renderer/services/bridge/contracts.js'; + +// -- row-identity mirrors (see this file's own doc comment for why these are +// -- copies of `app/store/store.ts`'s/`pages/Mcp/lib/mcpTree.tsx`'s builders +// -- rather than imports of them) --------------------------------------- + +/** Mirrors `app/store/store.ts`'s `repoMcpPresetId`. */ +function repoMcpPresetId(repoId: string, group: string | undefined, name: string): string { + return `repo:${repoId}:${group ?? ''}:${name}`; +} + +/** Mirrors `pages/Mcp/lib/mcpTree.tsx`'s `mcpProjectPresetLeafId`. */ +function mcpProjectPresetLeafId(projectId: string, presetId: string): string { + return ['mcp-repo', 'leaf', projectId, presetId].join('::'); +} + +/** Mirrors `pages/Mcp/lib/mcpTree.tsx`'s `mcpInstalledLeafId`. */ +function mcpInstalledLeafId(projectId: string, key: string): string { + return ['mcp-inst', projectId, key].join('::'); +} + +/** Mirrors `pages/Mcp/lib/mcpTree.tsx`'s `identityKey`, narrowed to the one + * identity shape this file's fixtures ever build (a manual preset's + * `local` id) -- the remote-based branch is not needed here. */ +function localIdentityKey(presetId: string): string { + return `local:${presetId}`; +} + +/** Mirrors `pages/Mcp/lib/mcpTree.tsx`'s `instanceKey`. */ +function instanceKey(identityKey: string, instanceName: string): string { + return `${identityKey}|${instanceName}`; +} + +// -- flow 7: installing a repo-discovered preset into a tracked project ----- + +/** The one repository flow 7's repo-discovered preset resolves from. */ +function repo(): Repository { + return { + id: 'mcp-repo-id', + name: 'mcp-repo', + url: 'https://example.invalid/mcp-repo.git', + kind: 'generic', + transport: 'https', + lfs: false, + localPath: '/repos/mcp-repo-id', + }; +} + +/** The one tracked project flow 7 installs into -- a tracked-project scope + * is the common case the original (Global-only) version of this fixture + * did not cover at all; see `mcpTree.tsx`'s "ROW IDENTITY" comment for why + * moving here no longer risks row-identity ambiguity against Global's own + * copy of the same preset. */ +function project(): Project { + return { id: 'mcp-project-id', path: '/projects/demo', name: 'Demo', addedAt: '2024-01-01T00:00:00Z' }; +} + +/** + * `App`'s `activeView` starts at `'projects'` (see `App.tsx`), so + * `ProjectsPage` mounts first on every `app.goto()` regardless of which page + * the spec navigates to afterward -- and with a non-empty `projects` array, + * its own mount effect (`refreshProjectInfo` -> `projects_describe`) and its + * cards' `OpenProjectButton` (`editors_list`) fire immediately, alongside the + * app-wide `useProjectCheckSchedule` sweep (`projects_folder_state`). None of + * the three has a default in `harness/commands.ts` (not part of `loadAll`'s + * startup round trip), so any scenario carrying a tracked project must answer + * them -- mirrors `fixtures/skills.ts`'s identical helper. + */ +function projectsPageStartupResponses(): Record { + return { + editors_list: [], + projects_describe: { skillCount: 0, fromReposCount: 0, agentCount: 0 }, + projects_folder_state: 'present', + }; +} + +const SERVER_DESCRIPTION_SPANS: DescriptionSpan[] = [ + { kind: 'text', text: 'Connect to ' }, + { kind: 'link', text: 'GitHub', url: 'https://github.com' }, + { kind: 'text', text: ' for issues.' }, +]; +const REGION_DESCRIPTION_SPANS: DescriptionSpan[] = [{ kind: 'text', text: 'Pick a region.' }]; + +/** The synthesized preset id `refreshMcpPresets` gives flow 7's + * repo-discovered preset -- same builder the store itself uses. */ +function githubPresetId(): string { + return repoMcpPresetId(repo().id, undefined, 'github'); +} + +/** + * The exact row identity flow 7's spec must filter by: the preset's install + * row nested under the TRACKED PROJECT's own branch. Global's copy of the + * same preset renders alongside it (every repo preset gets an install row + * per scope root shown) with a DIFFERENT id, so this must be the project- + * scoped one specifically, not the bare preset name. + */ +export function withParametersInstallRowId(): string { + return mcpProjectPresetLeafId(project().id, githubPresetId()); +} + +/** + * Flow 7: a repo-discovered preset with a server description (parsed into + * spans, never raw `[text](url)` markup -- see + * `features/mcpInstall/lib/descriptionRenderSites.test.ts` for the rule this + * exercises end to end) and one option-constrained parameter, installed into + * a tracked project. + */ +export function withParameters(): Scenario { + const def: RawMcpServerDef = { + name: 'github', + type: 'http', + url: 'https://api.example.invalid/{region}', + description: 'Connect to [GitHub](https://github.com) for issues.', + parameters: { + region: { + description: 'Pick a region.', + options: [ + { value: 'us', label: 'United States' }, + { value: 'eu', label: 'Europe' }, + ], + }, + }, + }; + const mcpAvailable: AvailableMcp[] = [{ repoId: repo().id, remote: repo().url, def, hash: 'hash-github' }]; + const applied: ApplyMcpResult = { + ok: true, + installed: [{ agent: 'claude', instanceName: 'github', notes: [] }], + removed: 0, + skipped: [], + }; + return withScenario({ + repositories: [repo()], + projects: [project()], + mcpAvailable, + responses: { + ...projectsPageStartupResponses(), + mcp_installs: [], + mcp_description_spans: [SERVER_DESCRIPTION_SPANS, REGION_DESCRIPTION_SPANS], + mcp_apply: applied, + }, + }); +} + +// -- flows 8, 12: updating an already-installed manual preset's instance ---- + +const MANUAL_PRESET_ID = 'preset-github'; + +/** + * The one manual preset flows 8 and 12 update an installed instance of. Its + * `url` carries a `{token}` placeholder; a manually-authored preset carries + * no per-parameter metadata at all (see the generated config `McpPreset`'s + * own doc comment: "the desktop editor does not author `parameters` or + * `options`"), so `token` always renders as a plain text field, never a + * `Select` -- `withParameters` above is what covers the option-select case. + */ +function manualPreset(): ConfigMcpPreset { + return { id: MANUAL_PRESET_ID, name: 'github', type: 'http', url: 'https://api.example.invalid/{token}' }; +} + +/** `defaultScenario()`'s config with `manualPreset()` as its one manual MCP + * server, everything else left at its default. */ +function configWithManualPreset(): Scenario['config'] { + return { ...defaultScenario().config, mcp: { servers: [manualPreset()] } }; +} + +/** + * The one already-installed instance flows 8 and 12 target, at the GLOBAL + * scope (unlike flow 7, no tracked project is needed here: `applyScope` + * resolves the global scope with zero tracked projects, and a manual + * preset's installed-instance row has no per-project "install row" + * duplicate to disambiguate against -- see `mcpTree.tsx`'s doc comment). + * `hash` deliberately never matches the live + * `hashMcpDefInRenderer(manualPreset().def)` output (an unfakeable SHA-256 + * digest computed client-side from the CURRENT def) -- that mismatch is + * exactly what `mcpInstallHasUpdate` reads as "an update is available", + * which is what makes the Update badge (`mcp-update-open`) render at all. + */ +function installedInstance(): McpInstall { + return { + projectId: 'global', + agent: 'codex', + instanceName: 'github', + identity: { local: MANUAL_PRESET_ID, source: 'github' }, + hash: 'sha256:does-not-match-the-live-def', + hasParams: true, + }; +} + +/** The exact row identity flows 8 and 12 must filter by -- computed the same + * way `mcpTree.tsx`'s `manualInstanceLeaves` builds it. */ +export function installedInstanceRowId(): string { + const install = installedInstance(); + return mcpInstalledLeafId('global', instanceKey(localIdentityKey(MANUAL_PRESET_ID), install.instanceName)); +} + +/** + * Flow 8: the preflight accepts the update but reports `token` (the source's + * placeholder) missing from this instance's stored params, opening + * `McpUpdateParamsModal` for exactly that field. Untouched by the fix round: + * this path was already correct (the params modal only ever opens when + * something is missing, so Confirm is pressed once to run it and once more + * to finalize). + */ +export function updatable(): Scenario { + const updated: UpdateMcpResult = { + ok: true, + updated: [{ agent: 'codex', instanceName: 'github', notes: [] }], + skipped: [], + }; + return withScenario({ + config: configWithManualPreset(), + mcpInstalls: [installedInstance()], + responses: { + mcp_installs: [installedInstance()], + // `McpUpdateParamsModal` fetches description spans unconditionally on + // open (mirroring `McpInstallModal`); `manualPreset()` authors no + // description at all, so nothing ever reads this response back -- + // it only needs to exist so the call is answered. + mcp_description_spans: [], + mcp_update_preflight: { ok: true, missingParams: ['token'] }, + mcp_update: updated, + }, + }); +} + +/** + * Flow 12: the 0.7.0 regression case -- codex cannot express an http + * transport. This is NOT a preflight refusal: `mcp_update_preflight` + * (`preflight_inner`, `src-tauri/src/commands/mcp/update.rs`) only ever + * checks for MISSING PARAMS, never transport/oauth support, so it reports + * `ok: true` with nothing missing and `startMcpUpdateAsync` proceeds + * straight to `updateMcp` -- no params modal opens at all. The actual + * transport check happens inside `mcp_update` itself (`update_inner`): + * codex's write for this instance is SKIPPED (`reason: 'transport'`), and + * `update_inner` `continue`s BEFORE `remove_mcp_instance` for that agent -- + * which is the literal mechanism the 0.7.0 fix guarantees (the instance is + * never removed for an agent whose write it already knows will fail). + * `updateMcp`'s own top-level result is still `ok: true` (the call as a + * whole succeeded; only codex's entry is reported skipped) -- see + * `task-8-report.md`'s fix-round notes for why the original version of this + * fixture (an `ok: false` preflight) was unreachable for this cause. + */ +export function transportSkipped(): Scenario { + const updated: UpdateMcpResult = { + ok: true, + updated: [], + skipped: [{ agent: 'codex', source: 'github', reason: 'transport', transport: 'http' }], + }; + return withScenario({ + config: configWithManualPreset(), + mcpInstalls: [installedInstance()], + responses: { + mcp_installs: [installedInstance()], + mcp_description_spans: [], + mcp_update_preflight: { ok: true, missingParams: [] }, + mcp_update: updated, + }, + }); +} diff --git a/e2e/desktop/fixtures/projects.ts b/e2e/desktop/fixtures/projects.ts new file mode 100644 index 00000000..f3c51d9e --- /dev/null +++ b/e2e/desktop/fixtures/projects.ts @@ -0,0 +1,73 @@ +/** + * Scenarios for the Projects page (flows 4 and 10). + * + * `App`'s `activeView` starts at `'projects'` (see `App.tsx`), so `ProjectsPage` + * mounts on every `app.goto()` with no navigation click needed, unlike the + * Repositories and Skills pages. + */ +import { withScenario } from '../harness/scenario.js'; +import type { Scenario } from '../harness/scenario.js'; +import type { Project } from '../../../apps/desktop/src/renderer/services/bridge/generated/core/index.js'; +import type { ProjectResult, ProjectInfo } from '../../../apps/desktop/src/renderer/services/bridge/contracts.js'; + +/** + * Flow 4: tracking a project. Neither `projects_add`, `projects_describe`, nor + * `editors_list` has a default answer in `harness/commands.ts` (none is part + * of `store.loadAll`'s startup round trip), so this scenario supplies all + * three, plus `dialog_select_folder` -- the native folder picker + * `ProjectAddButton` awaits before it ever calls `addProject` + * (`bridgeClient.selectFolder`). `editors_list` is needed because the added + * project's card is not `missing`, so `OpenProjectButton` mounts and calls it + * (`ProjectCard.tsx`'s actions column renders `openControl` only when the + * folder is not missing). `agentCount: 2` is set so the added card's agent + * badge (`project-card-agents`) actually renders -- `ProjectCard` only shows + * it when `agentCount > 0`. + */ +export function trackable(): Scenario { + const project: Project = { + id: 'tracked-project-id', + path: '/projects/demo-project', + name: 'Demo Project', + addedAt: '2024-01-01T00:00:00Z', + }; + const added: ProjectResult = { ok: true, project }; + const info: ProjectInfo = { skillCount: 0, fromReposCount: 0, agentCount: 2 }; + return withScenario({ + responses: { + dialog_select_folder: project.path, + projects_add: added, + projects_describe: info, + editors_list: [], + }, + }); +} + +/** + * Flow 10: a tracked project whose folder no longer exists. The project is + * seeded up front (`scenario.projects`), exactly as a normal page load would + * show it; `projects_folder_state` answers `missing` for it, matching what + * `useProjectCheckSchedule`'s startup sweep (`store.checkProjects`) polls for + * every tracked project. `projects_describe` still needs an answer -- + * `ProjectsPage`'s mount effect (`refreshProjectInfo`) describes every project + * regardless of folder state -- but `editors_list` is deliberately NOT + * mocked: `ProjectCard` never renders `OpenProjectButton` while a project is + * `missing` (see `ProjectCard.tsx`'s actions column, which shows only the + * remove button in that case), so the app never calls it for this scenario, + * and mocking it anyway would hide that fact. + */ +export function folderMissing(): Scenario { + const project: Project = { + id: 'missing-project-id', + path: '/projects/gone', + name: 'Gone Project', + addedAt: '2024-01-01T00:00:00Z', + }; + const info: ProjectInfo = { skillCount: 0, fromReposCount: 0, agentCount: 0 }; + return withScenario({ + projects: [project], + responses: { + projects_describe: info, + projects_folder_state: 'missing', + }, + }); +} diff --git a/e2e/desktop/fixtures/repositories.ts b/e2e/desktop/fixtures/repositories.ts new file mode 100644 index 00000000..21cf8845 --- /dev/null +++ b/e2e/desktop/fixtures/repositories.ts @@ -0,0 +1,132 @@ +/** + * Scenarios for the Repositories page's add flow (flows 1 and 9). + * + * Neither `repositories_add`, `repositories_clone`, nor `repositories_describe` + * has a default answer in `harness/commands.ts`: none of the three is part of + * `store.loadAll`'s startup round trip (see that file's doc comment), so + * `defaultScenario()` leaves all three unmocked on purpose. A spec that drives + * the add flow must supply them itself, through one of the scenarios below -- + * omitting one is exactly what the fixture's "no command went unmocked" + * assertion (`harness/fixture.ts`) exists to catch. + */ +import { withScenario } from '../harness/scenario.js'; +import type { Scenario } from '../harness/scenario.js'; +import { NEVER_RESOLVES } from '../harness/commands.js'; +import type { Repository } from '../../../apps/desktop/src/renderer/services/bridge/generated/core/index.js'; +import type { RepoResult, RepoInfo } from '../../../apps/desktop/src/renderer/services/bridge/contracts.js'; + +/** + * The repository `repositories_add` and `repositories_clone` both answer with. + * Matches the URL flow 1's spec fills into the add form; `deriveRepoName` + * derives the name "demo" from it client-side, so the add form never has to + * type a name for the row's `data-repo-name="demo"` to hold. + * + * `branch` is deliberately absent: the real `add`/`clone` backend commands + * never set `Repository.branch` (that field is the user's explicit override + * from the edit modal, written only by `repositories_update`) -- the card's + * branch badge comes from `repositories_describe`'s `RepoInfo` instead, below. + */ +function demoRepository(): Repository { + return { + id: 'demo-repo-id', + name: 'demo', + url: 'https://example.invalid/demo.git', + kind: 'generic', + transport: 'https', + lfs: false, + localPath: '/repos/demo-repo-id', + }; +} + +/** + * The scenario flow 1 ("adding a repository shows it in the list") runs + * against: `repositories_add` and `repositories_clone` both succeed with the + * same repository, and `repositories_describe` reports it cloned on `main` + * with no skills -- the state `RepositoriesPage` is in once `addRepository`'s + * whole add-then-clone-then-describe chain (`app/store/store.ts`) settles. + */ +export function oneRepository(): Scenario { + const repository = demoRepository(); + const added: RepoResult = { ok: true, repository }; + const cloned: RepoResult = { ok: true, repository }; + const info: RepoInfo = { branch: 'main', skillCount: 0 }; + return withScenario({ + responses: { + repositories_add: added, + repositories_clone: cloned, + repositories_describe: info, + }, + }); +} + +/** + * The scenario flow 9 ("a repository that will not clone") runs against: + * `repositories_add` itself fails with `message`, before a repository record + * ever exists -- `addRepository` (`app/store/store.ts`) returns right there, + * so `repositories_clone`/`repositories_describe` are never called and stay + * deliberately unmocked; a spec that reaches them anyway is a real bug, not a + * fixture gap. + */ +export function cloneFails(message: string): Scenario { + const failed: RepoResult = { ok: false, error: message }; + return withScenario({ + responses: { + repositories_add: failed, + }, + }); +} + +/** + * The repository already on the page BEFORE the add flow runs, for + * `duplicateFails()` below -- same identity (id/url) as `demoRepository()` so + * a submit of the same URL is genuinely the duplicate the real backend would + * reject (`add`, `apps/desktop/src-tauri/src/commands/repositories.rs`: "if + * `state.repositories.iter().any(|r| r.url == url)` -> `RepoResult::err + * ("duplicate")`"). + */ +function existingRepository(): Repository { + return demoRepository(); +} + +/** + * The scenario for "a repository that already exists": one repository is + * already tracked (seeded via `scenario.repositories`, exactly like a normal + * page load would show it, including its own `repositories_describe` + * answer), and `repositories_add` answers the SAME "duplicate" error the real + * backend returns for a URL already in `AppState.repositories`. This is the + * regression case for the bug where the add form inferred success by + * checking whether ANY repository with the submitted URL was present in the + * store -- which a pre-existing duplicate satisfies just as well as a + * genuine new row would, so the false-positive check swallowed this error + * silently instead of surfacing it. + */ +export function duplicateFails(): Scenario { + const repository = existingRepository(); + const failed: RepoResult = { ok: false, error: 'duplicate' }; + const info: RepoInfo = { branch: 'main', skillCount: 0 }; + return withScenario({ + repositories: [repository], + responses: { + repositories_add: failed, + repositories_describe: info, + }, + }); +} + +/** + * The scenario for "cancel an add while it is in flight": `repositories_add` + * never settles (see `harness/commands.ts`'s `NEVER_RESOLVES`), so a spec can + * submit, observe the form's in-flight `submitting` state, cancel out of it, + * and reopen the dialog -- all while that original call is still outstanding, + * exactly like dismissing the form during a slow clone in real use. + * `repositories_clone`/`repositories_describe` stay unmocked on purpose, same + * as `cloneFails()` above: `addRepository`'s chain never reaches them while + * `repositories_add` itself has not resolved. + */ +export function addNeverResolves(): Scenario { + return withScenario({ + responses: { + repositories_add: NEVER_RESOLVES, + }, + }); +} diff --git a/e2e/desktop/fixtures/settings.ts b/e2e/desktop/fixtures/settings.ts new file mode 100644 index 00000000..ae1dbfbc --- /dev/null +++ b/e2e/desktop/fixtures/settings.ts @@ -0,0 +1,115 @@ +/** + * Scenarios for the Settings page and the self-update surface (flows 5 and 6). + */ +import { withScenario } from '../harness/scenario.js'; +import type { Scenario } from '../harness/scenario.js'; +import type { + SkillKeeperConfig, + SectionValidity, +} from '../../../apps/desktop/src/renderer/services/bridge/generated/config/index.js'; +import type { AppUpdateOffer } from '../../../apps/desktop/src/renderer/services/bridge/generated/AppUpdateOffer.js'; +import type { SshKeyDto } from '../../../apps/desktop/src/renderer/services/bridge/contracts.js'; + +/** The warning `config_get` reports alongside `invalidSection()`'s + * `repositories: 'invalid'`. Exported so the spec can assert its exact text + * reaches `ConfigBanner` rather than merely asserting the banner exists. */ +export const REPOSITORIES_INVALID_WARNING = 'repositories: gitPath must not be empty'; + +/** + * `SettingsPage`'s mount round trip: `OpenConfigButton` (the toolbar's editor + * picker, same lazy-loaded-once pattern as `OpenProjectButton`) reads + * `editors_list`, and the repositories section's `SshKeyField` reads + * `ssh_key_state` -- neither has a default in `harness/commands.ts` (not part + * of `loadAll`'s startup round trip), so any scenario that opens Settings + * must answer both, exactly like `fixtures/skills.ts`'s + * `projectsPageStartupResponses`. + */ +function settingsPageStartupResponses(): Record { + const sshKeyState: SshKeyDto = { state: 'notConfigured' }; + return { + editors_list: [], + ssh_key_state: sshKeyState, + }; +} + +/** + * A config with every section populated by values distinct from + * `harness/scenario.ts`'s own `emptyConfig()` defaults, so a rendered field + * can be told apart from an incidental default. `config_get`'s + * `SectionValidity` (`harness/commands.ts`'s `defaultResponses`) marks every + * section 'valid' regardless of the values here -- that default IS flow 5's + * "all-valid" precondition, kept implicit rather than duplicated in a + * `responses` override. + */ +function config(): SkillKeeperConfig { + return { + general: { language: 'en', theme: 'system', animations: 'fast' }, + updates: { mode: 'scheduled', intervalMinutes: 180, checkOnStartup: false }, + agents: { enabled: ['claude', 'codex', 'copilot', 'cursor', 'opencode'], overrides: {} }, + executables: { globs: [] }, + security: { hookConsentPolicy: 'always-ask' }, + notifications: { enabled: true }, + repositories: { gitPath: '/usr/bin/git' }, + projects: { checkIntervalMinutes: 5 }, + mcp: { servers: [] }, + }; +} + +/** Flow 5: opening Settings renders every section, backed by a config whose + * `SectionValidity` is all-valid (see `config()`'s doc comment). */ +export function settingsPage(): Scenario { + return withScenario({ config: config(), responses: settingsPageStartupResponses() }); +} + +/** The counterpart to `settingsPage()`: same config, but `repositories` is + * reported invalid with one warning -- so the invalid-config banner + * (`ConfigBanner`) has something to actually show, giving the valid case + * above a contrasting negative to be meaningful against. */ +export function invalidSection(): Scenario { + const validity: SectionValidity = { + general: 'valid', + updates: 'valid', + agents: 'valid', + executables: 'valid', + security: 'valid', + notifications: 'valid', + repositories: 'invalid', + projects: 'valid', + mcp: 'valid', + }; + return withScenario({ + config: config(), + validity, + configWarnings: [REPOSITORIES_INVALID_WARNING], + responses: settingsPageStartupResponses(), + }); +} + +/** + * The offer `app_update_check_now` resolves with once the spec presses + * "Check now" on the Settings page. `showDialog: false` keeps the "update + * available" dialog from auto-opening (`noteAppUpdateOffer` in + * `app/store/store.ts`), so the only overlay this flow drives is the "ready + * to install" dialog, via a plain `appUpdate:ready` emit afterward. + */ +function offer(): AppUpdateOffer { + return { + version: '1.5.0', + bump: 'minor', + notes: '', + truncatedHistory: false, + installable: true, + showDialog: false, + }; +} + +/** Flow 6: `app_update_check_now` returns an offer; the spec then emits + * `appUpdate:ready` to complete it -- see `settings.spec.ts`. */ +export function offeredUpdate(): Scenario { + return withScenario({ + responses: { + ...settingsPageStartupResponses(), + app_update_check_now: offer(), + }, + }); +} diff --git a/e2e/desktop/fixtures/skills.ts b/e2e/desktop/fixtures/skills.ts new file mode 100644 index 00000000..35e6a88e --- /dev/null +++ b/e2e/desktop/fixtures/skills.ts @@ -0,0 +1,250 @@ +/** + * Scenarios for the Skills pages (flows 2, 3 and 11). + * + * `projects_detect_agents`, `skills_apply` and `skills_list` carry no default + * answer in `harness/commands.ts`: none is part of `store.loadAll`'s startup + * round trip (see that file's doc comment), so `installable()` and + * `withDependency()` -- both of which drive `SkillInstallModal` through a real + * apply -- must supply all three themselves. `flatAndGrouped()` only browses, + * so it needs none of them. + * + * Row identity: `entities/skill/lib/skillTree.tsx`'s `skill-row`/ + * `skill-install-checkbox` rows and `skill-group` rows carry each leaf/group's + * OWN unique tree-node id, never a bare name or repo-relative path (mirrors + * `mcpTree.tsx`'s "ROW IDENTITY" rule -- see that file's doc comment for why a + * bare name is not unique). The functions below compute those same ids by + * mirroring the builders the tree itself uses (`repoSkillKey`, + * `projectSkillKey`, `projectGroupNodeId`) rather than guessing the format -- + * NOT by importing them: `skillTree.tsx` is `@/`-aliased JSX source, which + * this directory's own, deliberately narrower `tsconfig.json` cannot resolve + * (see `fixtures/mcp.ts`'s identical note on its own mirrors). + */ +import { withScenario } from '../harness/scenario.js'; +import type { Scenario } from '../harness/scenario.js'; +import type { + Repository, + Project, + InstallManifest, +} from '../../../apps/desktop/src/renderer/services/bridge/generated/core/index.js'; +import type { AvailableSkill, ApplyResult } from '../../../apps/desktop/src/renderer/services/bridge/contracts.js'; + +// -- row-identity mirrors (see this file's own doc comment) ----------------- + +/** Mirrors `entities/skill/lib/skillTree.tsx`'s `enc`. */ +const enc = (part: string): string => encodeURIComponent(part); + +/** Mirrors `entities/skill/lib/skillTree.tsx`'s `repoSkillKey` (the + * Components page's browse-tree leaf id, and `SkillInstallModal` step 1's + * source). */ +function repoSkillKey(repoId: string, group: string | undefined, name: string): string { + return [repoId, group ?? '', name].map(enc).join('::'); +} + +/** Mirrors `entities/skill/lib/skillTree.tsx`'s `projectSkillKey` (the + * Management page's leaf id, and `SkillInstallModal` step 2's checkbox id). */ +function projectSkillKey(scopeId: string, repoId: string, group: string | undefined, name: string): string { + return [scopeId, repoId, group ?? '', name].map(enc).join('::'); +} + +/** Mirrors `entities/skill/lib/skillTree.tsx`'s `projectGroupNodeId` (the + * Management page's group-branch id). */ +function projectGroupNodeId(scopeId: string, repoId: string, group: string): string { + return `${scopeId}::${repoId}::${group}`; +} + +/** Mirrors `domain/scope.ts`'s `GLOBAL_SCOPE_ID`. */ +const GLOBAL_SCOPE_ID = 'global'; + +/** The one repository every scenario below resolves its skills from. */ +function repo(): Repository { + return { + id: 'skills-repo-id', + name: 'skills-repo', + url: 'https://example.invalid/skills-repo.git', + kind: 'generic', + transport: 'https', + lfs: false, + localPath: '/repos/skills-repo-id', + }; +} + +/** The one tracked project `installable()`/`withDependency()` install into. */ +function project(): Project { + return { id: 'skills-project-id', path: '/projects/demo', name: 'Demo', addedAt: '2024-01-01T00:00:00Z' }; +} + +/** + * `App`'s `activeView` starts at `'projects'` (see `App.tsx`), so `ProjectsPage` + * mounts first on every `app.goto()`, regardless of which page the spec + * navigates to afterward -- and with a non-empty `projects` array, its own + * mount effect (`refreshProjectInfo` -> `projects_describe`) and its cards' + * `OpenProjectButton` (`editors_list`) fire immediately, alongside the + * app-wide `useProjectCheckSchedule` sweep (`projects_folder_state`). None of + * the three has a default in `harness/commands.ts` (not part of `loadAll`'s + * startup round trip), so any scenario carrying a tracked project must answer + * them, exactly like `fixtures/repositories.ts`'s trio for the Repositories + * page. + */ +function projectsPageStartupResponses(): Record { + return { + editors_list: [], + projects_describe: { skillCount: 0, fromReposCount: 0, agentCount: 0 }, + projects_folder_state: 'present', + }; +} + +/** + * Flow 2: a flat skill, a skill inside a group, and a skill inside a nested + * group -- one repository's `skills_available` resolving to all three group + * depths at once. + */ +export function flatAndGrouped(): Scenario { + const repoId = repo().id; + const skills: AvailableSkill[] = [ + { + repoId, + repoName: repo().name, + remote: repo().url, + name: 'flat-skill', + contentHash: 'hash-flat-skill', + hasGuidance: false, + }, + { + repoId, + repoName: repo().name, + remote: repo().url, + group: 'platform', + name: 'grouped-skill', + contentHash: 'hash-grouped-skill', + hasGuidance: false, + }, + { + repoId, + repoName: repo().name, + remote: repo().url, + group: 'platform/lint', + name: 'nested-skill', + contentHash: 'hash-nested-skill', + hasGuidance: false, + }, + ]; + return withScenario({ repositories: [repo()], skills }); +} + +/** `flatAndGrouped()`'s flat skill's row id: the Management page has no + * tracked project, so only the reserved Global scope root shows. */ +export function flatSkillLeafId(): string { + return projectSkillKey(GLOBAL_SCOPE_ID, repo().id, undefined, 'flat-skill'); +} + +/** `flatAndGrouped()`'s top-level group's branch id. */ +export function groupedSkillGroupId(): string { + return projectGroupNodeId(GLOBAL_SCOPE_ID, repo().id, 'platform'); +} + +/** `flatAndGrouped()`'s nested group's branch id. */ +export function nestedSkillGroupId(): string { + return projectGroupNodeId(GLOBAL_SCOPE_ID, repo().id, 'platform/lint'); +} + +/** + * Flow 3: one installable skill, plus the three commands `SkillInstallModal`'s + * own round trip needs once it applies: `projects_detect_agents` (auto-picks + * the chosen project's agents in step 1, so the spec never has to drive + * `AgentSelect`'s own popup), `skills_apply` (the apply itself), and + * `skills_list` (the post-apply refresh `applySkills` awaits before it + * resolves). + */ +export function installable(): Scenario { + const repoId = repo().id; + const skills: AvailableSkill[] = [ + { + repoId, + repoName: repo().name, + remote: repo().url, + name: 'installable-skill', + contentHash: 'hash-installable-skill', + hasGuidance: false, + }, + ]; + const applied: ApplyResult = { ok: true, installed: 1, removed: 0 }; + const ledgerAfterApply: InstallManifest[] = []; + return withScenario({ + repositories: [repo()], + projects: [project()], + skills, + responses: { + ...projectsPageStartupResponses(), + projects_detect_agents: ['claude'], + skills_apply: applied, + skills_list: ledgerAfterApply, + }, + }); +} + +/** `installable()`'s skill's row id on the Components page's browse tree + * (`buildRepoTree`, repo-scoped, no project prefix). */ +export function installableSkillRowId(): string { + return repoSkillKey(repo().id, undefined, 'installable-skill'); +} + +/** `installable()`'s skill's checkbox id in `SkillInstallModal`'s step 2 tree + * (`buildProjectTree`), scoped to the one project the spec picks in step 1. */ +export function installableSkillInstallCheckboxId(): string { + return projectSkillKey(project().id, repo().id, undefined, 'installable-skill'); +} + +/** + * Flow 11: a skill that `requires` another skill of the same repository (a + * bare name -- `docs/usage/skills-and-hooks.md`'s "a reference is an absolute + * skill path ... or bare `name` for an ungrouped skill"). Checking + * `needs-dependency` must also select `depended-on-skill`, marked required. + */ +export function withDependency(): Scenario { + const repoId = repo().id; + const skills: AvailableSkill[] = [ + { + repoId, + repoName: repo().name, + remote: repo().url, + name: 'needs-dependency', + requires: ['depended-on-skill'], + contentHash: 'hash-needs-dependency', + hasGuidance: false, + }, + { + repoId, + repoName: repo().name, + remote: repo().url, + name: 'depended-on-skill', + contentHash: 'hash-depended-on-skill', + hasGuidance: false, + }, + ]; + const applied: ApplyResult = { ok: true, installed: 2, removed: 0 }; + const ledgerAfterApply: InstallManifest[] = []; + return withScenario({ + repositories: [repo()], + projects: [project()], + skills, + responses: { + ...projectsPageStartupResponses(), + projects_detect_agents: ['claude'], + skills_apply: applied, + skills_list: ledgerAfterApply, + }, + }); +} + +/** `withDependency()`'s dependent skill's row id on the Components page's + * browse tree (`buildRepoTree`, repo-scoped, no project prefix). */ +export function needsDependencySkillRowId(): string { + return repoSkillKey(repo().id, undefined, 'needs-dependency'); +} + +/** `withDependency()`'s dependency's checkbox id in `SkillInstallModal`'s + * step 2 tree (`buildProjectTree`), scoped to the one project the spec picks + * in step 1. */ +export function dependedOnSkillInstallCheckboxId(): string { + return projectSkillKey(project().id, repo().id, undefined, 'depended-on-skill'); +} diff --git a/e2e/desktop/harness/commands.ts b/e2e/desktop/harness/commands.ts new file mode 100644 index 00000000..ef048b73 --- /dev/null +++ b/e2e/desktop/harness/commands.ts @@ -0,0 +1,171 @@ +import type { Scenario } from './scenario.js'; + +/** + * Prefix on the error thrown by the harness's mocked `invoke` handler for a + * command with no entry in the table below. Task 9 asserts a rejection + * message starts with this string, so it stays a distinct, greppable + * constant rather than an inline literal. + * + * Throwing (rather than resolving `undefined`, or logging and moving on) is + * the point: a renamed or newly added backend command must surface here, as + * a named failure, not three steps later as a confusing assertion mismatch. + */ +export const UNKNOWN_COMMAND_PREFIX = 'e2e-harness: unmocked command '; + +/** + * A `Scenario.responses` value meaning "this command's invoke never + * settles" -- `installHarness.ts`'s mocked callback returns a `Promise` that + * neither resolves nor rejects for it, instead of the value normally looked + * up in the response table. For a spec that needs to observe UI state WHILE a + * command is still in flight (e.g. cancelling a form before its submit's + * promise has a chance to settle), where a plain, already-resolved answer + * (see `Scenario`'s own doc comment on why `responses` is plain data) would + * settle the call within the same microtask drain the click that triggered it + * runs in -- long before Playwright's own round trip could ever observe the + * in-flight state. + * + * Deliberately narrower than a general gated/deferred response mechanism + * (see `fixtures/base.ts`'s `emit()` doc comment on why that broader "release + * on demand" mechanism is its own, unbuilt, task): a promise answered with + * this sentinel has no way to ever be released, so it only suits a spec that + * never needs the call to complete. + */ +export const NEVER_RESOLVES = 'e2e-harness: never resolves'; + +/** + * Default answers keyed by the backend command name as written in + * `apps/desktop/src/renderer/services/bridge/client.ts`. A scenario's + * `responses` are merged over this table (see `Scenario` in `scenario.ts`); + * a later task may make one of those override values a function of the + * invoke arguments instead of plain data. + * + * STARTUP COMMAND LIST (Task 2, Step 6): discovered by running + * `boot.spec.ts` against an empty table and `defaultScenario()` (macOS, + * onboarding already completed, empty repositories/projects/installs, + * update mode 'manual'), reading each `e2e-harness: unmocked command ` + * page error, adding a minimal valid answer, and repeating. + * + * Only three commands turned out to be load-bearing for the assertions in + * `boot.spec.ts` (`[data-testid="app-shell"]` visible, zero page errors) -- + * everything else `loadAll` awaits is wrapped in try/catch, so an unmocked + * response there is a caught rejection (`store.error` gets set, a background + * task's status flips to 'error'), never an uncaught page error: + * + * 1. platform -- main.tsx's `bridgeClient.init()`, awaited + * before the app mounts at all; index.html's + * preloader stays up until this settles. Left + * unmocked, `bridgeClient.platform` stays '' + * (its default), `hostPlatform('')` resolves to + * 'linux' rather than 'mac', and WindowChrome + * then calls `window_is_maximized` too -- + * chasing that command is a trap; fix `platform` + * and it disappears on its own. + * 2. onboarding_menu_sync -- App's "keep the native menu in sync" effect + * (`bridgeClient.onboardingMenuSync`) calls + * `invoke` with a bare `void`, no .catch -- an + * unmocked rejection is a genuine unhandled + * promise rejection, unconditionally, on every + * boot. + * 3. terminal_resize -- likewise a bare `void invoke(...)` with no + * .catch, fired by the always-mounted terminal + * view's initial fit/resize, independent of + * whether the terminal panel is open. + * + * TASK 3 ADDITION -- terminal_start: `TerminalView`'s mount effect awaits + * `startWithRetry(() => bridgeClient.startTerminal(...))` + * (`systems/terminal/startShell.ts`), which retries an unmocked/rejecting + * call up to `START_ATTEMPTS` (3) times with a real `setTimeout` + * (`START_RETRY_MS`, 750ms) between attempts before settling into + * `setTerminalError`. Left unmocked, every boot -- not just this suite's, any + * spec's -- pays that ~1.5s of real timers running in the background before + * the rejection is swallowed into store state, which is exactly the kind of + * nondeterministic, timer-driven tail this suite's "no timers, no randomness" + * rule exists to rule out; it also used to be invisible to `boot.spec.ts` + * (caught locally, no page error, no assertion touches `terminalOpen`), which + * is why Task 2's `__SKK_E2E_UNMOCKED__` array was the only thing that caught + * it. Mocked here so `defaultScenario()` is a genuinely complete startup and + * the fixture's post-test "nothing went unmocked" assertion (`fixture.ts`) + * never has to carry an exemption list for it. The value is the retained + * scrollback the renderer replays into the terminal on start; empty string is + * a valid "freshly started, nothing buffered yet" answer. + * + * `boot.spec.ts` would pass with only those three mocked -- `loadAll`'s + * Promise.all would simply reject and `store.error` would be set. This table + * mocks the rest of `loadAll`'s round trip anyway, because a boot test that + * tolerates the ENTIRE startup data fetch failing is not meaningfully + * proving "a scripted backend answers" (this suite's stated purpose), and + * every later task built on `defaultScenario()` wants a working store, not + * one parked in its error state: + * + * - config_get -- store.loadAll. + * - onboarding_get -- store.loadAll -> loadOnboarding. + * - repositories_list -- store.loadAll. + * - skills_reconcile -- store.loadAll (reconciles the install ledger + * against disk; NOT `skills_list`, which no + * startup path calls). + * - skills_available -- store.loadAll. + * - projects_list -- store.loadAll. + * - mcp_reconcile -- store.loadAll (NOT `mcp_installs`, same + * reason as `skills_reconcile` above). + * - get_app_version -- StatusBar's `useAppVersion`, mounted + * unconditionally alongside the shell (its own + * `.then(ok, () => undefined)` already tolerates + * a rejection, so this one was never required + * either -- included for the same reason as the + * `loadAll` set above). + * + * TASK 3 ADDITION -- mcp_list_available: NOT part of startup (`loadAll` never + * calls it -- only `refreshMcpPresets`, run after a repository + * add/update/sync or when the MCP page reads its catalog). Mocked here anyway + * so a scenario's `mcpAvailable` catalog (the MCP counterpart of `skills` + * above) is answered the moment a later task's flow reaches for it, the same + * way `skills_available` is answered though nothing in `boot.spec.ts` needs + * it either. + * + * TASK 3 ADDITION -- app_update_check: fires once, unconditionally, every + * boot (`useAppUpdateSchedule`'s startup check), independent of the + * scenario's `updates.mode`. `store.runAppUpdateCheck` wraps the call in + * try/catch, so `boot.spec.ts`'s page-error-based assertions never needed + * this mocked -- but that same try/catch is exactly what let it slip past + * unnoticed into `__SKK_E2E_UNMOCKED__` until Task 3's fixture started + * asserting that array empty after every test (see `fixtures/base.ts`). + * Answered here for the same "complete startup" reason as `terminal_start` + * above, with the least eventful `CheckOutcome`: no offer, and `suppressed: + * true` so a spec never has to reason about `checkAppUpdate`'s network-gate + * semantics by accident. + * + * Commands that need no entry at all, and why: + * - `window_is_maximized` / `window:maximizeChanged` (WindowChrome): only + * called when `hostPlatform(bridgeClient.platform) !== 'mac'`; + * `defaultScenario` reports `platform: 'darwin'`, so WindowChrome renders + * nothing and never calls either. + * - Every repository/project/MCP mutation and every `plugin:event|*` + * listener registration: `mockIPC` is called with + * `{ shouldMockEvents: true }` (see `installHarness.ts`), so `listen()` + * calls (onConfigChanged, onSshUnlockResolved, the app-update + * subscriptions, ...) are answered by `@tauri-apps/api/mocks`'s own + * internal event registry and never reach this table at all. + */ +export function defaultResponses(scenario: Scenario): Record { + return { + platform: scenario.platform, + onboarding_menu_sync: null, + terminal_resize: null, + terminal_start: '', + config_get: { + config: scenario.config, + validity: scenario.validity, + warnings: scenario.configWarnings, + }, + onboarding_get: scenario.onboarding, + repositories_list: scenario.repositories, + skills_reconcile: scenario.installs, + skills_available: { skills: scenario.skills, warnings: [] }, + projects_list: scenario.projects, + mcp_reconcile: scenario.mcpInstalls, + mcp_list_available: { mcp: scenario.mcpAvailable, warnings: [] }, + app_update_check: { offer: null, suppressed: true }, + get_app_version: '0.0.0-e2e', + ...scenario.responses, + }; +} diff --git a/e2e/desktop/harness/fixture.ts b/e2e/desktop/harness/fixture.ts new file mode 100644 index 00000000..4e39afb8 --- /dev/null +++ b/e2e/desktop/harness/fixture.ts @@ -0,0 +1,12 @@ +/** + * The name every spec imports the scenario-driven `test`/`expect` from + * (`import { test, expect } from '../harness/fixture'`). + * + * The implementation lives in `../fixtures/base.ts` -- see that file's doc + * comment for why the two are separate. This module exists so a spec reaches + * for it alongside the rest of the harness (`scenario.ts`, `installHarness.ts` + * are both in this directory) without needing to know about the `fixtures/` + * split. + */ +export { test, expect } from '../fixtures/base.js'; +export type { App, UnmockedCommand } from '../fixtures/base.js'; diff --git a/e2e/desktop/harness/installHarness.ts b/e2e/desktop/harness/installHarness.ts new file mode 100644 index 00000000..61c77f05 --- /dev/null +++ b/e2e/desktop/harness/installHarness.ts @@ -0,0 +1,144 @@ +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import type { Page } from '@playwright/test'; +import type { Scenario } from './scenario.js'; +import { NEVER_RESOLVES, UNKNOWN_COMMAND_PREFIX, defaultResponses } from './commands.js'; + +/** + * `@tauri-apps/api/mocks` (`mockIPC`, `mockWindows`) cannot be `import`ed from + * a `page.addInitScript` payload: browsers only resolve bare specifiers + * (`import('@tauri-apps/api/mocks')`) through an import map, and `vite + * preview` serves the built production bundle as plain static files with none + * configured -- that module was never part of the app's own dependency graph + * in the first place, since the app itself never imports its own test mocks. + * Bundling the harness with Vite (the brief's other suggested option) would + * need its own build step just for this. + * + * Instead, read the installed package's CommonJS build (`require.resolve` + * follows the package's "require" export condition to `mocks.cjs`, a script + * of plain function declarations ending in `exports.mockIPC = mockIPC;` etc.) + * once, here in Node, and hand its text to the page as init-script *data* + * (not code) via `installHarness`'s `addInitScript` argument. The page-side + * function runs that text through `new Function('exports', source)`, + * supplying the `exports` object the script assigns onto, entirely at + * page-runtime, with no module resolution involved at all. + * + * `@tauri-apps/api` is a dependency of `@skillkeeper/desktop`, not of the + * repository root (pnpm keeps this suite, like `e2e/cli`, outside the + * workspace), so it is resolved through a `require` rooted at the desktop + * package rather than at this file. + */ +const requireFromDesktop = createRequire(fileURLToPath(new URL('../../../apps/desktop/package.json', import.meta.url))); +const TAURI_MOCKS_SOURCE = readFileSync(requireFromDesktop.resolve('@tauri-apps/api/mocks'), 'utf8'); + +/** Data passed into the page; must stay JSON-serializable end to end (see + * `Scenario`'s doc comment on why `responses` cannot carry a function yet). */ +interface HarnessInit { + readonly scenario: Scenario; + readonly responses: Record; + readonly unknownCommandPrefix: string; + /** See `commands.ts`'s `NEVER_RESOLVES` doc comment. */ + readonly neverResolves: string; + readonly mocksSource: string; +} + +/** + * Installs the scripted backend before any application module runs. + * + * `mockIPC` writes `window.__TAURI_INTERNALS__`, and the renderer's `invoke` + * reads `window.__TAURI_INTERNALS__.invoke(...)` at call time, not at module + * load -- so this beats every lazily imported route exactly as well as it + * beats an eagerly imported one. Call this before `page.goto`. + */ +export async function installHarness(page: Page, scenario: Scenario): Promise { + const init: HarnessInit = { + scenario, + responses: defaultResponses(scenario), + unknownCommandPrefix: UNKNOWN_COMMAND_PREFIX, + neverResolves: NEVER_RESOLVES, + mocksSource: TAURI_MOCKS_SOURCE, + }; + + await page.addInitScript((arg: HarnessInit) => { + // See the module doc comment: this is the only way to get `mocks.cjs`'s + // exports out of its source text without a real module system, in a page + // served as static files with no import map. + const modFactory = new Function('exports', `${arg.mocksSource}\nreturn exports;`) as ( + exportsObj: Record, + ) => { + mockIPC: (cb: (cmd: string, args: unknown) => unknown, options?: { shouldMockEvents?: boolean }) => void; + mockWindows: (current: string, ...rest: string[]) => void; + }; + const { mockIPC, mockWindows } = modFactory({}); + + (window as unknown as Record).__SKK_E2E_CALLS__ = []; + // Writes recorded by the clipboard stub below, in write order. A spec + // reads this through `fixture.ts`'s `app.clipboard()` to assert a copy + // action without a real OS clipboard (there is none in a headless + // Chromium run, and the Clipboard API needs a user gesture besides). + (window as unknown as Record).__SKK_E2E_CLIPBOARD__ = []; + // Every unmocked command the callback below could not answer, in call + // order, as `{ cmd, message }` -- `message` is the exact string the + // thrown `Error` carried (`${unknownCommandPrefix}${cmd}`), kept + // alongside the bare name rather than reconstructed by a reader, so a + // spec asserting on it (Task 9's `harness.spec.ts` demonstration) is + // checking what the harness actually produced, not restating the same + // template a second time. `store.loadAll` and several call sites swallow + // a rejection (into `store.error`, a caught background-task status, or a + // `.then(ok, () => undefined)`), so an unmocked command does not reliably + // surface anywhere a test's DOM assertions can see it -- a test that + // needs to know reads this array directly (via `fixture.ts`'s + // `app.unmocked()`) instead. + (window as unknown as Record).__SKK_E2E_UNMOCKED__ = []; + const label = arg.scenario.windowLabel || 'main'; + (window as unknown as Record).__SKK_E2E_WINDOW_LABEL__ = label; + + // main.tsx renders SshUnlockApp instead of App when the current window's + // label is 'ssh-unlock' -- see Scenario's doc comment on windowLabel. + mockWindows(label); + + // The clipboard-manager plugin command `writeText` invokes -- see + // `@tauri-apps/plugin-clipboard-manager`'s `writeText`. Handled as a fixed + // stub rather than a `commands.ts` table entry: a copy action's payload is + // whatever the app computed at click time, not scenario data, and the + // recording needs to happen for every scenario without each one having to + // opt in. + const CLIPBOARD_WRITE_TEXT = 'plugin:clipboard-manager|write_text'; + + // `shouldMockEvents: true` routes every `plugin:event|listen` / + // `plugin:event|emit` / `plugin:event|unlisten` invoke to mockIPC's own + // in-page listener registry instead of to the callback below. Without it, + // the app's first `listen()` call (useConfigWatch's onConfigChanged fires + // during App's very first effect pass) would throw "unmocked command + // plugin:event|listen" before the command table below ever got a look in. + mockIPC( + (cmd, args) => { + const calls = (window as unknown as Record).__SKK_E2E_CALLS__ as unknown[]; + calls.push({ cmd, args }); + if (cmd === CLIPBOARD_WRITE_TEXT) { + const clipboard = (window as unknown as Record).__SKK_E2E_CLIPBOARD__ as string[]; + const text = (args as { text?: unknown } | undefined)?.text; + clipboard.push(typeof text === 'string' ? text : ''); + return null; + } + if (!Object.prototype.hasOwnProperty.call(arg.responses, cmd)) { + const unmocked = (window as unknown as Record).__SKK_E2E_UNMOCKED__ as { + cmd: string; + message: string; + }[]; + const message = `${arg.unknownCommandPrefix}${cmd}`; + unmocked.push({ cmd, message }); + throw new Error(message); + } + const response = arg.responses[cmd]; + // See `commands.ts`'s `NEVER_RESOLVES` doc comment: a scenario opts a + // command into this instead of an ordinary answer when a spec needs + // to observe the app WHILE that command's invoke is still pending. + if (response === arg.neverResolves) return new Promise(() => undefined); + return response; + }, + { shouldMockEvents: true }, + ); + }, init); +} diff --git a/e2e/desktop/harness/scenario.ts b/e2e/desktop/harness/scenario.ts new file mode 100644 index 00000000..98c61def --- /dev/null +++ b/e2e/desktop/harness/scenario.ts @@ -0,0 +1,165 @@ +/** + * The scripted backend's fixture data for one Playwright run. + * + * A `Scenario` is plain, JSON-serializable data: `installHarness` carries it + * across into the page via `page.addInitScript`'s argument, which uses + * Playwright's structured-clone-style serialization -- functions and other + * non-serializable values do not survive that trip. `responses` is the one + * deliberate escape hatch: `commands.ts`'s `defaultResponses` merges it over + * the default command table, so a scenario built entirely from plain data (as + * `defaultScenario` is) never runs into the restriction; a later task that + * needs a per-call computed answer resolves it a different way (see + * `installHarness.ts`'s notes on that boundary). + * + * Every field type below is imported from the same generated/hand-written + * sources the renderer itself uses (`apps/desktop/src/renderer/services/ + * bridge/generated/**` for the ts-rs output, `.../bridge/contracts.ts` for the + * hand-written result wrappers) -- never a hand-rolled approximation. A + * scenario that drifts from those shapes is a defect here, not a "close + * enough" fixture; the renderer would reject the same payload from the real + * backend. + */ +import type { + SkillKeeperConfig, + OnboardingState, + SectionValidity, +} from '../../../apps/desktop/src/renderer/services/bridge/generated/config/index.js'; +import type { + Repository, + Project, + InstallManifest, +} from '../../../apps/desktop/src/renderer/services/bridge/generated/core/index.js'; +import type { + AvailableSkill, + AvailableMcp, + McpInstall, +} from '../../../apps/desktop/src/renderer/services/bridge/contracts.js'; + +export interface Scenario { + /** `process.platform` as the Rust backend reports it: 'darwin' | 'win32' | 'linux'. */ + readonly platform: string; + /** The mocked Tauri window label. `main.tsx` mounts an entirely different + * app (`SshUnlockApp`) for the 'ssh-unlock' label, so this is load-bearing, + * not decoration -- default 'main'. */ + readonly windowLabel: string; + readonly config: SkillKeeperConfig; + /** Per-section validity `config_get` reports alongside `config` (see + * `harness/commands.ts`'s `defaultResponses`). `store.setConfig` reads + * this straight off the result into `configValidity`, and `ConfigBanner` + * reads it back -- a scenario that wants the invalid-config banner visible + * sets one section here to `'invalid'` (and usually pairs it with a + * `configWarnings` entry, since `ConfigBanner` lists `configWarnings` + * underneath the banner text). */ + readonly validity: SectionValidity; + /** Warnings `config_get` reports alongside `validity` -- one human-readable + * line per invalid section, by convention (see `LoadConfigResult`'s own + * doc comment), though nothing enforces that count here. */ + readonly configWarnings: readonly string[]; + readonly onboarding: OnboardingState; + readonly repositories: readonly Repository[]; + readonly projects: readonly Project[]; + readonly installs: readonly InstallManifest[]; + /** The `skills_available` catalog (installable skills across all tracked + * repositories), independent of `installs` (what is already installed). + * `skills_available`'s `warnings` half stays a fixed empty array in + * `commands.ts` -- no flow in this plan needs one yet. */ + readonly skills: readonly AvailableSkill[]; + /** The `mcp_list_available` catalog (installable MCP presets discovered from + * tracked repositories), independent of `mcpInstalls` (what is already + * installed) -- same split as `skills`/`installs` above. Its `warnings` + * half is likewise fixed to an empty array in `commands.ts`. Not part of + * startup (`loadAll` never calls it; only `refreshMcpPresets`, run after a + * repository add/update/sync or when the MCP page asks), so an empty + * default costs `defaultScenario` nothing. */ + readonly mcpAvailable: readonly AvailableMcp[]; + readonly mcpInstalls: readonly McpInstall[]; + /** + * Per-command overrides, merged over `commands.ts`'s defaults. A value here + * is plain data for now (see the file-level note on why a function value + * cannot cross into the page yet). + */ + readonly responses: Record; + /** + * Reserved for a scenario that wants to declare named event payloads + * up front (e.g. to seed a subscriber before the spec's first assertion). + * Unused today: `fixture.ts`'s `app.emit(name, payload)` is the imperative + * escape hatch a spec uses instead, dispatched on demand through the mocked + * event plugin rather than replayed from scenario data. Kept as part of the + * `Scenario` shape so a later task can add that replay without another + * interface change. + */ + readonly events: Record; +} + +/** A `SkillKeeperConfig` with every section present and valid, matching what + * a fresh install's `config.yaml` defaults resolve to. */ +function emptyConfig(): SkillKeeperConfig { + return { + general: { language: 'en', theme: 'system', animations: 'normal' }, + updates: { mode: 'manual', intervalMinutes: 720, checkOnStartup: false }, + agents: { enabled: ['claude', 'codex', 'copilot', 'cursor', 'opencode'], overrides: {} }, + executables: { globs: [] }, + security: { hookConsentPolicy: 'always-ask' }, + notifications: { enabled: true }, + repositories: { gitPath: 'git' }, + projects: { checkIntervalMinutes: 1 }, + mcp: { servers: [] }, + }; +} + +/** No skills installed anywhere -- the ledger `skills_reconcile` returns for a + * fresh install. */ +function emptyManifest(): InstallManifest[] { + return []; +} + +/** Every config section reported valid -- the default `config_get` validity, + * matching what a config that parsed cleanly resolves to. */ +function allValidValidity(): SectionValidity { + return { + general: 'valid', + updates: 'valid', + agents: 'valid', + executables: 'valid', + security: 'valid', + notifications: 'valid', + repositories: 'valid', + projects: 'valid', + mcp: 'valid', + }; +} + +/** + * A scenario with a clean, empty-but-valid backend: no repositories, no + * projects, no installs, onboarding already completed (so the guided tour + * never opens unprompted), update checks on 'manual' (so nothing schedules a + * startup sweep), and `platform: 'darwin'` -- which resolves to the 'mac' + * chrome variant (see `hostPlatform.ts`), so `WindowChrome` renders nothing + * and the `window_is_maximized`/`window:maximizeChanged` round trip it would + * otherwise drive never comes into play. + */ +export function defaultScenario(): Scenario { + return { + platform: 'darwin', + windowLabel: 'main', + config: emptyConfig(), + validity: allValidValidity(), + configWarnings: [], + onboarding: { version: 1, completed: true, step: 'done' }, + repositories: [], + projects: [], + installs: emptyManifest(), + skills: [], + mcpAvailable: [], + mcpInstalls: [], + responses: {}, + events: {}, + }; +} + +/** `defaultScenario()` with `patch` shallow-merged over it. A field in `patch` + * replaces the default wholesale (e.g. `repositories: [...]` replaces the + * empty array, it does not append to it). */ +export function withScenario(patch: Partial): Scenario { + return { ...defaultScenario(), ...patch }; +} diff --git a/e2e/desktop/playwright.config.ts b/e2e/desktop/playwright.config.ts new file mode 100644 index 00000000..f1630af8 --- /dev/null +++ b/e2e/desktop/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +// The suite drives the PRODUCTION bundle, not the dev server: `vite preview` +// serves what `vite build` wrote to dist-tauri. That removes module +// transformation, the hot-module transport, and the dev/prod split from the +// failure surface, and makes every run prove the bundle boots. The webServer +// command below chains the build in: `vite preview` serves a stale +// `dist-tauri` without complaint (only a MISSING one exits loudly), so +// building every time a fresh server actually launches is what stops a +// developer from testing yesterday's bundle and believing today's passed. +// `reuseExistingServer: !process.env.CI` still means a server already +// listening on :4173 from an earlier run is reused with no rebuild at all -- +// stop it between real changes to the renderer if you rely on this config +// picking them up locally. +export default defineConfig({ + testDir: './tests', + // A retry turns a flake into a slow pass and throws away the signal. If a + // spec is unstable, the spec or the harness is wrong. + retries: 0, + fullyParallel: true, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']], + use: { + baseURL: 'http://localhost:4173', + // Layout-dependent visibility must not depend on the runner's default. + viewport: { width: 1440, height: 900 }, + // The renderer animates with `motion`; a moving target is the classic + // reason a click lands nowhere. Task 3 also zeroes durations in CSS. + reducedMotion: 'reduce', + locale: 'en-US', + // `retries: 0` above means a trace on-first-retry never gets a retry to + // fire on -- the two settings would otherwise cancel out and every + // failure would ship with no trace at all. + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: + 'pnpm --filter @skillkeeper/desktop run frontend:build && ' + + 'pnpm --filter @skillkeeper/desktop exec vite preview --port 4173 --strictPort', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/e2e/desktop/tests/boot.spec.ts b/e2e/desktop/tests/boot.spec.ts new file mode 100644 index 00000000..4798ff40 --- /dev/null +++ b/e2e/desktop/tests/boot.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '../harness/fixture'; + +test('the application mounts against the scripted backend', async ({ app, page }) => { + const failures: string[] = []; + page.on('pageerror', (e) => failures.push(e.message)); + await app.goto(); + // `` mounts inside main.tsx's `bridgeClient.init().finally(...)`, + // which runs its callback on rejection too -- so `app-shell` appearing does + // NOT by itself prove the harness answered anything. `toBeVisible()` also + // checks bounding box and CSS visibility, not occlusion, so it would still + // pass with `#sk-preloader` (a `position: fixed; inset: 0; z-index: + // 2147483647` overlay) sitting on top of everything. `#sk-preloader` is + // only removed by App.tsx's own effect, once `loading` has cycled true then + // false -- proof the mount-to-first-effects lifecycle ran to completion, not + // proof any one command was answered correctly. What actually catches a + // broken command (like an unmocked `platform`, which several call sites + // never await/catch) is the `failures` array below, from `pageerror`. All + // three assertions together are what the "scripted backend answers" + // round-trip needs; any one alone would pass on a harness with real gaps. + await expect(page.getByTestId('app-shell')).toBeVisible(); + await expect(page.locator('#sk-preloader')).toHaveCount(0); + expect(failures, failures.join('\n')).toEqual([]); +}); diff --git a/e2e/desktop/tests/harness.spec.ts b/e2e/desktop/tests/harness.spec.ts new file mode 100644 index 00000000..cc20e9cc --- /dev/null +++ b/e2e/desktop/tests/harness.spec.ts @@ -0,0 +1,161 @@ +/** + * Proves the scenario-driven fixture actually threads a spec's data through + * to the scripted backend, and that its two imperative escape hatches + * (`app.emit`, `app.clipboard`) actually work end to end -- not just by + * construction. + * + * The first test asserts on a rendered `repo-row` (Task 4's testid): the + * repository the scenario seeds shows up in the Repositories page, proving + * the scenario's data reaches all the way to the DOM, not just the bridge + * boundary `app.calls('repositories_list')` alone would prove. `repositories_ + * describe` (the card's branch/skill-count badges) is mocked here too -- + * `RepositoriesPage` fetches it for every listed repository on mount + * (`refreshRepoInfo`), and it carries no default in `harness/commands.ts` (see + * that file's doc comment): unlike `repositories_list`, it is not part of + * `store.loadAll`'s startup round trip. + * + * The other two are self-tests of the fixture itself, not of the app: five + * later tasks compose specs against `app.emit`/`app.clipboard` (one + * specifically needs `app.emit` to drive `skills:progress`), and neither has + * any application UI to exercise it through yet. Each drives the exact wire + * shape a real caller would -- `plugin:event|listen`/`|emit` the way + * `@tauri-apps/api/event`'s `listen`/`emit` build them (see `core.js`'s + * `invoke` and `event.js`'s `listen`), and `plugin:clipboard-manager|write_text` + * the way `@tauri-apps/plugin-clipboard-manager`'s `writeText` builds it -- + * from inside the page, entirely independent of `fixture.ts`'s own + * implementation, so a wrong shape on either side surfaces here. + * + * Task 9 adds one more self-test, in the final `describe` block below: proof + * that `fixtures/base.ts`'s "nothing went unmocked" guard can actually fail. + * Every other spec in this suite only ever exercises the guard's PASSING + * path (no scenario has ever left a command it actually exercises + * unanswered on purpose), so without this the guard's failing path would be + * verified by hand only -- exactly the kind of silent regression this + * suite's own stated purpose (a scripted backend, not a decorative one) + * exists to rule out. + */ +import { test, expect } from '../harness/fixture'; +import { withScenario } from '../harness/scenario'; +import { UNKNOWN_COMMAND_PREFIX } from '../harness/commands'; + +test.use({ + scenario: withScenario({ + repositories: [ + { + id: 'demo', + name: 'demo', + url: 'https://example.invalid/demo.git', + kind: 'generic', + transport: 'https', + lfs: false, + localPath: '/repos/demo', + branch: 'main', + }, + ], + responses: { + repositories_describe: { branch: 'main', skillCount: 0 }, + }, + }), +}); + +test('a scenario decides what the backend returns', async ({ app, page }) => { + await app.goto(); + await expect(page.getByTestId('app-shell')).toBeVisible(); + await page.getByTestId('nav-repositories').click(); + const row = page.getByTestId('repo-row').filter({ has: page.locator('[data-repo-name="demo"]') }); + await expect(row).toBeVisible(); + const calls = await app.calls('repositories_list'); + expect(calls.length).toBeGreaterThan(0); +}); + +/** Shape of `window.__TAURI_INTERNALS__` this file's self-tests reach into + * directly, matching `fixture.ts`'s own narrowing. */ +interface TauriInternals { + readonly invoke: (cmd: string, args: unknown) => Promise; + readonly transformCallback: (callback: (data: unknown) => void) => number; +} + +test('app.emit dispatches to a page-registered listener', async ({ app, page }) => { + await app.goto(); + + // Register a listener the same way `@tauri-apps/api/event`'s `listen()` + // registers one under the hood: mint a callback id via `transformCallback`, + // then invoke `plugin:event|listen` with it. This is the harness's own + // mocked event plugin (`installHarness.ts`'s `shouldMockEvents`), reached + // directly rather than through the app -- no application code needs to + // exist for this to prove `app.emit` actually dispatches. + await page.evaluate(() => { + const internals = (window as unknown as { __TAURI_INTERNALS__: TauriInternals }).__TAURI_INTERNALS__; + const received = window as unknown as Record; + received.__E2E_SELF_TEST_RECEIVED__ = undefined; + const handler = internals.transformCallback((data) => { + received.__E2E_SELF_TEST_RECEIVED__ = data; + }); + return internals.invoke('plugin:event|listen', { + event: 'e2e-self-test', + target: { kind: 'Any' }, + handler, + }); + }); + + await app.emit('e2e-self-test', { hello: 'world' }); + + const received = await page.evaluate(() => (window as unknown as Record).__E2E_SELF_TEST_RECEIVED__); + expect(received).toEqual({ event: 'e2e-self-test', payload: { hello: 'world' } }); +}); + +test('app.clipboard records a write made through the clipboard plugin', async ({ app, page }) => { + await app.goto(); + + // The exact command name and argument shape + // `@tauri-apps/plugin-clipboard-manager`'s `writeText()` sends (see its + // `dist-js/index.js`), reached directly rather than through the app -- + // there is no copy button to click yet. + await page.evaluate(() => { + const internals = (window as unknown as { __TAURI_INTERNALS__: TauriInternals }).__TAURI_INTERNALS__; + return internals.invoke('plugin:clipboard-manager|write_text', { text: 'copied-by-self-test' }); + }); + + const clipboard = await app.clipboard(); + expect(clipboard).toEqual(['copied-by-self-test']); +}); + +test.describe('a command with no scripted answer', () => { + // The default scenario (unchanged here) never gives `repositories_add` a + // response -- see `fixtures/repositories.ts`'s doc comment: it is not part + // of `store.loadAll`'s startup round trip, so `defaultScenario()` leaves it + // genuinely unmocked on purpose, exactly like every other add/update/apply + // command a spec must supply its own answer for. Driving the add form + // without doing that is this test's whole point, so `expectUnmocked` names + // it instead of a scenario override supplying one. + test.use({ expectUnmocked: ['repositories_add'] }); + + test('is recorded by name and message, not silently accepted', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-repositories').click(); + await page.getByTestId('repo-add-button').click(); + await page.getByTestId('repo-add-url').fill('https://example.invalid/unmocked.git'); + await page.getByTestId('repo-add-submit').click(); + + // `RepoAddButton`'s submit handler (`features/repoAdd/ui/RepoAddButton. + // tsx`) catches `addRepository`'s rejection and renders `err.message` + // verbatim -- so if the harness ever stopped throwing a named error here + // (e.g. resolved `undefined` instead), this banner would either never + // appear or would stop naming the command, and this assertion would fail + // for that reason rather than passing on a coincidence. + await expect(page.getByTestId('repo-add-error')).toContainText(UNKNOWN_COMMAND_PREFIX + 'repositories_add'); + + // The authoritative check: exactly one command went unmocked, and its + // recorded message is the real `UNKNOWN_COMMAND_PREFIX` constant (not a + // hardcoded copy of today's string) followed by the offending command's + // own name -- so renaming or reformatting that constant without updating + // every place it is produced breaks this assertion instead of passing + // quietly. `expectUnmocked: ['repositories_add']` above only stops the + // fixture's teardown from failing the test over this EXACT name; a + // second, unrelated command going unmocked at the same time (or none at + // all) would still fail it (see that option's doc comment in + // `fixtures/base.ts`). + const unmocked = await app.unmocked(); + expect(unmocked).toEqual([{ cmd: 'repositories_add', message: UNKNOWN_COMMAND_PREFIX + 'repositories_add' }]); + }); +}); diff --git a/e2e/desktop/tests/mcp.spec.ts b/e2e/desktop/tests/mcp.spec.ts new file mode 100644 index 00000000..a7afdbe3 --- /dev/null +++ b/e2e/desktop/tests/mcp.spec.ts @@ -0,0 +1,193 @@ +/** + * Flows 7, 8 and 12 (MCP pages): installing a preset with a description and + * an option-constrained parameter into a tracked project, updating an + * installed instance whose preflight reports a missing parameter, and + * updating one whose update SKIPS an agent that cannot express its + * transport -- the regression test for the 0.7.0 fix "Updating an MCP + * server no longer deletes it when the new definition cannot be installed". + * Flow 12 is NOT a preflight refusal -- that mechanism cannot occur for this + * cause; the comment on the flow itself explains why. + * + * MCP is a two-level sidebar group exactly like Skills (see `App.tsx`'s + * `NAV_ITEMS` comment): `nav-group-mcp` must be expanded before either of + * its two sub-items (`nav-mcp-components`, `nav-mcp-management`) is + * clickable. All three flows below use `nav-mcp-management` -- the page + * whose Install and Update badges (`useMcpActions`) carry this task's + * testids. + */ +import { test, expect } from '../harness/fixture'; +import type { Page } from '@playwright/test'; +import { + withParameters, + withParametersInstallRowId, + updatable, + transportSkipped, + installedInstanceRowId, +} from '../fixtures/mcp'; + +/** + * A `mcp-server-row` identified by its own (now-unique) `data-mcp-name` + * tree-node id -- see `pages/Mcp/lib/mcpTree.tsx`'s "ROW IDENTITY" comment + * and `fixtures/mcp.ts`'s `withParametersInstallRowId`/ + * `installedInstanceRowId` for why this is an id, not a bare preset name. + */ +function mcpRow(page: Page, rowId: string) { + return page.getByTestId('mcp-server-row').filter({ has: page.locator(`[data-mcp-name="${rowId}"]`) }); +} + +test.describe('installing an mcp server with a description and an option parameter', () => { + test.use({ scenario: withParameters() }); + + test('the description renders as spans and the option select offers its labels', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-group-mcp').click(); + await page.getByTestId('nav-mcp-management').click(); + await expect(page.getByTestId('mcp-page')).toBeVisible(); + + // The tracked project's own root is already expanded (it is a tree + // root); the repo node nested under it is not -- mirroring + // `skills.spec.ts`'s "browsing skills" flow, expanding it reveals the + // preset's install row. Global's own root ALSO shows a copy of the same + // repo/preset (every repo preset gets an install row per scope shown), + // so the click is scoped to the "Demo" project's own branch -- its + // `[role="treeitem"]` is the only one whose (accumulated, nested) text + // contains "Demo" at all. + const demoRoot = page.locator('[role="treeitem"]').filter({ hasText: 'Demo' }); + await demoRoot.getByText('mcp-repo', { exact: true }).click(); + + const row = mcpRow(page, withParametersInstallRowId()); + await row.getByTestId('mcp-install-open').click(); + + const modal = page.getByTestId('mcp-install-modal'); + await expect(modal).toBeVisible(); + + // The backend has already parsed the description into spans by the time + // it reaches the renderer (`mcp_description_spans`, mocked by the + // scenario) -- this asserts it rendered as those spans (a link button + // reading "GitHub" alongside its surrounding text), never as the raw + // `[GitHub](https://github.com)` markup string. See + // `features/mcpInstall/lib/descriptionRenderSites.test.ts` for the + // source-level rule this is the end-to-end counterpart of. + const description = page.getByTestId('mcp-install-description'); + await expect(description).toContainText('Connect to'); + await expect(description.getByRole('button', { name: 'GitHub' })).toBeVisible(); + await expect(description).toContainText('for issues.'); + const descriptionText = await description.textContent(); + expect(descriptionText).not.toContain('['); + expect(descriptionText).not.toContain(']'); + expect(descriptionText).not.toContain('(https://github.com)'); + + // `exact: true` and scoped to the modal: the toolbar's own "Projects" + // filter combobox is also on screen, and a substring match on "Project" + // would otherwise resolve to both. + await modal.getByRole('combobox', { name: 'Project', exact: true }).click(); + await page.getByRole('option', { name: 'Demo' }).click(); + // The native checkbox input is visually hidden (`Checkbox.scss`'s + // `.sk-checkbox__input`, zero-size + opacity 0 -- the styled box is a + // sibling), so it never becomes Playwright-"visible" itself; clicking its + // label text toggles it exactly as a real click anywhere on the row would. + await modal.getByText('Claude', { exact: true }).click(); + + // The option-constrained parameter renders as a Select, not a text + // field; opening it must offer both authored option labels. + const regionField = page + .getByTestId('mcp-param-select') + .filter({ has: page.locator('[data-param-name="region"]') }); + await regionField.getByRole('button').click(); + await expect(page.getByRole('option', { name: 'United States' })).toBeVisible(); + await page.getByRole('option', { name: 'Europe' }).click(); + + await page.getByTestId('mcp-install-submit').click(); + await expect(modal).toBeHidden(); + + const applyCalls = await app.calls('mcp_apply'); + expect(applyCalls).toHaveLength(1); + // Not just "was called once" -- a Select that always submitted its FIRST + // option (United States) regardless of the click above would pass that + // check too. This confirms the chosen option ("Europe", value `eu`) + // actually reached `mcp_apply`'s payload. + const { batches } = ( + applyCalls[0] as { args: { batches: readonly { install: readonly { values: Record }[] }[] } } + ).args; + expect(batches.flatMap((batch) => batch.install).map((install) => install.values)).toEqual([{ region: 'eu' }]); + }); +}); + +test.describe('updating an mcp server with a missing parameter', () => { + test.use({ scenario: updatable() }); + + test('a missing parameter is asked for and the update proceeds', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-group-mcp').click(); + await page.getByTestId('nav-mcp-management').click(); + await expect(page.getByTestId('mcp-page')).toBeVisible(); + + const row = mcpRow(page, installedInstanceRowId()); + await expect(row).toBeVisible(); + + // The preflight runs eagerly, before any modal opens (see + // `useMcpActions.tsx`'s `startMcpUpdateAsync`); it reports the source's + // new `{token}` placeholder missing from this instance's stored params, + // which is what opens `McpUpdateParamsModal` with exactly that field. + await row.getByTestId('mcp-update-open').click(); + + const modal = page.getByTestId('mcp-update-modal'); + await expect(modal).toBeVisible(); + + const tokenField = page.getByTestId('mcp-param-input').filter({ has: page.locator('[data-param-name="token"]') }); + await expect(tokenField).toBeVisible(); + await tokenField.locator('input').fill('secret-token'); + + await page.getByTestId('mcp-update-submit').click(); + await expect(modal).toBeHidden(); + + const calls = await app.calls('mcp_update'); + expect(calls).toHaveLength(1); + const { updates } = (calls[0] as { args: { updates: readonly { values: Record }[] } }).args; + expect(updates[0]?.values).toEqual({ token: 'secret-token' }); + }); +}); + +test.describe('an mcp update the agent cannot express', () => { + test.use({ scenario: transportSkipped() }); + + test('leaves the instance alone and reports why', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-group-mcp').click(); + await page.getByTestId('nav-mcp-management').click(); + await expect(page.getByTestId('mcp-page')).toBeVisible(); + + const row = mcpRow(page, installedInstanceRowId()); + await expect(row).toBeVisible(); + + // The preflight accepts the update outright (nothing is missing -- + // `preflight_inner` never checks transport support), so no params modal + // opens at all: the click runs the update straight through. + await row.getByTestId('mcp-update-open').click(); + + // `updateMcp` succeeds overall but skips codex specifically (`reason: + // 'transport'`); `runMcpUpdate` (`useMcpActions.tsx`) turns that into an + // info-level `notify`, which surfaces as a toast -- there is no modal in + // this path at all, so there is nothing to press Confirm on. + const toast = page.getByTestId('toast'); + await expect(toast).toContainText('Codex'); + await expect(toast).toContainText('http'); + + // What this proves, and what it does not: the skip message reaches the + // user and `mcp_update` was called exactly once -- both asserted below. + // It does NOT prove the instance survived the update on the backend: this + // scenario's `mcp_installs` (`fixtures/mcp.ts`'s `installedInstance()`) + // answers with the same fixed row regardless of what `mcp_update` itself + // returned, so `row` being visible here is the harness's fixed answer, + // not a falsifiable check on `updateMcp`'s effect. Making it falsifiable + // needs `mcp_installs` to answer differently before and after + // `mcp_update` runs -- a sequenced/gated-response mechanism this harness + // does not have (see `fixtures/base.ts`'s `emit()` doc comment; that + // mechanism is scoped as its own, unbuilt, task). The claim that the + // instance is never removed -- the actual point of the 0.7.0 fix -- is + // covered instead by `update_inner`'s own Rust tests. + await expect(row).toBeVisible(); + + expect(await app.calls('mcp_update')).toHaveLength(1); + }); +}); diff --git a/e2e/desktop/tests/projects.spec.ts b/e2e/desktop/tests/projects.spec.ts new file mode 100644 index 00000000..b03caff6 --- /dev/null +++ b/e2e/desktop/tests/projects.spec.ts @@ -0,0 +1,41 @@ +/** + * Flows 4 and 10 (Projects page): tracking a project, and a project whose + * folder has gone missing. + * + * Projects is the default view (`App.tsx`'s `activeView` starts at + * 'projects'), so unlike the Repositories and Skills specs, neither test + * navigates before asserting. + */ +import { test, expect } from '../harness/fixture'; +import { trackable, folderMissing } from '../fixtures/projects'; + +test.describe('tracking a project', () => { + test.use({ scenario: trackable() }); + + test('adding a project shows its card with agent badges', async ({ app, page }) => { + await app.goto(); + await expect(page.getByTestId('projects-page')).toBeVisible(); + await page.getByTestId('project-add-button').click(); + const card = page + .getByTestId('project-card') + .filter({ has: page.locator('[data-project-id="tracked-project-id"]') }); + await expect(card).toBeVisible(); + // Asserts the label text, not just visibility: `agentCount: 2` + // (`fixtures/projects.ts`) is set specifically to make this badge + // render at all, so its actual count should be read back too. + await expect(card.getByTestId('project-card-agents')).toHaveText('2 agents'); + }); +}); + +test.describe('a project whose folder is missing', () => { + test.use({ scenario: folderMissing() }); + + test('a missing folder shows the folder-missing affordance, not a generic error', async ({ app, page }) => { + await app.goto(); + const card = page + .getByTestId('project-card') + .filter({ has: page.locator('[data-project-id="missing-project-id"]') }); + await expect(card).toBeVisible(); + await expect(card.getByTestId('project-card-folder-missing')).toBeVisible(); + }); +}); diff --git a/e2e/desktop/tests/repositories.spec.ts b/e2e/desktop/tests/repositories.spec.ts new file mode 100644 index 00000000..9cd9e733 --- /dev/null +++ b/e2e/desktop/tests/repositories.spec.ts @@ -0,0 +1,88 @@ +/** + * Flows 1 and 9 (Repositories page): adding a repository, and a repository + * that fails to add at all. + * + * Each flow needs its own scenario (`repositories_add` answers success in one, + * failure in the other), so each gets its own `describe` block: `test.use` + * applies to every test declared after it within the same block, not to the + * whole file. + */ +import { test, expect } from '../harness/fixture'; +import { oneRepository, cloneFails, duplicateFails, addNeverResolves } from '../fixtures/repositories'; + +test.describe('adding a repository', () => { + test.use({ scenario: oneRepository() }); + + test('adding a repository shows it in the list', async ({ app, page }) => { + await app.goto(); + // Repositories is not the default view (Projects is) -- see App.tsx's + // `activeView` initial state. + await page.getByTestId('nav-repositories').click(); + await expect(page.getByTestId('repositories-page')).toBeVisible(); + await page.getByTestId('repo-add-button').click(); + await page.getByTestId('repo-add-url').fill('https://example.invalid/demo.git'); + await page.getByTestId('repo-add-submit').click(); + const row = page.getByTestId('repo-row').filter({ has: page.locator('[data-repo-name="demo"]') }); + await expect(row).toBeVisible(); + await expect(row.getByTestId('repo-row-branch')).toHaveText('main'); + }); +}); + +test.describe('a repository that will not clone', () => { + test.use({ scenario: cloneFails('authentication failed') }); + + test('a repository that will not clone reports the error and adds no row', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-repositories').click(); + await page.getByTestId('repo-add-button').click(); + await page.getByTestId('repo-add-url').fill('https://example.invalid/nope.git'); + await page.getByTestId('repo-add-submit').click(); + await expect(page.getByTestId('repo-add-error')).toContainText('authentication failed'); + await expect(page.getByTestId('repo-row')).toHaveCount(0); + }); +}); + +test.describe('a repository that already exists', () => { + test.use({ scenario: duplicateFails() }); + + test('a duplicate repository reports the error and adds no second row', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-repositories').click(); + // The one pre-existing repository, seeded by the scenario -- confirms the + // page starts with exactly one row before the duplicate submit runs. + await expect(page.getByTestId('repo-row')).toHaveCount(1); + await page.getByTestId('repo-add-button').click(); + await page.getByTestId('repo-add-url').fill('https://example.invalid/demo.git'); + await page.getByTestId('repo-add-submit').click(); + await expect(page.getByTestId('repo-add-error')).toBeVisible(); + await expect(page.getByTestId('repo-row')).toHaveCount(1); + }); +}); + +test.describe('cancelling an add while it is still in flight', () => { + test.use({ scenario: addNeverResolves() }); + + // Regression for RepoAddButton.tsx: cancelling out of (or dismissing) the + // dialog while a submit's `addRepository` call is still outstanding used to + // leave `submitting` stuck `true` forever -- neither the stale-token early + // return in the `.then`/`.catch` handlers nor `cancel()` itself reset it, so + // the Add button stayed disabled on reopen with no way to recover short of + // navigating away (which unmounts the form). `repositories_add` never + // settles in this scenario (`addNeverResolves`), so the submit is + // genuinely still in flight when Close is clicked, not merely fast enough + // to look that way. + test('the add button is usable again after cancelling mid-flight', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-repositories').click(); + await page.getByTestId('repo-add-button').click(); + await page.getByTestId('repo-add-url').fill('https://example.invalid/demo.git'); + await page.getByTestId('repo-add-submit').click(); + // Confirms the submit is genuinely in flight before cancelling out of it. + await expect(page.getByTestId('repo-add-submit')).toBeDisabled(); + await page.getByRole('button', { name: 'Close' }).click(); + await expect(page.getByTestId('repo-add-form')).toBeHidden(); + await page.getByTestId('repo-add-button').click(); + await page.getByTestId('repo-add-url').fill('https://example.invalid/demo.git'); + await expect(page.getByTestId('repo-add-submit')).toBeEnabled(); + }); +}); diff --git a/e2e/desktop/tests/settings.spec.ts b/e2e/desktop/tests/settings.spec.ts new file mode 100644 index 00000000..eaf0fd15 --- /dev/null +++ b/e2e/desktop/tests/settings.spec.ts @@ -0,0 +1,85 @@ +/** + * Flows 5 and 6 (Settings and self-update): opening Settings renders every + * section against an all-valid config, and an offered update completes into + * the "ready to install" status once `appUpdate:ready` arrives. + * + * Settings is a FLAT sidebar item (`nav-settings`), not a group -- unlike + * Skills/MCP it has no sub-items to expand first. + */ +import { test, expect } from '../harness/fixture'; +import { settingsPage, invalidSection, offeredUpdate, REPOSITORIES_INVALID_WARNING } from '../fixtures/settings'; + +test.describe('the settings page', () => { + test.use({ scenario: settingsPage() }); + + test('every section renders and reports valid', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-settings').click(); + await expect(page.getByTestId('settings-page')).toBeVisible(); + + // Each section's identity sits on its title (a CHILD of the + // `settings-section` container), never on the container itself -- see + // `FormSection`'s `sectionId` doc comment. + const sectionIds = ['general', 'repositories', 'projects', 'onboarding', 'app-updates']; + for (const id of sectionIds) { + const section = page.getByTestId('settings-section').filter({ has: page.locator(`[data-section-id="${id}"]`) }); + await expect(section).toBeVisible(); + } + + // The scenario's `config_get` reports every section 'valid' (see + // `fixtures/settings.ts`'s `settingsPage`); the user-visible sign of that + // is that the invalid-config banner (`ConfigBanner`, `role="alert"`) + // never appears. This assertion only has power to fail paired with the + // "an invalid section" test below, which drives the same banner from the + // opposite scenario -- on its own it would pass even if `config_get`'s + // validity never reached the UI at all. + await expect(page.getByRole('alert')).toHaveCount(0); + }); +}); + +test.describe('an invalid section', () => { + test.use({ scenario: invalidSection() }); + + test('the config banner reports the invalid section and its warning', async ({ app, page }) => { + await app.goto(); + + // `ConfigBanner` is mounted app-wide (`App.tsx`, alongside `WindowChrome`), + // not scoped to the Settings page, so it is already visible on the + // default Projects view -- no navigation needed for this assertion. + const banner = page.getByRole('alert'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText(REPOSITORIES_INVALID_WARNING); + }); +}); + +test.describe('an offered update', () => { + test.use({ scenario: offeredUpdate() }); + + test('checking now then receiving a ready event surfaces the ready status', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-settings').click(); + await expect(page.getByTestId('settings-page')).toBeVisible(); + + // No status is showing yet -- the "ready to install" dialog only mounts + // its content once `appUpdateReadyOpen` is true (`Modal` renders nothing + // while closed). + await expect(page.getByTestId('app-update-status')).toBeHidden(); + + await page.getByTestId('app-update-check-button').click(); + const calls = await app.calls('app_update_check_now'); + expect(calls).toHaveLength(1); + + // `useAppUpdateSchedule`'s `onAppUpdateReady` subscription is set up once + // for the App's lifetime (an empty-deps effect), not inside the check's + // own resolving chain, so this plain `app.emit` lands normally -- unlike + // `skills.spec.ts`'s "installing a skill" test, there is no transient + // listener window to race here. + await app.emit('appUpdate:ready', { version: '1.5.0', path: '/tmp/SkillKeeper-1.5.0.pkg' }); + + // The offer noted from `app_update_check_now` (not the emitted event's + // own `version`, which the store deliberately ignores -- see + // `useAppUpdateSchedule.ts`) is what the ready dialog renders. + await expect(page.getByTestId('app-update-status')).toBeVisible(); + await expect(page.getByTestId('app-update-status')).toContainText('1.5.0'); + }); +}); diff --git a/e2e/desktop/tests/skills.spec.ts b/e2e/desktop/tests/skills.spec.ts new file mode 100644 index 00000000..7c08617b --- /dev/null +++ b/e2e/desktop/tests/skills.spec.ts @@ -0,0 +1,206 @@ +/** + * Flows 2, 3 and 11 (Skills pages): browsing the catalog, installing a skill + * through `SkillInstallModal`, and a skill whose dependency must be selected + * alongside it. + * + * Skills and MCP are not flat sidebar items (see `App.tsx`'s `NAV_ITEMS` + * comment): the Skills group header must be expanded first, then one of its + * two sub-items reaches a page -- `nav-skills-management` for the tree that + * merges the catalog with what is installed (flow 2), `nav-skills-components` + * for the repositories browse tree that feeds `SkillInstallModal` (flows 3, 11). + */ +import { test, expect } from '../harness/fixture'; +import type { Page } from '@playwright/test'; +import { + flatAndGrouped, + installable, + withDependency, + flatSkillLeafId, + groupedSkillGroupId, + nestedSkillGroupId, + installableSkillRowId, + installableSkillInstallCheckboxId, + needsDependencySkillRowId, + dependedOnSkillInstallCheckboxId, +} from '../fixtures/skills'; + +/** + * A `skill-group` row identified by its `data-group-id`, found via its + * nearest `[data-testid="skill-group"]` ancestor rather than + * `.filter({ has: ... })`: groups nest (a nested group's `
  • ` sits INSIDE + * its parent group's `
  • `), so `.filter({ has: ... })` also matches the + * parent -- it contains a descendant with that attribute too. Walking up from + * the identity attribute is unambiguous regardless of nesting depth. + */ +function groupRow(page: Page, groupId: string) { + return page.locator(`[data-group-id="${groupId}"]`).locator('xpath=ancestor::*[@data-testid="skill-group"][1]'); +} + +test.describe('browsing skills', () => { + test.use({ scenario: flatAndGrouped() }); + + test('the tree lists a flat skill, a group, and a nested group', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-group-skills').click(); + await page.getByTestId('nav-skills-management').click(); + await expect(page.getByTestId('skills-page')).toBeVisible(); + + // The repository root has no dedicated test id (no flow needs to select it + // by identity); it is the one branch labeled with the repository's own + // name, and expanding it is what reveals the flat skill and the group. + await page.getByText('skills-repo', { exact: true }).click(); + + const flatSkill = page + .getByTestId('skill-row') + .filter({ has: page.locator(`[data-skill-id="${flatSkillLeafId()}"]`) }); + await expect(flatSkill).toBeVisible(); + + const group = groupRow(page, groupedSkillGroupId()); + await expect(group).toBeVisible(); + + // Expanding the group reveals the nested group underneath it. + await group.click(); + const nestedGroup = groupRow(page, nestedSkillGroupId()); + await expect(nestedGroup).toBeVisible(); + }); +}); + +test.describe('installing a skill', () => { + test.use({ scenario: installable() }); + + test('checking a skill and applying it drives progress to completion', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-group-skills').click(); + await page.getByTestId('nav-skills-components').click(); + await expect(page.getByTestId('skills-page')).toBeVisible(); + + // Checking the skill row (a leaf click toggles its checkbox -- see + // `TreeView`'s `activateRow`) reveals the dock's "Install" button. + const row = page + .getByTestId('skill-row') + .filter({ has: page.locator(`[data-skill-id="${installableSkillRowId()}"]`) }); + await row.click(); + + await page.getByTestId('skill-install-open').click(); + await expect(page.getByTestId('skill-install-modal')).toBeVisible(); + + // Step 1: pick the project. `projects_detect_agents` (mocked by the + // scenario) auto-fills the agent selection, so there is nothing else to + // drive before "Next" is enabled. + await page.getByRole('combobox', { name: 'Project' }).click(); + await page.getByRole('option', { name: 'Demo' }).click(); + await page.getByRole('button', { name: 'Next' }).click(); + + // Step 2: the picked skill is already checked -- seeded from the page's + // hand pick (`seedInstallSelection`). + const checkbox = page + .getByTestId('skill-install-checkbox') + .filter({ has: page.locator(`[data-skill-id="${installableSkillInstallCheckboxId()}"]`) }); + await expect(checkbox).toHaveAttribute('aria-checked', 'true'); + + // Save is a double-confirm: the first click only arms it. + await page.getByTestId('skill-install-submit').click(); + + // The confirming click, the `skills:progress` emit, and the read of its + // effect all happen inside ONE `page.evaluate` rather than as separate + // `app`-fixture calls. This is not stylistic: `applySkills` (`app/store/ + // store.ts`) awaits the scripted `skills_apply` then `skills_list`, both + // of which the harness resolves within a couple of microtask ticks (see + // `harness/installHarness.ts`'s synchronous mock callback) -- an order of + // magnitude faster than a second, separate Playwright round trip can + // land. Confirmed empirically: a standalone `app.emit` call issued after + // an already-awaited confirming click always arrives once the apply has + // already resolved and the modal has already closed, so there is no + // listener left to receive it. Sequencing by microtask ticks + // (`await Promise.resolve()`) inside one script is deterministic JS + // ordering, not a timing guess -- unlike a real wait, it cannot be + // "almost long enough". The emitted event is dispatched through the exact + // same `plugin:event|emit` invoke call `harness/fixture.ts`'s `app.emit` + // uses (see that file's doc comment, which now records this limit), so + // this exercises the identical wire path. The payload matches the + // authoritative `ApplyProgress` shape (`contracts.ts`) in full, including + // `label` -- the modal renders it, so an approximated payload would be a + // silent product-code path this spec never actually exercises. + const progressValueNow = await page.evaluate(async () => { + const submit = document.querySelector('[data-testid="skill-install-submit"]') as HTMLButtonElement; + submit.click(); + // One tick for `applySkills`'s synchronous `set({ skillApply: ... })` + // (made before its first await) to reach a committed render. + await Promise.resolve(); + const internals = ( + window as unknown as { + __TAURI_INTERNALS__: { invoke: (cmd: string, args: unknown) => Promise }; + } + ).__TAURI_INTERNALS__; + await internals.invoke('plugin:event|emit', { + event: 'skills:progress', + payload: { done: 1, total: 1, label: 'Installing installable-skill' }, + }); + // One more tick for the emitted update to commit before the apply's own + // (already in-flight) resolution clears it again. + await Promise.resolve(); + return ( + document + .querySelector('[data-testid="skill-install-progress"] [role="progressbar"]') + ?.getAttribute('aria-valuenow') ?? null + ); + }); + + // Proves the emitted event was received: the progress section reflects + // the {done: 1, total: 1} the spec pushed, not whatever `skills_apply`'s + // own (much faster) resolution would have shown on its own. + expect(progressValueNow).toBe('100'); + + // The flow's actual result -- a successful apply -- is the modal closing + // (`SkillInstallModal.save()` calls `onClose()` only once every op's + // `applySkills` call resolves `ok: true`). Web-first: by now the apply has + // long since settled (a real round trip past the evaluate above), so this + // is asserting a stable end state, not racing a transient one. + await expect(page.getByTestId('skill-install-modal')).toBeHidden(); + }); +}); + +test.describe('a skill with dependencies', () => { + test.use({ scenario: withDependency() }); + + test('checking a skill also selects its dependency, marked required', async ({ app, page }) => { + await app.goto(); + await page.getByTestId('nav-group-skills').click(); + await page.getByTestId('nav-skills-components').click(); + await expect(page.getByTestId('skills-page')).toBeVisible(); + + const dependent = page + .getByTestId('skill-row') + .filter({ has: page.locator(`[data-skill-id="${needsDependencySkillRowId()}"]`) }); + await dependent.click(); + + await page.getByTestId('skill-install-open').click(); + await page.getByRole('combobox', { name: 'Project' }).click(); + await page.getByRole('option', { name: 'Demo' }).click(); + await page.getByRole('button', { name: 'Next' }).click(); + + const dependencyRow = page + .getByTestId('skill-install-checkbox') + .filter({ has: page.locator(`[data-skill-id="${dependedOnSkillInstallCheckboxId()}"]`) }); + await expect(dependencyRow).toHaveAttribute('aria-checked', 'true'); + await expect(dependencyRow.getByTestId('skill-install-required-badge')).toBeVisible(); + + // The design doc's rule for this flow (`installSelection.ts`'s own header + // comment) is that the apply plan is built from the DERIVED checked set, + // never the user's hand pick alone -- the mistake that would draw the + // dependency correctly and then install none of it. `needs-dependency` + // alone is the only hand pick (`skillKeys`); if the plan were built from + // that instead of `derived.shown`, `depended-on-skill` would be silently + // missing from `skills_apply`'s `install` list below. Submitting and + // reading the actually-recorded call is what catches that regression -- + // asserting the checkbox/badge above only proves the tree DRAWS the + // dependency, not that applying it INSTALLS the dependency too. + await page.getByTestId('skill-install-submit').click(); + await page.getByTestId('skill-install-submit').click(); + + const calls = await app.calls('skills_apply'); + expect(calls).toHaveLength(1); + const { install } = (calls[0] as { args: { install: readonly { name: string }[] } }).args; + expect(install.map((ref) => ref.name).sort()).toEqual(['depended-on-skill', 'needs-dependency']); + }); +}); diff --git a/e2e/desktop/tsconfig.json b/e2e/desktop/tsconfig.json new file mode 100644 index 00000000..5825b34d --- /dev/null +++ b/e2e/desktop/tsconfig.json @@ -0,0 +1,57 @@ +{ + // Like e2e/cli/tsconfig.json, this suite is deliberately its own TypeScript + // scope: it is not a pnpm workspace package (pnpm-workspace.yaml globs only + // packages/* and apps/*), so it does not extend tsconfig.base.json and is + // not part of `pnpm typecheck` (which is per-workspace-package). Playwright + // Test transpiles these files with esbuild at run time and does not type-check + // them; this file exists for editor support and for anyone who runs `tsc + // --noEmit -p e2e/desktop/tsconfig.json` by hand. + // + // Unlike e2e/cli (Node-only, CommonJS), this suite needs DOM types for the + // `page.addInitScript` payloads in harness/installHarness.ts, which run in + // the browser, and stays ESM (the repository root is "type": "module" and + // this directory has no package.json of its own to override that), which is + // what lets installHarness.ts use `import.meta.url`. + // + // "moduleResolution": "bundler" (Task 3), not "NodeNext": `scenario.ts` + // imports its field types straight from the ts-rs-generated sources under + // `apps/desktop/src/renderer/services/bridge/generated/**` rather than + // hand-rolling approximations, and those generated files -- authored for + // Vite's bundler resolution, same as the rest of `apps/desktop` -- import + // each other with no file extension. `tsc` type-checks a whole program, not + // file-by-file, so once anything in this directory reaches into that tree, + // EVERY file on the way is checked under this tsconfig's resolution mode; + // under "NodeNext" that turns every one of those extensionless sibling + // imports into an error in a generated file this suite does not own and + // must not edit. "bundler" accepts both a bare specifier and an explicit + // `.js` extension pointing at a `.ts` source, so it resolves that tree + // cleanly without changing how this directory's OWN files are written -- + // they keep the explicit `.js` extension throughout, which also still + // matches how Node actually resolves them at runtime (the "type": "module" + // requirement `.js`-on-`.ts` served under `NodeNext`), this option just + // stops REQUIRING it. Playwright's runtime never consults this setting at + // all (see the note above: esbuild transpiles per file and does not + // validate module resolution the way `tsc` does), so this is purely a + // type-checking/editor concern. + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/jest.config.cjs b/jest.config.cjs index d76edd6f..c25759c7 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,18 +1,21 @@ -// Jest drives the end-to-end suite in `e2e/` only. Unit tests stay on Vitest -// (`vitest.config.ts`, `pnpm test:cov`) -- the two runners cover different -// layers and never overlap: +// Jest drives the end-to-end suite in `e2e/cli/` only; `e2e/desktop/` is +// Playwright's (see e2e/desktop/playwright.config.ts), driving the renderer in +// Chromium against a scripted backend rather than a real Git working tree. +// Unit tests stay on Vitest (`vitest.config.ts`, `pnpm test:cov`) -- these +// three runners cover different layers and never overlap: // -// Vitest pure logic, in-process, coverage-gated at 90% -// Jest the built `skillkeeper` binary against a real Git working tree +// Vitest pure logic, in-process, coverage-gated at 90% +// Jest the built `skillkeeper` binary against a real Git working tree +// Playwright the built renderer bundle in Chromium, against a scripted backend // // This file is `.cjs` on purpose: the root package.json sets `"type": "module"`, // so a `.js` config would be ESM and Jest's config loader plus ts-jest's -// CommonJS transform are simplest kept out of ESM entirely. See e2e/tsconfig.json. +// CommonJS transform are simplest kept out of ESM entirely. See e2e/cli/tsconfig.json. /** @type {import('jest').Config} */ module.exports = { rootDir: __dirname, testEnvironment: 'node', - testMatch: ['/e2e/tests/**/*.spec.ts'], + testMatch: ['/e2e/cli/tests/**/*.spec.ts'], // Jest's module map otherwise walks the whole tree and trips over duplicate // package.json names: `.claude/worktrees/*` holds full checkouts of this same // repository (git worktrees for parallel branches), and the fixture submodule @@ -21,7 +24,7 @@ module.exports = { haste: { retainAllFiles: false }, watchPathIgnorePatterns: ['/.claude/', '/target/'], transform: { - '^.+\\.ts$': ['ts-jest', { tsconfig: '/e2e/tsconfig.json' }], + '^.+\\.ts$': ['ts-jest', { tsconfig: '/e2e/cli/tsconfig.json' }], }, // Each spec adds a repository, installs skills, and shells out to git; the // default 5s is far too tight for real process work on a cold cache. diff --git a/package.json b/package.json index 81f0caa9..14810780 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skillkeeper", - "version": "0.7.0", + "version": "0.7.1", "private": true, "type": "module", "description": "Install and manage skills and hooks for AI coding agents", @@ -27,7 +27,9 @@ "test": "vitest run", "test:watch": "vitest", "test:cov": "vitest run --coverage", - "test:e2e": "node scripts/e2e-prepare.mjs && jest --config jest.config.cjs", + "test:e2e": "pnpm test:e2e:cli && pnpm test:e2e:desktop", + "test:e2e:cli": "node scripts/e2e-prepare.mjs && jest --config jest.config.cjs", + "test:e2e:desktop": "playwright test --config e2e/desktop/playwright.config.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", @@ -41,6 +43,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", + "@playwright/test": "1.63.0", "@resvg/resvg-js": "^2.6.2", "@types/jest": "30.0.0", "@types/node": "24.13.3", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 3be2ef23..ca5debf8 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@skillkeeper/i18n", - "version": "0.7.0", + "version": "0.7.1", "private": true, "author": "Lorem Dev", "homepage": "https://lorem-dev.github.io/skillkeeper/", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 292c1444..3233a62e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@eslint/js': specifier: 10.0.1 version: 10.0.1(eslint@10.10.0(jiti@2.7.0)) + '@playwright/test': + specifier: 1.63.0 + version: 1.63.0 '@resvg/resvg-js': specifier: ^2.6.2 version: 2.6.2 @@ -1159,6 +1162,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@resvg/resvg-js-android-arm-eabi@2.6.2': resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} engines: {node: '>= 10'} @@ -3446,6 +3454,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + pngjs@6.0.0: resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} engines: {node: '>=12.13.0'} @@ -5272,6 +5290,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@resvg/resvg-js-android-arm-eabi@2.6.2': optional: true @@ -7853,6 +7875,12 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + pngjs@6.0.0: {} pngjs@7.0.0: {} diff --git a/scripts/e2e-prepare.mjs b/scripts/e2e-prepare.mjs index 23f147cf..6bced9f2 100644 --- a/scripts/e2e-prepare.mjs +++ b/scripts/e2e-prepare.mjs @@ -1,7 +1,7 @@ // Prepare the end-to-end run: make sure the fixture submodule is present and up // to date, then build the binary the specs drive. // -// node scripts/e2e-prepare.mjs (run for you by `pnpm test:e2e`) +// node scripts/e2e-prepare.mjs (run for you by `pnpm test:e2e:cli`) // // By default the fixture is force-pulled, not just initialized: the specs assert // against the current fixture, and a stale checkout fails for reasons that have