Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/a0-bootstrap-plugin
1 change: 1 addition & 0 deletions .claude/skills/a0-plugin-architecture
1 change: 1 addition & 0 deletions .claude/skills/a0-plugin-e2e-bdd
1 change: 1 addition & 0 deletions .claude/skills/a0-plugin-testkit
1 change: 1 addition & 0 deletions .claude/skills/author-plugin-from-template
1 change: 1 addition & 0 deletions .claude/skills/plugin-manifest-contract
1 change: 1 addition & 0 deletions .claude/skills/rotate-plugin-credentials
1 change: 1 addition & 0 deletions .claude/skills/troubleshoot-plugin-deployment
6 changes: 6 additions & 0 deletions .devkit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
plugin_dir: usr/plugins/diff_visualizer
display_name: DiffVisualizer
# If the plugin has agent-driven behaviour needing an e2e seam, enable it here:
# e2e_pod_env:
# A0_DIFF_VISUALIZER_TEST_PROBE: "1"
devkit_major: 2
20 changes: 20 additions & 0 deletions .gemini/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Gemini Code Assist configuration for the A0 plugin devkit (and, via the
# template, every plugin repo). https://developers.google.com/gemini-code-assist/docs/customize-repo-review
have_fun: false

code_review:
disable: false
comment_severity_threshold: LOW
max_review_comments: -1
pull_request_opened:
help: false
summary: true
code_review: true
include_drafts: true # the merge-guard drafts red PRs — still review them

ignore_patterns:
- "tests/_testkit/**" # the vendored devkit (reviewed in its own repo)
- "**/node_modules/**"
- "**/.features-gen/**" # generated by bddgen
- "**/test-results/**" # playwright artifacts
- "dist/**"
62 changes: 62 additions & 0 deletions .gemini/styleguide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# A0 plugin — Code Review Style Guide

Conventions and quality gates every PR to an Agent Zero plugin (or this devkit) must comply with.
Gemini Code Assist applies these during PR review — a **second, plain-language line of defense** in
front of the machine-checked CI gates (devkit `docs/BDD-GATES.md`, SPEC §5.14 / DEC-059–066). CI is the
hard gate; this review catches the same violations earlier and explains them. **Flag, don't rubber-stamp.**

The threat model is laziness — human *and* AI. If a diff looks like it's routing *around* a gate
(loosening a check, adding a bare skip, wrapping an assertion in try/catch), say so explicitly.

## Behaviour-first BDD — the `.feature` files (`tests/e2e/features/*.feature`)

- **Feature-purity (flag as HIGH):** a `Given/When/Then` step must read as plain behaviour. Flag any
selector (`.kebab-class`, `#id`), DOM/Playwright API (`querySelector`, `evaluate`, `dispatchEvent`),
store/internal name (`showModal`, `Alpine`, `getContext`, `callJsonApi`, `chat_create`), or framework
directive (`x-…`) appearing in a scenario. The "how" belongs in the step layer, never the feature.
- Scenarios assert **observable behaviour in domain language**, one behaviour per scenario, proper
Given/When/Then order. Behaviour is provoked by **real actions**, never by setting an internal flag.

## Honesty (flag as HIGH — these are the lies that pass CI)

- **No bare `@skip`.** Every skipped scenario must carry a `#` comment with the reason + a tracked
ref/issue. Flag any `@skip` without one.
- **No swallowed failures** in step files: an empty `catch {}`, or a `.catch(() => …)` / `try/catch`
that recovers instead of failing or re-asserting. Reserve try/catch for genuinely un-enableable env,
and even then assert *something*.
- **The four `docs/spec/` docs must exist** for a BDD plugin: `behaviour-spec.md`,
`implementation-plan.md`, `e2e.feature.md`, `e2e-steps-spec.md`. Flag a PR that adds
`tests/e2e/features/` without them.
- **Traceability:** every `BEH-n` in `behaviour-spec.md` must be covered in `e2e.feature.md` or listed
as a tracked skip. Flag orphans.
- **No fake green:** a scenario must genuinely assert. A suite that would pass with the plugin
uninstalled is fake-green (CI's seam-off red-proof catches it; flag designs that look hollow).

## The deterministic seam (`api/<plugin>_probe.py`)

- Must be **env-gated** (`A0_<PLUGIN>_TEST_PROBE`), off in production, and call the plugin's **real**
code path — never reimplement behaviour in the probe. Flag a seam that's ungated or that fakes logic.
- Seam calls belong in the **step layer**, never in a `.feature`.

## Fork-robustness (the tests run on the deployed fork, not stock A0)

- Flag a step that creates a **synthetic** chat context (`newContext()` with no backing chat) for the
plugin's polling path — the fork's chat-restore deselects it. Use a **real** chat (`chat_create`).
- Flag clicking a control without handling the no-LLM `composer-banner` overlay (hide it, then real
click; `dispatchEvent` does not trigger the framework handler).

## Plugin-specific vs common

- Plugins ship **only their own behaviour** features/steps. Flag any copy of the common lifecycle
(install/uninstall/boot/probe-enable) into a plugin — it ships from the devkit via `tests/_testkit`.

## Manifest & shipping

- `plugin.yaml` ↔ `<name>.meta.yaml` version match; declared `env[]` actually read in source; no UI
prompts for secrets (operator provides via chart). To ship verified, the gate meta needs
`source_repo` + `source_commit` with a green `plugin-e2e`.

## General

- No plaintext secrets. Idempotent lifecycle. Commit messages explain the *why*. Don't weaken a gate to
make a red build green — fix the underlying issue (see `docs/BDD-GATES.md` for each gate's fix).
15 changes: 14 additions & 1 deletion .github/workflows/plugin-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,28 @@
# (.devkit.yml `plugin_dir` → else canonical usr/plugins/<name>), so this
# template is identical across all repos.
#
# Needs the shared sync-App secrets to clone the private devkit submodule.
# Needs the shared sync-App secrets to clone the private devkit submodule, and
# (DEC-055) GHCR_PULL_TOKEN to pull the private fork image. `secrets: inherit`
# forwards all repo secrets, so new shared secrets need no caller change.
name: plugin-e2e

on:
pull_request:
branches: ["main", "master"]
workflow_dispatch:
inputs:
capture_all_traces:
description: "Capture a Playwright trace for EVERY scenario (not just failures)"
type: boolean
default: false

permissions:
contents: read
pull-requests: write # merge-guard drafts a PR whose plugin-e2e is red

jobs:
e2e:
uses: agent-zero-plugins/agent-zero-plugin-development-testkit/.github/workflows/plugin-e2e.yml@main
secrets: inherit
with:
capture-all-traces: ${{ inputs.capture_all_traces || false }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ credentials.json

# Skills submodule generated translation output
.skills/.generated/
node_modules
File renamed without changes.
4 changes: 2 additions & 2 deletions docs/spec/e2e.feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Background:
And the inline container must survive each close

Scenario: Close via the close button (E2E-7)
# → BEH-5a
# → BEH-5, BEH-5a
When I click .diff-overlay-close
Then the overlay count goes to 0
And the inline .diff-visualizer-container is still visible
Expand Down Expand Up @@ -177,7 +177,7 @@ Background:
And a pageerror listener is attached to prove no uncaught crash

Scenario: CDN unreachable → raw pre>code stays readable, marker cleared, no crash (E2E-12)
# → BEH-3a, EC-1
# → BEH-3, BEH-3a, EC-1
Given requests to cdn.jsdelivr.net are aborted before injection
When a valid diff block is injected
Then .diff-visualizer-container count is 0 (no swap)
Expand Down
21 changes: 21 additions & 0 deletions docs/spec/implementation-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# diff-visualizer — Implementation plan

Product internals (counterpart to `e2e-steps-spec.md`).

## Component
`extensions/webui/sidebar-end/diff-renderer.html` — a module that imports diff2html (UMD JS + CSS) from a
pinned CDN URL, installs a `MutationObserver` on the chat DOM, and replaces ```diff code blocks with a
rendered side-by-side visual diff.

## Internals
- **Detection:** `code.language-diff:not([data-diff-processed])`; observer re-scans on chat mutations
(debounce-coalesced).
- **Render:** builds a `.diff-visualizer-container` whose body is diff2html's `.d2h-wrapper`; marks the
source `data-diff-processed`. If diff2html doesn't yield a `d2h-wrapper` (CDN down / bad output), the
marker is REMOVED and the raw `pre>code` is left readable (no crash).
- **Toolbar:** `.diff-visualizer-toolbar` with a label, a maximize button (opens `.diff-visualizer-overlay`
re-rendering the diff), and a copy button (writes the RAW diff source; execCommand fallback).

## Dependencies
diff2html from `cdn.jsdelivr.net` at runtime (pinned URL; the plugin's design — the e2e needs the CDN).
No fork seam. The render trigger is a `code.language-diff` block in the DOM; the e2e injects one directly.
10 changes: 10 additions & 0 deletions tests/e2e/features/10-diff.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Feature: Rendering diffs
Scenario: A diff block in the chat is rendered as a visual diff # BEH-1, BEH-3
Given I am in a chat
When a unified diff is posted in the chat
Then it is rendered as a visual diff

Scenario: The rendered diff offers maximize and copy # BEH-2
Given I am in a chat
When a unified diff is posted in the chat
Then the rendered diff has a maximize and a copy control
42 changes: 42 additions & 0 deletions tests/e2e/steps/diff.steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Given, When, Then } from "../../_testkit/e2e/bdd/bdd-fixtures";
import { expect } from "@playwright/test";

const openChat = async (page: any) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(1500);
await page.evaluate(async () => {
const { callJsonApi } = await import("/js/api.js");
const r = await callJsonApi("/chat_create", {});
const id = (r && (r.ctxid || r.context)) || "";
if (id) (globalThis as any).setContext(id);
});
await page.waitForTimeout(9000); // let the sidebar-end extension's CDN import + observer install
};

// A complete git-style unified diff (diff2html needs the diff --git / index headers to render).
const DIFF = "diff --git a/greeting.txt b/greeting.txt\nindex 1111111..2222222 100644\n--- a/greeting.txt\n+++ b/greeting.txt\n@@ -1,2 +1,2 @@\n-hello world\n+hello there\n goodbye\n";
const postDiff = (page: any) =>
page.evaluate((c: string) => {
const wrap = document.createElement("div");
wrap.className = "markdown-block-wrap dv-fixture";
const cbw = document.createElement("div"); cbw.className = "code-block-wrapper";
const pre = document.createElement("pre");
const codeEl = document.createElement("code");
codeEl.className = "language-diff";
codeEl.textContent = c;
pre.appendChild(codeEl); cbw.appendChild(pre); wrap.appendChild(cbw);
(document.querySelector("#chat-history") || document.body).appendChild(wrap);
}, DIFF);

Given("I am in a chat", async ({ loggedInPage }: any) => { await openChat(loggedInPage); });
When("a unified diff is posted in the chat", async ({ loggedInPage }: any) => { await postDiff(loggedInPage); });

Then("it is rendered as a visual diff", async ({ loggedInPage }: any) => {
// The plugin replaces the raw code block with a diff2html render (.d2h-wrapper).
await expect(loggedInPage.locator(".d2h-wrapper").first()).toBeVisible({ timeout: 30000 });
});

Then("the rendered diff has a maximize and a copy control", async ({ loggedInPage }: any) => {
await expect(loggedInPage.locator(".diff-visualizer-toolbar").first()).toBeVisible({ timeout: 30000 });
expect(await loggedInPage.locator(".diff-copy-source").count()).toBeGreaterThan(0);
});
Loading