diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd20cef..7f9782c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,20 +13,29 @@ permissions: jobs: package: - name: Build plugin packages + name: Build and verify plugin packages runs-on: ubuntu-latest steps: - # The local Kandev SDK replacement expects this sibling layout. - - name: Checkout plugin + - name: Check out plugin uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: path: plugin - - name: Checkout Kandev SDK + - name: Read pinned Kandev source revision + id: sdk + working-directory: plugin + run: | + set -euo pipefail + sdk_ref="$(cat .kandev-sdk-ref)" + [[ "$sdk_ref" =~ ^[0-9a-f]{40}$ ]] + echo "ref=$sdk_ref" >> "$GITHUB_OUTPUT" + + - name: Check out Kandev SDK contract uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: kdlbs/kandev path: kandev + ref: ${{ steps.sdk.outputs.ref }} sparse-checkout: apps/backend - name: Set up Go @@ -39,6 +48,10 @@ jobs: working-directory: plugin run: make build - - name: Package all supported platforms + - name: Build and verify host-only package + working-directory: plugin + run: make package-host + + - name: Build and verify every declared platform working-directory: plugin run: make package diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b73f5a..9c1160c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,19 +16,29 @@ jobs: name: Format, vet, and test runs-on: ubuntu-latest steps: - # Keep the checkouts side by side so go.mod's local Kandev SDK - # replacement (../kandev/apps/backend) resolves on a fresh runner. - - name: Checkout plugin + - name: Check out plugin uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: path: plugin - - name: Checkout Kandev SDK + - name: Read pinned Kandev source revision + id: sdk + working-directory: plugin + run: | + set -euo pipefail + sdk_ref="$(cat .kandev-sdk-ref)" + [[ "$sdk_ref" =~ ^[0-9a-f]{40}$ ]] + echo "ref=$sdk_ref" >> "$GITHUB_OUTPUT" + + - name: Check out Kandev SDK contract uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: kdlbs/kandev path: kandev - sparse-checkout: apps/backend + ref: ${{ steps.sdk.outputs.ref }} + sparse-checkout: | + apps/backend + apps/packages/plugin-sdk - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 @@ -36,21 +46,26 @@ jobs: go-version-file: plugin/go.mod cache-dependency-path: plugin/go.sum + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + - name: Check module files are tidy working-directory: plugin run: | + set -euo pipefail go mod tidy git diff --exit-code -- go.mod go.sum - name: Check formatting working-directory: plugin - run: | - test -z "$(gofmt -l ./server)" || { echo "gofmt needed:"; gofmt -l ./server; exit 1; } + run: make check-format - name: Vet working-directory: plugin run: make vet - - name: Test + - name: Test backend, UI, and package verifiers working-directory: plugin run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15a1eb1..01c8c00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,7 @@ name: release -# Run manually from GitHub Actions on master to calculate a version, commit the -# release metadata and changelog, tag that commit, then build and publish it. -# Existing v* tag pushes skip preparation and only build and publish. +# Dispatch builds and validates the complete candidate before pushing release +# metadata or a tag. Pushed tags pass through the same checks before publish. on: push: tags: @@ -19,19 +18,25 @@ on: - minor - major dry_run: - description: "Calculate the release version without committing, tagging, or publishing" + description: "Build and validate the candidate without committing, tagging, or publishing" required: true type: boolean default: false +concurrency: + group: kandy-release + cancel-in-progress: false + permissions: - contents: write + contents: read jobs: prepare: - name: Prepare release commit + name: Validate and prepare release candidate if: ${{ github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest + permissions: + contents: write outputs: tag: ${{ steps.version.outputs.tag }} steps: @@ -48,17 +53,52 @@ jobs: - name: Check out master and release tags uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: + path: plugin + ref: master fetch-depth: 0 + - name: Read pinned Kandev source revision + id: sdk + working-directory: plugin + run: | + set -euo pipefail + sdk_ref="$(cat .kandev-sdk-ref)" + [[ "$sdk_ref" =~ ^[0-9a-f]{40}$ ]] + echo "ref=$sdk_ref" >> "$GITHUB_OUTPUT" + + - name: Check out Kandev SDK contract + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: kdlbs/kandev + path: kandev + ref: ${{ steps.sdk.outputs.ref }} + sparse-checkout: | + apps/backend + apps/packages/plugin-sdk + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: plugin/go.mod + cache-dependency-path: plugin/go.sum + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + - name: Compute next release version id: version + working-directory: plugin env: BUMP: ${{ inputs.bump }} run: | set -euo pipefail - CURRENT_VERSION="$(sed -nE 's/^version: "([0-9]+\.[0-9]+\.[0-9]+)(-[0-9A-Za-z.-]+)?"$/\1\2/p' manifest.yaml)" - MAKE_VERSION="$(sed -nE 's/^VERSION := ([0-9]+\.[0-9]+\.[0-9]+)(-[0-9A-Za-z.-]+)?$/\1\2/p' Makefile)" - test -n "$CURRENT_VERSION" || { echo "manifest.yaml has no SemVer version." >&2; exit 1; } + VERSION_PATTERN='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + CURRENT_VERSION="$(sed -nE 's/^version: "([^"]+)"$/\1/p' manifest.yaml)" + MAKE_VERSION="$(sed -nE 's/^VERSION := ([^[:space:]]+)$/\1/p' Makefile)" + printf '%s\n' "$CURRENT_VERSION" | grep -Eq "$VERSION_PATTERN" || { echo "manifest.yaml has an invalid version." >&2; exit 1; } + printf '%s\n' "$MAKE_VERSION" | grep -Eq "$VERSION_PATTERN" || { echo "Makefile has an invalid version." >&2; exit 1; } test "$CURRENT_VERSION" = "$MAKE_VERSION" || { echo "manifest.yaml ($CURRENT_VERSION) and Makefile ($MAKE_VERSION) disagree." >&2 exit 1 @@ -83,14 +123,12 @@ jobs: esac TAG="v$NEXT" - ! git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null || { + if git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then echo "Tag already exists locally: $TAG" >&2 exit 1 - } - ! git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null || { - echo "Tag already exists on origin: $TAG" >&2 - exit 1 - } + fi + REMOTE_TAG="$(git ls-remote --tags origin "refs/tags/$TAG")" + test -z "$REMOTE_TAG" || { echo "Tag already exists on origin: $TAG" >&2; exit 1; } echo "Current version: $CURRENT_VERSION" echo "Latest release tag: ${LATEST_TAG:-none}" @@ -99,12 +137,8 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "previous_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" - - name: Report dry run - if: ${{ inputs.dry_run }} - run: echo "Would commit, tag, and publish ${{ steps.version.outputs.tag }}." - - - name: Update release metadata and changelog - if: ${{ !inputs.dry_run }} + - name: Update candidate metadata and changelog + working-directory: plugin env: VERSION: ${{ steps.version.outputs.version }} PREVIOUS_TAG: ${{ steps.version.outputs.previous_tag }} @@ -114,7 +148,7 @@ jobs: sed -i -E "s/^VERSION := [0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/VERSION := $VERSION/" Makefile sed -i -E "s/kandev-plugin-kandy-[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?\.tar\.gz/kandev-plugin-kandy-$VERSION.tar.gz/" README.md - RANGE="${PREVIOUS_TAG:+$PREVIOUS_TAG..}HEAD" + if [ -n "$PREVIOUS_TAG" ]; then RANGE="$PREVIOUS_TAG..HEAD"; else RANGE=HEAD; fi CHANGES="$(git log --no-merges --format='- %s (%h)' "$RANGE")" test -n "$CHANGES" || CHANGES="- Initial release" { @@ -129,8 +163,33 @@ jobs: } > CHANGELOG.md.next mv CHANGELOG.md.next CHANGELOG.md - - name: Commit and tag release metadata + - name: Verify candidate identity and checks + working-directory: plugin + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + sh scripts/verify-release-version.sh "$TAG" + go mod tidy + git diff --exit-code -- go.mod go.sum + make check-format + make vet + make test + make package-host + make package + PACKAGE_FILE="$(make -s package-file)" + sh scripts/verify-package.sh "$PACKAGE_FILE" full + sh scripts/verify-release-version.sh "$TAG" "$PACKAGE_FILE" + + - name: Report dry run + if: ${{ inputs.dry_run }} + env: + TAG: ${{ steps.version.outputs.tag }} + run: echo "Candidate $TAG passed checks; no commit, tag, or release was created." + + - name: Commit and tag validated release candidate if: ${{ !inputs.dry_run }} + working-directory: plugin env: VERSION: ${{ steps.version.outputs.version }} TAG: ${{ steps.version.outputs.tag }} @@ -145,7 +204,7 @@ jobs: git push origin "$TAG" publish: - name: Build and publish release + name: Verify package and publish release needs: prepare if: >- ${{ @@ -154,19 +213,34 @@ jobs: (github.event_name == 'workflow_dispatch' && !inputs.dry_run && needs.prepare.result == 'success')) }} runs-on: ubuntu-latest + permissions: + contents: write steps: - - name: Checkout plugin + - name: Check out release commit uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: path: plugin ref: ${{ github.event_name == 'push' && github.ref || needs.prepare.outputs.tag }} + fetch-depth: 0 - - name: Checkout kandev SDK + - name: Read pinned Kandev source revision + id: sdk + working-directory: plugin + run: | + set -euo pipefail + sdk_ref="$(cat .kandev-sdk-ref)" + [[ "$sdk_ref" =~ ^[0-9a-f]{40}$ ]] + echo "ref=$sdk_ref" >> "$GITHUB_OUTPUT" + + - name: Check out Kandev SDK contract uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: kdlbs/kandev path: kandev - sparse-checkout: apps/backend + ref: ${{ steps.sdk.outputs.ref }} + sparse-checkout: | + apps/backend + apps/packages/plugin-sdk - name: Set up Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 @@ -174,28 +248,46 @@ jobs: go-version-file: plugin/go.mod cache-dependency-path: plugin/go.sum - - name: Verify + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Verify release identity, checks, and package + id: verify working-directory: plugin + env: + TAG: ${{ github.event_name == 'push' && github.ref_name || needs.prepare.outputs.tag }} run: | - test -z "$(gofmt -l ./server)" || { echo "gofmt needed:"; gofmt -l ./server; exit 1; } - go vet ./server/... + set -euo pipefail + sh scripts/verify-release-version.sh "$TAG" + go mod tidy + git diff --exit-code -- go.mod go.sum + make check-format + make vet make test - - - name: Package all platforms - working-directory: plugin - run: make package + make package-host + make package + PACKAGE_FILE="$(make -s package-file)" + sh scripts/verify-package.sh "$PACKAGE_FILE" full + sh scripts/verify-release-version.sh "$TAG" "$PACKAGE_FILE" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "package=$PACKAGE_FILE" >> "$GITHUB_OUTPUT" - name: Generate release checksums working-directory: plugin + env: + PACKAGE_FILE: ${{ steps.verify.outputs.package }} run: | - tar -xzf kandev-plugin-kandy-*.tar.gz checksums.txt - sha256sum kandev-plugin-kandy-*.tar.gz >> checksums.txt + set -euo pipefail + tar -xOzf "$PACKAGE_FILE" checksums.txt > checksums.txt + sha256sum "$PACKAGE_FILE" >> checksums.txt - name: Create GitHub release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: - tag_name: ${{ github.event_name == 'push' && github.ref_name || needs.prepare.outputs.tag }} + tag_name: ${{ steps.verify.outputs.tag }} files: | - plugin/kandev-plugin-kandy-*.tar.gz + plugin/${{ steps.verify.outputs.package }} plugin/checksums.txt generate_release_notes: true diff --git a/.kandev-sdk-ref b/.kandev-sdk-ref new file mode 100644 index 0000000..d1d6464 --- /dev/null +++ b/.kandev-sdk-ref @@ -0,0 +1 @@ +570600439036e81f8e9e1c63f15c4abce8a6c846 diff --git a/Makefile b/Makefile index fd181ef..2de0090 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ -.PHONY: build run test fmt vet package package-host clean +.PHONY: build run test fmt check-format vet package package-host verify-package smoke-package clean package-file BIN := bin/kandev-plugin-kandy VERSION := 0.14.1 STAGE := .build/stage PKG_OUT := kandev-plugin-kandy-$(VERSION).tar.gz +NODE ?= node build: mkdir -p bin @@ -13,11 +14,16 @@ run: build ./$(BIN) test: - go test ./server/... - node --test ui/bundle.test.js + go test ./server/... -count=1 + $(NODE) --test ui/bundle.test.js + sh scripts/test-verify-package.sh + sh scripts/test-verify-release-version.sh fmt: - gofmt -l . + gofmt -w ./server + +check-format: + @unformatted="$$(gofmt -l ./server)"; test -z "$$unformatted" || { echo "gofmt needed:"; printf '%s\n' "$$unformatted"; exit 1; } vet: go vet ./server/... @@ -25,28 +31,37 @@ vet: package: rm -rf $(STAGE) mkdir -p $(STAGE)/server $(STAGE)/ui - cp manifest.yaml $(STAGE)/manifest.yaml - cp README.md $(STAGE)/README.md + cp manifest.yaml README.md $(STAGE)/ cp ui/bundle.js $(STAGE)/ui/bundle.js - GOOS=linux GOARCH=amd64 go build -o $(STAGE)/server/plugin-linux-amd64 ./server - GOOS=linux GOARCH=arm64 go build -o $(STAGE)/server/plugin-linux-arm64 ./server - GOOS=darwin GOARCH=amd64 go build -o $(STAGE)/server/plugin-darwin-amd64 ./server - GOOS=darwin GOARCH=arm64 go build -o $(STAGE)/server/plugin-darwin-arm64 ./server + GOOS=linux GOARCH=amd64 go build -o $(STAGE)/server/plugin-linux-amd64 ./server + GOOS=linux GOARCH=arm64 go build -o $(STAGE)/server/plugin-linux-arm64 ./server + GOOS=darwin GOARCH=amd64 go build -o $(STAGE)/server/plugin-darwin-amd64 ./server + GOOS=darwin GOARCH=arm64 go build -o $(STAGE)/server/plugin-darwin-arm64 ./server GOOS=windows GOARCH=amd64 go build -o $(STAGE)/server/plugin-windows-amd64.exe ./server go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) rm -rf $(STAGE) + sh scripts/verify-package.sh $(PKG_OUT) full @echo "Wrote $(PKG_OUT)" package-host: rm -rf $(STAGE) mkdir -p $(STAGE)/server $(STAGE)/ui - cp manifest.yaml $(STAGE)/manifest.yaml - cp README.md $(STAGE)/README.md + cp manifest.yaml README.md $(STAGE)/ cp ui/bundle.js $(STAGE)/ui/bundle.js - go build -o $(STAGE)/server/plugin-$$(go env GOOS)-$$(go env GOARCH)$$(go env GOEXE) ./server - go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) -platform-only - rm -rf $(STAGE) + set -eu; host_platform="$$(go env GOOS)-$$(go env GOARCH)"; host_executable="server/plugin-$$(go env GOOS)-$$(go env GOARCH)$$(go env GOEXE)"; \ + go build -o "$(STAGE)/$$host_executable" ./server; \ + go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) -platform-only; \ + rm -rf $(STAGE); \ + sh scripts/verify-package.sh $(PKG_OUT) host "$$host_platform" @echo "Wrote $(PKG_OUT)" +verify-package: package + +smoke-package: package-host + $(NODE) scripts/smoke-package-ui.js $(PKG_OUT) + +package-file: + @printf '%s\n' "$(PKG_OUT)" + clean: rm -rf bin $(STAGE) kandev-plugin-kandy-*.tar.gz diff --git a/README.md b/README.md index a4c7555..839e57c 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,17 @@ Build a package (`make package-host` for your platform, `make package` for all platforms) and install the tarball via **Settings > Plugins > Install** or `POST /api/plugins/install`. +The manifest requires Kandev 0.83.0 or newer. It declares instance state, +encrypted host secrets, and event subscriptions. Kandy has no setup form and +does not ask for provider credentials. The host `Action` is optional at +runtime: newer hosts use it for the top-bar control, while older supported +hosts keep the native-button fallback. The source SDK pin below identifies the +build contract; it does not change the runtime minimum. + +The package includes binaries for Linux amd64/arm64, macOS amd64/arm64, and +Windows amd64. `make package-host` builds only for the current machine; +`make package` builds every declared platform. + ## How it works and what it reads Kandy is a visual, instance-wide companion. It does not call an agent, read a @@ -181,22 +192,99 @@ its grotto history. ## Development -Developed against a local checkout of the kandev monorepo (see the -`replace` directive in `go.mod`). +Kandy uses the Go SDK from a private sibling checkout of the Kandev monorepo. +`.kandev-sdk-ref` pins that source to +`570600439036e81f8e9e1c63f15c4abce8a6c846`, which contains the additive +`host.ui.Action` API. The Go `replace` path expects the plugin and Kandev +checkouts to be siblings: + +```sh +mkdir plugin-work && cd plugin-work +git clone https://github.com/kdlbs/kandev.git kandev +git clone https://github.com/kdlbs/kandev-plugin-kandy.git kandev-plugin-kandy +git -C kandev checkout "$(cat kandev-plugin-kandy/.kandev-sdk-ref)" +cd kandev-plugin-kandy + +go mod tidy +git diff --exit-code -- go.mod go.sum +make check-format +make vet +make test +make build +make package-host +make package +``` + +Use Go 1.26.0 and Node 24. The hand-written `ui/bundle.js` has no build step +or frontend dependencies. `make test` runs Go tests, UI interaction tests, +and positive and negative package/release verifier checks. `make smoke-package` +opens the packaged UI bundle in a disposable Chrome fixture. It checks the +Action and older-host paths on desktop and phone sizes. It exercises the +package contract but does not replace validation in a Kandev host. + +For host integration, `scripts/smoke-real-host.mjs` launches a disposable +Kandev backend, installs the built package through Settings, and tests the +actual chat top bar and phone control with fake agent data. The SDK-pin host +also has a phone plugin menu; v0.83.0 predates that menu, so its phone check +uses the legacy top-bar button. Both checks require a 44px touch target. The +script also checks hover/focus preview, Enter/Space and touch activation, +reduced motion, and one registration after disable/re-enable. It writes +`result.json`, measured control geometry, host logs, and screenshots to each +artifacts directory. Run it on Node 24 after building the two host revisions +and installing the locked web dependencies and Playwright Chromium: ```sh -make test # Go unit tests + dependency-free UI render/clipboard tests -make fmt vet # gofmt + go vet +mkdir -p "${XDG_CACHE_HOME:-$HOME/.cache}" +SMOKE_ROOT="$(mktemp -d "${XDG_CACHE_HOME:-$HOME/.cache}/kandy-host-smoke.XXXXXX")" +HOST_ROOT="$SMOKE_ROOT/hosts/kandev" +mkdir -p "$SMOKE_ROOT/hosts" +git clone https://github.com/kdlbs/kandev.git "$HOST_ROOT" +git -C "$HOST_ROOT" checkout --detach 570600439036e81f8e9e1c63f15c4abce8a6c846 +git -C "$HOST_ROOT" worktree add --detach "$SMOKE_ROOT/hosts/kandev-min" v0.83.0 + +for checkout in "$HOST_ROOT" "$SMOKE_ROOT/hosts/kandev-min"; do + (cd "$checkout/apps" && corepack enable && pnpm install --frozen-lockfile) + (cd "$checkout/apps/web" && pnpm exec playwright install chromium) + (cd "$checkout" && make build) +done + +mkdir -p "$SMOKE_ROOT/runtime" +VERSION="$(sed -n 's/^VERSION := //p' Makefile)" +PACKAGE="kandev-plugin-kandy-$VERSION.tar.gz" make package-host +TMPDIR="$SMOKE_ROOT/runtime" node scripts/smoke-real-host.mjs \ + "$HOST_ROOT" "$PACKAGE" action 18431 "$SMOKE_ROOT/action" +TMPDIR="$SMOKE_ROOT/runtime" node scripts/smoke-real-host.mjs \ + "$SMOKE_ROOT/hosts/kandev-min" "$PACKAGE" legacy 18432 "$SMOKE_ROOT/minimum" ``` +Use an available pair of ports. The smoke host uses isolated home and database +directories and deletes them when each run ends. Screenshots and JSON +measurements remain in the artifacts directories under `SMOKE_ROOT`. + ## Automation and releases Pull requests to `master` run separate verification and packaging workflows. -They check module tidiness, formatting, `go vet`, tests, a host build, and a -cross-platform package build. Pushing a `v*` tag verifies the plugin, builds -the all-platform package, and publishes a GitHub Release with the package and -its `checksums.txt` asset. +They pin the Kandev source checkout, check module tidiness and formatting, run +`go vet` and tests, and build and verify the host-only and all-platform +packages. Package verification checks the manifest identity and version, +declared binaries, exact file inventory, and every SHA-256 checksum. + +The `release` workflow runs from `master`. It validates the candidate, runs +backend and UI checks, and verifies the package before it pushes release +metadata or a tag. A pushed `v*` tag passes through the same checks before the +workflow publishes a GitHub Release with the package and `checksums.txt`. +Prerelease versions remain valid in both the manifest/Makefile match and tag +validation. + +## Troubleshooting + +- If `go test` cannot resolve `github.com/kandev/kandev`, check that the host + checkout is beside the plugin and is at `.kandev-sdk-ref`. +- If packaging reports a missing runtime executable, check that the build + platform is declared in `manifest.yaml`, then run `make clean` and retry. +- `make smoke-package` requires Chrome or Chromium. Set `CHROME_BIN` if the + browser executable is not named `google-chrome`. ## State @@ -204,3 +292,8 @@ Two aggregate JSON ledgers in kandev Host state (scope `instance`) participate in kandev backups, survive plugin upgrades, and are removed on uninstall. Uninstalling the plugin is, in the kindest possible terms, the end of that kandy's story and its Token Grotto. + +## License + +This repository has no license file or declared GitHub license. The manifest's +author field gives attribution; it does not grant reuse rights. diff --git a/scripts/smoke-package-ui.js b/scripts/smoke-package-ui.js new file mode 100644 index 0000000..86ffdcb --- /dev/null +++ b/scripts/smoke-package-ui.js @@ -0,0 +1,280 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const { spawn, spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const packageFile = process.argv[2]; +if (!packageFile) { + process.stderr.write("usage: node scripts/smoke-package-ui.js PACKAGE_FILE [CHROME]\n"); + process.exit(2); +} + +const chromePath = process.argv[3] || process.env.CHROME_BIN || "google-chrome"; +const fixturePath = path.join(__dirname, "ui-smoke.html"); +const temporaryRoot = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), "kandy-ui-smoke-")); +const extractedDir = path.join(temporaryRoot, "package"); +fs.mkdirSync(extractedDir); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: "utf8", ...options }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} failed (${result.status}): ${result.stderr || result.stdout}`); + } + return result.stdout; +} + +function createCdp(webSocketUrl) { + const socket = new WebSocket(webSocketUrl); + const pending = new Map(); + const listeners = new Map(); + let nextId = 1; + + const opened = new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", () => reject(new Error("Chrome DevTools WebSocket failed to open")), { once: true }); + }); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.id !== undefined) { + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.error) request.reject(new Error(message.error.message)); + else request.resolve(message.result); + return; + } + const eventListeners = listeners.get(message.method) || []; + for (const listener of eventListeners) { + if (!listener.sessionId || listener.sessionId === message.sessionId) listener.resolve(message.params); + } + }); + + return { + async open() { + await opened; + }, + send(method, params = {}, sessionId) { + const id = nextId++; + const request = new Promise((resolve, reject) => pending.set(id, { resolve, reject })); + socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + return request; + }, + waitFor(method, sessionId, timeoutMs = 15000) { + return new Promise((resolve, reject) => { + const entry = { sessionId, resolve, reject }; + const entries = listeners.get(method) || []; + entries.push(entry); + listeners.set(method, entries); + const timer = setTimeout(() => { + const index = entries.indexOf(entry); + if (index >= 0) entries.splice(index, 1); + reject(new Error(`Timed out waiting for Chrome DevTools event ${method}`)); + }, timeoutMs); + entry.resolve = (value) => { + clearTimeout(timer); + const index = entries.indexOf(entry); + if (index >= 0) entries.splice(index, 1); + resolve(value); + }; + }); + }, + close() { + if (socket.readyState === WebSocket.OPEN) socket.close(); + }, + }; +} + +async function evaluate(cdp, sessionId, expression) { + const response = await cdp.send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }, sessionId); + if (response.exceptionDetails) { + throw new Error(response.exceptionDetails.exception?.description || response.exceptionDetails.text); + } + return response.result.value; +} + +async function smokeScenario(mode, mobile, index) { + const profileDir = path.join(temporaryRoot, `chrome-profile-${index}`); + fs.mkdirSync(profileDir); + const args = [ + "--headless=new", + "--no-sandbox", + "--disable-gpu", + "--disable-dev-shm-usage", + "--no-first-run", + "--no-default-browser-check", + "--remote-allow-origins=*", + `--user-data-dir=${profileDir}`, + "--remote-debugging-port=0", + "about:blank", + ]; + const chrome = spawn(chromePath, args, { stdio: ["ignore", "ignore", "pipe"] }); + let chromeStderr = ""; + chrome.stderr.setEncoding("utf8"); + chrome.stderr.on("data", (chunk) => { + chromeStderr = (chromeStderr + chunk).slice(-4000); + }); + let cdp; + + try { + const activePortFile = path.join(profileDir, "DevToolsActivePort"); + const deadline = Date.now() + 10000; + while (!fs.existsSync(activePortFile) && Date.now() < deadline) { + if (chrome.exitCode !== null) throw new Error(`Chrome exited during startup: ${chromeStderr}`); + await sleep(50); + } + assert.ok(fs.existsSync(activePortFile), `Chrome DevTools port starts: ${chromeStderr}`); + + const [port] = fs.readFileSync(activePortFile, "utf8").trim().split("\n"); + const version = await fetch(`http://127.0.0.1:${port}/json/version`).then((response) => response.json()); + cdp = createCdp(version.webSocketDebuggerUrl); + await cdp.open(); + + const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); + const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true }); + const width = mobile ? 390 : 1280; + const height = mobile ? 844 : 900; + await cdp.send("Page.enable", {}, sessionId); + await cdp.send("Runtime.enable", {}, sessionId); + await cdp.send("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: 1, + mobile, + screenWidth: width, + screenHeight: height, + }, sessionId); + await cdp.send("Emulation.setTouchEmulationEnabled", mobile ? { enabled: true, maxTouchPoints: 1 } : { enabled: false }, sessionId); + await cdp.send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "reduce" }], + }, sessionId); + + const smokePage = path.join(temporaryRoot, `ui-smoke-${index}.html`); + const fixture = fs.readFileSync(fixturePath, "utf8"); + const bundleUrl = pathToFileURL(path.join(extractedDir, "ui", "bundle.js")).href; + const localFixture = fixture.replace('', ``); + assert.notEqual(localFixture, fixture, "fixture bundle URL is replaced with the extracted package asset"); + fs.writeFileSync(smokePage, localFixture); + + const loaded = cdp.waitFor("Page.loadEventFired", sessionId); + await cdp.send("Page.navigate", { url: pathToFileURL(smokePage).href + `?mode=${mode}` }, sessionId); + await loaded; + const ready = await evaluate(cdp, sessionId, "document.body.dataset.smokeReady"); + assert.equal(ready, "true", `fixture initializes packaged UI: ${await evaluate(cdp, sessionId, "document.body.dataset.smokeError || ''")}`); + + if (mobile) { + await evaluate(cdp, sessionId, "window.smoke.button.focus()"); + const point = await evaluate(cdp, sessionId, "(() => { const r = window.smoke.button.getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; })()"); + await cdp.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 1, radiusY: 1, force: 1 }], + }, sessionId); + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }, sessionId); + } else { + const point = await evaluate(cdp, sessionId, "(() => { const r = window.smoke.button.getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; })()"); + await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y }, sessionId); + await evaluate(cdp, sessionId, "window.smoke.button.focus()"); + const keyboardOpens = []; + await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: "\r", unmodifiedText: "\r" }, sessionId); + await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sessionId); + keyboardOpens.push(await evaluate(cdp, sessionId, "window.smoke.counters.opens")); + await cdp.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: " ", code: "Space", windowsVirtualKeyCode: 32, nativeVirtualKeyCode: 32 }, sessionId); + await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: " ", code: "Space", windowsVirtualKeyCode: 32, nativeVirtualKeyCode: 32 }, sessionId); + keyboardOpens.push(await evaluate(cdp, sessionId, "window.smoke.counters.opens")); + await evaluate(cdp, sessionId, `window.smoke.keyboardOpens = ${JSON.stringify(keyboardOpens)}`); + } + + return await evaluate(cdp, sessionId, "({ initial: window.smoke.initialMetrics, after: window.smoke.metrics(), mobile: window.smoke.mobile, nativeButton: window.smoke.nativeButton, keyboardOpens: window.smoke.keyboardOpens, keyEvents: window.smoke.keyEvents })"); + } finally { + if (cdp) cdp.close(); + if (chrome.exitCode === null) { + chrome.kill("SIGTERM"); + await Promise.race([new Promise((resolve) => chrome.once("exit", resolve)), sleep(2000)]); + if (chrome.exitCode === null) chrome.kill("SIGKILL"); + } + } +} + +async function main() { + const archive = path.resolve(packageFile); + run("tar", ["-xzf", archive, "-C", extractedDir]); + assert.ok(fs.existsSync(path.join(extractedDir, "ui", "bundle.js")), "package contains ui/bundle.js"); + const checks = []; + let index = 0; + for (const mode of ["action", "legacy"]) { + for (const mobile of [false, true]) { + const result = await smokeScenario(mode, mobile, index++); + const initial = result.initial; + const after = result.after; + const expectedWidth = mobile ? 44 : 28; + const expectedArt = mode === "action" ? 16 : 22; + + assert.equal(result.nativeButton, true, `${mode} path renders a native button`); + assert.equal(result.mobile, mobile); + assert.equal(initial.id, "kandev-kandy-widget"); + assert.match(initial.label, /^Kandy: level 12 Drowsy Sporeling, gloomy$/); + assert.equal(initial.outerWidth, expectedWidth, `${mode} ${mobile ? "phone" : "desktop"} width`); + assert.equal(initial.outerHeight, expectedWidth, `${mode} ${mobile ? "phone" : "desktop"} height`); + assert.equal(initial.glyphWidth, mode === "action" ? 16 : expectedArt); + assert.equal(initial.glyphHeight, mode === "action" ? 16 : expectedArt); + assert.equal(initial.artWidth, expectedArt); + assert.equal(initial.artHeight, expectedArt); + assert.equal(initial.pointerCoarse, mobile, `${mode} pointer media query matches desktop/phone mode`); + assert.deepEqual(initial.registeredSlots, ["chat-top-bar", "chat-top-bar"]); + assert.equal(initial.registrationCountBeforeDisable, 1); + assert.equal(initial.registrationCountAfterReenable, 2); + assert.equal(initial.styleRemoved, true); + assert.equal(initial.styleRestored, true); + assert.ok(initial.wsHandlerCount > 0); + assert.equal(initial.reducedMotion, true); + assert.equal(initial.artAnimation, "none"); + + if (mobile) { + assert.equal(after.opens, 1, `${mode} touch path opens the Kandy dialog`); + assert.ok(after.loads >= 1 && after.loads <= 2, `${mode} focus/touch loads Kandy on phone`); + } else { + assert.equal(after.opens, 2, `${mode} Enter and Space activation open the Kandy dialog`); + assert.deepEqual(result.keyboardOpens, [1, 2], `${mode} Enter and Space each activate the button`); + assert.deepEqual(result.keyEvents, ["down:Enter", "up:Enter", "down: ", "up: "]); + assert.equal(after.loads, 2, `${mode} hover and focus both load Kandy`); + } + assert.equal(after.activeId, "kandev-kandy-widget"); + assert.equal(after.previewVisible, true); + assert.equal(after.dialogVisible, true); + + checks.push({ + hostPath: mode === "action" ? "host Action" : "older-host fallback", + layout: mobile ? "phone/touch" : "desktop/keyboard", + target: `${initial.outerWidth}x${initial.outerHeight}px`, + creature: `${initial.artWidth}x${initial.artHeight}px`, + coarsePointer: initial.pointerCoarse, + reducedMotion: initial.artAnimation, + loads: after.loads, + opens: after.opens, + }); + } + } + + process.stdout.write( + JSON.stringify({ package: path.basename(archive), browser: chromePath, checks }, null, 2) + "\n", + ); +} + +main() + .catch((error) => { + process.stderr.write(`${error.stack || error}\n`); + process.exitCode = 1; + }) + .finally(() => { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + }); diff --git a/scripts/smoke-real-host.mjs b/scripts/smoke-real-host.mjs new file mode 100644 index 0000000..5f75eff --- /dev/null +++ b/scripts/smoke-real-host.mjs @@ -0,0 +1,495 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { spawn, execFileSync } from "node:child_process"; +import { createWriteStream, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const PLUGIN_ID = "kandev-plugin-kandy"; +const [hostRootArg, packageArg, hostVariant, portArg, artifactsArg] = process.argv.slice(2); +if (!hostRootArg || !packageArg || !["action", "legacy"].includes(hostVariant) || !portArg) { + throw new Error( + "usage: node scripts/smoke-real-host.mjs [artifacts-dir]", + ); +} + +const hostRoot = path.resolve(hostRootArg); +const hostRevision = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: hostRoot, + encoding: "utf8", +}).trim(); +const packagePath = path.resolve(packageArg); +const port = Number(portArg); +assert.ok(Number.isInteger(port) && port > 1024 && port < 65536, "port must be valid"); +assert.ok(existsSync(path.join(hostRoot, "apps/web/package.json")), "host checkout is missing apps/web"); +assert.ok(existsSync(path.join(hostRoot, "apps/backend/bin/kandev")), "build the host backend first"); +assert.ok(existsSync(path.join(hostRoot, "apps/web/dist/index.html")), "build the host web app first"); +assert.ok(existsSync(packagePath), `plugin package does not exist: ${packagePath}`); + +const outputDir = path.resolve( + artifactsArg || path.join(process.cwd(), "real-host-smoke", hostVariant), +); +mkdirSync(outputDir, { recursive: true }); +const tempDir = mkdtempSync(path.join(os.tmpdir(), `kandy-${hostVariant}-`)); +const homeDir = path.join(tempDir, "home"); +const repositoryDir = path.join(tempDir, "repos", "smoke-repo"); +const serverLogPath = path.join(outputDir, "host.log"); +mkdirSync(repositoryDir, { recursive: true }); +mkdirSync(path.join(homeDir, "tmp"), { recursive: true }); + +const requireFromHost = createRequire(path.join(hostRoot, "apps/web/package.json")); +const { chromium, expect } = requireFromHost("@playwright/test"); +const baseUrl = `http://127.0.0.1:${port}`; +const serverLog = createWriteStream(serverLogPath, { flags: "w" }); +let server; +let browser; + +function runGit(args) { + execFileSync("git", args, { + cwd: repositoryDir, + env: { + ...process.env, + GIT_AUTHOR_NAME: "Kandy Smoke", + GIT_AUTHOR_EMAIL: "kandy-smoke@example.invalid", + GIT_COMMITTER_NAME: "Kandy Smoke", + GIT_COMMITTER_EMAIL: "kandy-smoke@example.invalid", + }, + stdio: "ignore", + }); +} + +async function api(method, route, body) { + const response = await fetch(new URL(route, baseUrl), { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + if (!response.ok) { + throw new Error(`${method} ${route} returned ${response.status}: ${JSON.stringify(data)}`); + } + return data; +} + +async function startHost() { + const backendBin = path.join(hostRoot, "apps/backend/bin/kandev"); + const agentctlPortBase = 30001 + (port % 30) * 1000; + const env = { + ...process.env, + HOME: homeDir, + KANDEV_HOME_DIR: homeDir, + KANDEV_DATABASE_PATH: path.join(homeDir, "kandev.db"), + KANDEV_SERVER_HOST: "127.0.0.1", + KANDEV_SERVER_PORT: String(port), + KANDEV_WEB_DIST_DIR: path.join(hostRoot, "apps/web/dist"), + KANDEV_E2E_MOCK: "true", + KANDEV_E2E_SYSTEM_TEMP_ROOT: path.join(homeDir, "tmp"), + KANDEV_DOCKER_ENABLED: "false", + KANDEV_WORKTREE_ENABLED: "true", + KANDEV_WORKTREE_BASEPATH: path.join(homeDir, "worktrees"), + KANDEV_REPOCLONE_BASEPATH: path.join(homeDir, "repoclones"), + KANDEV_LOG_LEVEL: "info", + AGENTCTL_INSTANCE_PORT_BASE: String(agentctlPortBase), + AGENTCTL_INSTANCE_PORT_MAX: String(agentctlPortBase + 199), + GIT_AUTHOR_NAME: "Kandy Smoke", + GIT_AUTHOR_EMAIL: "kandy-smoke@example.invalid", + GIT_COMMITTER_NAME: "Kandy Smoke", + GIT_COMMITTER_EMAIL: "kandy-smoke@example.invalid", + PATH: [path.join(hostRoot, "apps/backend/bin"), process.env.PATH || ""].join(path.delimiter), + }; + server = spawn(backendBin, ["__backend"], { + cwd: hostRoot, + env, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + server.stdout.pipe(serverLog); + server.stderr.pipe(serverLog); + await expect + .poll(async () => { + if (server.exitCode !== null) throw new Error(`Kandev exited with ${server.exitCode}`); + try { + return (await fetch(`${baseUrl}/ready`)).status; + } catch { + return 0; + } + }, { timeout: 60_000, intervals: [250, 500, 1000] }) + .toBe(200); +} + +async function stopHost() { + if (!server || server.exitCode !== null) return; + try { + process.kill(-server.pid, "SIGTERM"); + } catch { + return; + } + await Promise.race([ + new Promise((resolve) => server.once("exit", resolve)), + delay(10_000), + ]); + if (server.exitCode === null) { + try { + process.kill(-server.pid, "SIGKILL"); + } catch { + // The process group exited between the check and signal. + } + } +} + +async function seedTask() { + runGit(["init", "-b", "main"]); + writeFileSync(path.join(repositoryDir, "README.md"), "Disposable Kandy host smoke repository.\n"); + runGit(["add", "README.md"]); + runGit(["commit", "-m", "seed smoke repository"]); + + const workspace = await api("POST", "/api/v1/workspaces", { name: "Kandy smoke workspace" }); + const workflow = await api("POST", "/api/v1/workflows", { + workspace_id: workspace.id, + name: "Kandy smoke workflow", + workflow_template_id: "simple", + }); + const stepResult = await api("GET", `/api/v1/workflows/${workflow.id}/workflow/steps`); + const step = stepResult.steps + .slice() + .sort((left, right) => left.position - right.position) + .find((item) => item.is_start_step) || stepResult.steps[0]; + assert.ok(step, "simple workflow has a start step"); + + const agentResult = await api("GET", "/api/v1/agents"); + const profileId = agentResult.agents + .filter((agent) => agent.id !== "dynamic") + .flatMap((agent) => agent.profiles || []) + .find((profile) => profile.id)?.id; + assert.ok(profileId, "the disposable host exposes an E2E fake agent profile"); + + const repository = await api("POST", `/api/v1/workspaces/${workspace.id}/repositories`, { + name: "Kandy smoke repository", + source_type: "local", + local_path: repositoryDir, + default_branch: "main", + }); + const task = await api("POST", "/api/v1/tasks", { + workspace_id: workspace.id, + title: "Kandy action host smoke", + description: "/e2e:simple-message", + start_agent: true, + agent_profile_id: profileId, + workflow_id: workflow.id, + workflow_step_id: step.id, + repositories: [{ repository_id: repository.id }], + }); + assert.ok(task.id, "task API returned an id"); + assert.ok(task.session_id, "task API created the disposable fake-agent session"); + return task; +} + +async function installPlugin(page) { + await page.goto(`${baseUrl}/settings/plugins`); + await page.getByTestId("install-plugin-trigger").click(); + await expect(page.getByTestId("install-plugin-dialog")).toBeVisible(); + await page.getByTestId("install-plugin-tab-upload").click(); + await page.getByTestId("install-plugin-file-input").setInputFiles(packagePath); + await page.getByTestId("install-plugin-upload-submit").click(); + const row = page.getByTestId(`plugin-row-${PLUGIN_ID}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText("Active", { exact: true })).toBeVisible({ timeout: 30_000 }); +} + +async function actionLocator(page) { + const action = page.locator("#kandev-kandy-widget:visible"); + await expect(action).toHaveCount(1, { timeout: 20_000 }); + return action; +} + +async function inspectAction(page, expectedSurface) { + const action = await actionLocator(page); + const details = await action.evaluate((element) => { + const rect = (node) => { + if (!node) return null; + const box = node.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }; + return { + tag: element.tagName.toLowerCase(), + classes: element.className, + surface: element.getAttribute("data-surface"), + presentation: element.getAttribute("data-presentation"), + accessibleName: element.getAttribute("aria-label"), + action: rect(element), + glyph: rect(element.querySelector('[data-slot="surface-action-icon"]')), + art: rect(element.querySelector(".kandev-kandy-widget-art")), + svg: rect(element.querySelector(".kandev-kandy-widget-art svg")), + }; + }); + writeFileSync(path.join(outputDir, "action-geometry.json"), `${JSON.stringify(details, null, 2)}\n`); + assert.match(details.accessibleName || "", /^Kandy: level \d+ /, "Kandy copy is accessible"); + assert.ok(details.svg && details.svg.width > 0 && details.svg.height > 0, "creature SVG is visible"); + + if (expectedSurface === "action") { + assert.equal(details.surface, "topbar", "host Action owns the topbar surface"); + assert.equal(details.presentation, "desktop"); + assert.ok(details.glyph, "the host supplies its glyph box"); + assert.ok(Math.abs(details.glyph.width - 16) < 0.6, "host glyph box is 16px wide"); + assert.ok(Math.abs(details.glyph.height - 16) < 0.6, "host glyph box is 16px tall"); + assert.ok( + Math.abs(details.svg.width - details.glyph.width) < 0.6, + `SVG fits the host glyph width: ${JSON.stringify(details)}`, + ); + assert.ok( + Math.abs(details.svg.height - details.glyph.height) < 0.6, + `SVG fits the host glyph height: ${JSON.stringify(details)}`, + ); + assert.ok(details.art.x >= details.glyph.x - 0.6 && details.art.y >= details.glyph.y - 0.6); + assert.ok(details.art.x + details.art.width <= details.glyph.x + details.glyph.width + 0.6); + assert.ok(details.art.y + details.art.height <= details.glyph.y + details.glyph.height + 0.6); + } else { + assert.equal(details.tag, "button", "minimum host uses the legacy native button"); + assert.match(details.classes, /kandev-kandy-widget-legacy/); + assert.equal(details.glyph, null, "legacy host does not invent a host glyph box"); + } + return { action, details }; +} + +const fakeKandy = { + level: 12, + stage: 2, + archetype: 3, + family: 4, + biome: 2, + lineage_seed: 1, + appearance_seed: 1234, + stage_name: "Drowsy Sporeling", + progress_pct: 64.5, + mood: "gloomy", + temperament_band: "wary", + scarred: true, + flavor: "A disposable smoke-test creature.", + award_seq: 8, +}; + +async function runDesktop(page, task) { + let allowFakeData = false; + let actualWebhookResponses = 0; + let actualKandy = null; + await page.route(`**/api/plugins/${PLUGIN_ID}/webhooks/kandy*`, async (route) => { + if (allowFakeData) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(fakeKandy) }); + return; + } + await route.continue(); + }); + page.on("response", async (response) => { + if ( + response.url().includes(`/api/plugins/${PLUGIN_ID}/webhooks/kandy`) && + response.status() === 200 + ) { + actualWebhookResponses += 1; + actualKandy = await response.json().catch(() => null); + } + }); + + await page.goto(`${baseUrl}/t/${task.id}`); + const { action } = await inspectAction(page, hostVariant); + await expect.poll(() => actualWebhookResponses, { timeout: 20_000 }).toBeGreaterThan(0); + await expect.poll(() => actualKandy, { timeout: 10_000 }).toBeTruthy(); + const expectedActualPrefix = + `Kandy: level ${actualKandy.level} ${actualKandy.stage_name}, ` + (actualKandy.mood || "content"); + await expect(action).toHaveAccessibleName(new RegExp(`^${expectedActualPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + + allowFakeData = true; + await action.hover(); + await expect(page.locator(".kandev-kandy-tooltip")).toBeVisible({ timeout: 15_000 }); + await expect(action).toHaveAccessibleName(/^Kandy: level 12 Drowsy Sporeling, gloomy(?:, sleeping)?$/); + const preview = page.locator(".kandev-kandy-tooltip"); + await preview.screenshot({ path: path.join(outputDir, "desktop-hover-preview.png") }); + + await action.click(); + const dialog = page.locator("#kandev-kandy-dialog"); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + + await action.focus(); + await expect(preview).toBeVisible({ timeout: 10_000 }); + await page.keyboard.press("Enter"); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + + await action.focus(); + await page.keyboard.press("Space"); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await action.hover(); + await expect(preview.locator(".kandev-kandy-wiggle").first()).toHaveCount(1); + const animationName = await preview + .locator(".kandev-kandy-wiggle") + .first() + .evaluate((element) => getComputedStyle(element).animationName); + assert.equal(animationName, "none", "reduced motion disables creature animation"); + await page.screenshot({ path: path.join(outputDir, "desktop-reduced-motion.png"), fullPage: true }); + + await page.goto(`${baseUrl}/settings/plugins`); + const row = page.getByTestId(`plugin-row-${PLUGIN_ID}`); + await expect(row.getByRole("button", { name: "Disable" })).toBeVisible(); + await row.getByRole("button", { name: "Disable" }).click(); + await expect(row.getByText("Disabled", { exact: true })).toBeVisible({ timeout: 20_000 }); + await page.goto(`${baseUrl}/t/${task.id}`); + await expect(page.locator("#kandev-kandy-widget:visible")).toHaveCount(0); + + await page.goto(`${baseUrl}/settings/plugins`); + const disabledRow = page.getByTestId(`plugin-row-${PLUGIN_ID}`); + await disabledRow.getByRole("button", { name: "Enable" }).click(); + await expect(disabledRow.getByText("Active", { exact: true })).toBeVisible({ timeout: 20_000 }); + await page.goto(`${baseUrl}/t/${task.id}`); + const reenabled = page.locator("#kandev-kandy-widget"); + await expect(reenabled).toHaveCount(1, { timeout: 20_000 }); + await expect(reenabled).toBeVisible(); + return { actualWebhookResponses, reenabledCount: await reenabled.count() }; +} + +async function runPhone(browser, task) { + const context = await browser.newContext({ + viewport: { width: 393, height: 852 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 2, + }); + const page = await context.newPage(); + let allowFakeData = false; + await page.route(`**/api/plugins/${PLUGIN_ID}/webhooks/kandy*`, async (route) => { + if (allowFakeData) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(fakeKandy) }); + return; + } + await route.continue(); + }); + + try { + await page.goto(`${baseUrl}/t/${task.id}`); + let section = null; + let action; + if (hostVariant === "action") { + const menuTrigger = page.getByTestId("app-nav-trigger"); + await expect(menuTrigger).toBeVisible({ timeout: 20_000 }); + await menuTrigger.tap(); + section = page.getByTestId("mobile-plugin-nav-section"); + await expect(section).toBeVisible(); + action = section.locator("#kandev-kandy-widget"); + } else { + // v0.83.0 has no mobile plugin-nav section. Its chat top-bar slot still + // renders the legacy button, so exercise that real surface directly. + action = page.locator("#kandev-kandy-widget:visible"); + } + await expect(action).toHaveCount(1, { timeout: 20_000 }); + await expect(action).toBeVisible(); + const details = await action.evaluate((element) => { + const rect = (node) => { + if (!node) return null; + const box = node.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }; + return { + accessibleName: element.getAttribute("aria-label"), + presentation: element.getAttribute("data-presentation"), + surface: element.getAttribute("data-surface"), + action: rect(element), + art: rect(element.querySelector(".kandev-kandy-widget-art")), + glyph: element.querySelector('[data-slot="surface-action-icon"]') + ? rect(element.querySelector('[data-slot="surface-action-icon"]')) + : null, + svg: rect(element.querySelector("svg")), + section: rect(element.closest('[data-testid="mobile-plugin-nav-section"]')), + viewport: { width: window.innerWidth, height: window.innerHeight }, + }; + }); + assert.match(details.accessibleName || "", /^Kandy: level \d+ /); + assert.ok(details.action.width >= 44 && details.action.height >= 44, "phone target is at least 44px"); + assert.ok(details.action.x >= -0.5 && details.action.y >= -0.5); + assert.ok(details.action.x + details.action.width <= details.viewport.width + 0.5); + assert.ok(details.svg.width === 22 || Math.abs(details.svg.width - 16) < 0.6); + if (hostVariant === "action") { + assert.equal(details.presentation, "mobile"); + assert.ok(details.section, "new-host Action is inside the mobile plugin section"); + assert.ok(details.glyph && Math.abs(details.glyph.width - 16) < 0.6); + assert.ok(details.action.x >= details.section.x - 0.5); + assert.ok(details.action.x + details.action.width <= details.section.x + details.section.width + 0.5); + assert.ok(details.art.x >= details.glyph.x - 0.6 && details.art.y >= details.glyph.y - 0.6); + assert.ok(details.art.x + details.art.width <= details.glyph.x + details.glyph.width + 0.6); + assert.ok(details.art.y + details.art.height <= details.glyph.y + details.glyph.height + 0.6); + } else { + assert.equal(details.presentation, null, "old host uses the legacy top-bar slot"); + assert.match(await action.evaluate((element) => element.tagName.toLowerCase()), /^button$/); + assert.ok(details.art.x >= details.action.x - 0.6 && details.art.y >= details.action.y - 0.6); + assert.ok(details.art.x + details.art.width <= details.action.x + details.action.width + 0.6); + assert.ok(details.art.y + details.art.height <= details.action.y + details.action.height + 0.6); + } + allowFakeData = true; + await page.screenshot({ path: path.join(outputDir, "phone-action-viewport.png") }); + await page.screenshot({ path: path.join(outputDir, "phone-action-surface.png"), fullPage: true }); + await action.tap(); + await expect(page.locator("#kandev-kandy-dialog")).toBeVisible(); + return { details }; + } finally { + await context.close(); + } +} + +async function main() { + let task; + try { + await startHost(); + task = await seedTask(); + browser = await chromium.launch({ headless: true }); + const desktopContext = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, + timezoneId: "UTC", + }); + const desktopPage = await desktopContext.newPage(); + await desktopPage.clock.install({ time: new Date("2026-09-30T13:00:00Z") }); + await installPlugin(desktopPage); + await desktopPage.goto(`${baseUrl}/t/${task.id}`); + await expect(desktopPage.locator("#kandev-kandy-widget:visible")).toHaveCount(1, { + timeout: 20_000, + }); + const desktop = await runDesktop(desktopPage, task); + const phone = await runPhone(browser, task); + const result = { + hostRoot, + hostRevision, + package: packagePath, + variant: hostVariant, + desktop, + phone, + screenshots: [ + "desktop-hover-preview.png", + "desktop-reduced-motion.png", + "phone-action-viewport.png", + "phone-action-surface.png", + ], + }; + writeFileSync(path.join(outputDir, "result.json"), `${JSON.stringify(result, null, 2)}\n`); + console.log(JSON.stringify(result, null, 2)); + await desktopContext.close(); + } finally { + if (browser) await browser.close(); + await stopHost(); + await new Promise((resolve) => serverLog.end(resolve)); + rmSync(tempDir, { recursive: true, force: true }); + } +} + +await main(); diff --git a/scripts/test-verify-package.sh b/scripts/test-verify-package.sh new file mode 100644 index 0000000..cfec076 --- /dev/null +++ b/scripts/test-verify-package.sh @@ -0,0 +1,129 @@ +#!/bin/sh +set -eu + +repo_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +verify_script=$repo_dir/scripts/verify-package.sh +test_dir=$(mktemp -d "${TMPDIR:-/tmp}/kandy-package-test.XXXXXX") +trap 'rm -rf "$test_dir"' EXIT HUP INT TERM +version=$(sed -nE 's/^VERSION := ([^[:space:]]+)$/\1/p' "$repo_dir/Makefile") + +write_checksums() { + fixture=$1 + ( + cd "$fixture" + find . -type f ! -name checksums.txt -print | sed 's#^\./##' | LC_ALL=C sort | + while IFS= read -r path; do + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" + else + shasum -a 256 "$path" + fi + done + ) > "$fixture/checksums.txt" +} + +create_fixture() { + fixture=$1 + mkdir -p "$fixture/server" "$fixture/ui" + cat > "$fixture/manifest.yaml" < "$fixture/README.md" + printf 'window.bundleFixture = true;\n' > "$fixture/ui/bundle.js" + for executable in \ + plugin-linux-amd64 plugin-linux-arm64 \ + plugin-darwin-amd64 plugin-darwin-arm64 \ + plugin-windows-amd64.exe; do + printf 'fixture binary: %s\n' "$executable" > "$fixture/server/$executable" + done + write_checksums "$fixture" +} + +copy_fixture() { + name=$1 + mkdir -p "$test_dir/$name" + cp -R "$test_dir/valid/." "$test_dir/$name/" +} + +expect_failure() { + name=$1 + fixture=$2 + mode=${3-full} + platform=${4-} + if [ "$mode" = host ]; then + if sh "$verify_script" "$fixture" host "$platform" >/dev/null 2>&1; then + printf 'expected package verification to reject %s\n' "$name" >&2 + exit 1 + fi + elif sh "$verify_script" "$fixture" full >/dev/null 2>&1; then + printf 'expected package verification to reject %s\n' "$name" >&2 + exit 1 + fi +} + +create_fixture "$test_dir/valid" +sh "$verify_script" "$test_dir/valid" full >/dev/null + +host_platform=$(go env GOOS)-$(go env GOARCH) +copy_fixture host-valid +host_fixture=$test_dir/host-valid +host_executable=$(awk -v platform="$host_platform" '$0 ~ " " platform ":" { gsub(/"/, "", $2); print $2 }' "$host_fixture/manifest.yaml") +[ -n "$host_executable" ] || { printf 'fixture does not declare host platform %s\n' "$host_platform" >&2; exit 1; } +for executable in "$host_fixture"/server/*; do + [ "$executable" = "$host_fixture/$host_executable" ] || rm "$executable" +done +write_checksums "$host_fixture" +sh "$verify_script" "$host_fixture" host "$host_platform" >/dev/null +if sh "$verify_script" "$host_fixture" full >/dev/null 2>&1; then + printf 'expected the host-only fixture to fail full-package verification\n' >&2 + exit 1 +fi + +copy_fixture missing-ui +rm "$test_dir/missing-ui/ui/bundle.js" +write_checksums "$test_dir/missing-ui" +expect_failure 'a missing UI bundle' "$test_dir/missing-ui" + +copy_fixture missing-binary +rm "$test_dir/missing-binary/server/plugin-linux-amd64" +write_checksums "$test_dir/missing-binary" +expect_failure 'a missing declared platform binary' "$test_dir/missing-binary" + +copy_fixture unexpected-file +printf 'unexpected\n' > "$test_dir/unexpected-file/extra.txt" +write_checksums "$test_dir/unexpected-file" +expect_failure 'an unexpected package file' "$test_dir/unexpected-file" + +copy_fixture incomplete-checksums +sed '/ui\/bundle.js$/d' "$test_dir/incomplete-checksums/checksums.txt" > "$test_dir/incomplete-checksums/checksums.next" +mv "$test_dir/incomplete-checksums/checksums.next" "$test_dir/incomplete-checksums/checksums.txt" +expect_failure 'an omitted checksum entry' "$test_dir/incomplete-checksums" + +copy_fixture corrupt-bundle +printf '// changed after checksum generation\n' >> "$test_dir/corrupt-bundle/ui/bundle.js" +expect_failure 'a corrupted UI bundle' "$test_dir/corrupt-bundle" + +copy_fixture wrong-platforms +sed '/windows-amd64:/d' "$test_dir/wrong-platforms/manifest.yaml" > "$test_dir/wrong-platforms/manifest.next" +mv "$test_dir/wrong-platforms/manifest.next" "$test_dir/wrong-platforms/manifest.yaml" +write_checksums "$test_dir/wrong-platforms" +expect_failure 'an unsupported runtime platform set' "$test_dir/wrong-platforms" + +archive="$test_dir/kandev-plugin-kandy-$version.tar.gz" +tar -czf "$archive" -C "$test_dir/valid" . +sh "$verify_script" "$archive" full >/dev/null +if sh "$verify_script" "$test_dir/valid" unknown >/dev/null 2>&1; then + printf 'expected an unknown verification mode to fail\n' >&2 + exit 1 +fi + +printf 'package verifier positive and negative checks passed\n' diff --git a/scripts/test-verify-release-version.sh b/scripts/test-verify-release-version.sh new file mode 100644 index 0000000..bfc78e3 --- /dev/null +++ b/scripts/test-verify-release-version.sh @@ -0,0 +1,68 @@ +#!/bin/sh +set -eu + +repo_dir=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +verify_script=$repo_dir/scripts/verify-release-version.sh +test_dir=$(mktemp -d "${TMPDIR:-/tmp}/kandy-release-version-test.XXXXXX") +trap 'rm -rf "$test_dir"' EXIT HUP INT TERM +base_version=$(sed -nE 's/^version: "([^"]+)"$/\1/p' "$repo_dir/manifest.yaml") +wrong_version=999.999.999 +[ "$wrong_version" != "$base_version" ] || wrong_version=999.999.998 + +make_fixture() { + name=$1 + fixture=$test_dir/$name + mkdir -p "$fixture" + cp "$repo_dir/Makefile" "$repo_dir/manifest.yaml" "$fixture/" +} + +expect_failure() { + name=$1 + fixture=$2 + shift 2 + if (cd "$fixture" && sh "$verify_script" "$@") > "$test_dir/output" 2>&1; then + printf 'expected release verification to reject %s\n' "$name" >&2 + exit 1 + fi +} + +make_fixture valid +(cd "$test_dir/valid" && sh "$verify_script" "v$base_version") + +make_fixture prerelease +prerelease_version=0.15.0-rc.2 +sed "s/^version: \"$base_version\"$/version: \"$prerelease_version\"/" "$test_dir/prerelease/manifest.yaml" > "$test_dir/prerelease/manifest.next" +mv "$test_dir/prerelease/manifest.next" "$test_dir/prerelease/manifest.yaml" +sed "s/^VERSION := $base_version$/VERSION := $prerelease_version/" "$test_dir/prerelease/Makefile" > "$test_dir/prerelease/Makefile.next" +mv "$test_dir/prerelease/Makefile.next" "$test_dir/prerelease/Makefile" +(cd "$test_dir/prerelease" && sh "$verify_script" "v$prerelease_version") + +make_fixture wrong-tag +expect_failure 'a tag that differs from manifest.yaml' "$test_dir/wrong-tag" "v$wrong_version" + +make_fixture wrong-manifest +sed "s/^version: \"$base_version\"$/version: \"$wrong_version\"/" "$test_dir/wrong-manifest/manifest.yaml" > "$test_dir/wrong-manifest/manifest.next" +mv "$test_dir/wrong-manifest/manifest.next" "$test_dir/wrong-manifest/manifest.yaml" +expect_failure 'a manifest version that differs from Makefile' "$test_dir/wrong-manifest" "v$base_version" + +make_fixture wrong-makefile +sed "s/^VERSION := $base_version$/VERSION := $wrong_version/" "$test_dir/wrong-makefile/Makefile" > "$test_dir/wrong-makefile/Makefile.next" +mv "$test_dir/wrong-makefile/Makefile.next" "$test_dir/wrong-makefile/Makefile" +expect_failure 'a Makefile version that differs from manifest.yaml' "$test_dir/wrong-makefile" "v$base_version" + +make_fixture wrong-package +mkdir -p "$test_dir/wrong-package/archive" +sed "s/^version: \"$base_version\"$/version: \"$wrong_version\"/" "$test_dir/wrong-package/manifest.yaml" > "$test_dir/wrong-package/archive/manifest.yaml" +package_file="kandev-plugin-kandy-$base_version.tar.gz" +tar -czf "$test_dir/wrong-package/$package_file" -C "$test_dir/wrong-package/archive" manifest.yaml +expect_failure 'an archive manifest that differs from the tag' "$test_dir/wrong-package" "v$base_version" "$package_file" + +make_fixture matching-package +mkdir -p "$test_dir/matching-package/archive" +cp "$test_dir/matching-package/manifest.yaml" "$test_dir/matching-package/archive/manifest.yaml" +package_file="kandev-plugin-kandy-$base_version.tar.gz" +tar -czf "$test_dir/matching-package/$package_file" -C "$test_dir/matching-package/archive" manifest.yaml +(cd "$test_dir/matching-package" && sh "$verify_script" "v$base_version" "$package_file") + +expect_failure 'a non-version release tag' "$test_dir/valid" "release-$base_version" +printf 'release version positive and negative checks passed\n' diff --git a/scripts/ui-smoke.html b/scripts/ui-smoke.html new file mode 100644 index 0000000..9b80dda --- /dev/null +++ b/scripts/ui-smoke.html @@ -0,0 +1,303 @@ + + + + + + Kandy packaged UI smoke + + + + + +
+ + + + + diff --git a/scripts/verify-package.sh b/scripts/verify-package.sh new file mode 100644 index 0000000..3ed8ed0 --- /dev/null +++ b/scripts/verify-package.sh @@ -0,0 +1,117 @@ +#!/bin/sh +set -eu + +fail() { + printf 'package verification failed: %s\n' "$1" >&2 + exit 1 +} + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + fail 'usage: verify-package.sh PACKAGE_FILE_OR_DIR full | host PLATFORM' +fi + +input=$1 +mode=$2 +host_platform=${3-} +package_dir= +temporary_dir= + +if [ -d "$input" ]; then + package_dir=$(CDPATH= cd -- "$input" && pwd) +elif [ -f "$input" ]; then + temporary_dir=$(mktemp -d "${TMPDIR:-/tmp}/kandy-package-verify.XXXXXX") + trap 'rm -rf "$temporary_dir"' EXIT HUP INT TERM + unsafe_path=$(tar -tzf "$input" | awk ' + /^\// { print; exit } + /(^|\/)\.\.(\/|$)/ { print; exit } + ') + [ -z "$unsafe_path" ] || fail "archive contains an unsafe path: $unsafe_path" + tar -xzf "$input" -C "$temporary_dir" || fail 'could not extract package archive' + package_dir=$temporary_dir +else + fail "package file or directory not found: $input" +fi + +for required in manifest.yaml README.md ui/bundle.js checksums.txt; do + [ -f "$package_dir/$required" ] || fail "missing required file: $required" +done + +manifest_id=$(sed -nE 's/^id: "([^"]+)"$/\1/p' "$package_dir/manifest.yaml") +[ "$manifest_id" = "kandev-plugin-kandy" ] || fail "unexpected manifest id: ${manifest_id:-missing}" +manifest_version=$(sed -nE 's/^version: "([^"]+)"$/\1/p' "$package_dir/manifest.yaml") +make_version=$(sed -nE 's/^VERSION := ([^[:space:]]+)$/\1/p' "$(dirname "$0")/../Makefile") +[ -n "$manifest_version" ] || fail 'manifest.yaml has no version' +[ "$manifest_version" = "$make_version" ] || fail "manifest version $manifest_version differs from Makefile version $make_version" +printf '%s\n' "$manifest_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || fail "manifest version is invalid: $manifest_version" + +if [ -f "$input" ]; then + expected_archive="kandev-plugin-kandy-$manifest_version.tar.gz" + [ "$(basename "$input")" = "$expected_archive" ] || fail "package filename $(basename "$input") differs from $expected_archive" +fi + +manifest_executables=$(awk ' + $0 == "runtime:" { in_runtime = 1; next } + in_runtime && $0 == " executables:" { in_executables = 1; next } + in_executables && $0 !~ /^ / { exit } + in_executables && /^ [[:alnum:]_-]+: "[^\"]+"$/ { + platform = $1 + sub(/:$/, "", platform) + path = $2 + gsub(/"/, "", path) + print platform " " path + } +' "$package_dir/manifest.yaml" | LC_ALL=C sort) +expected_executables=$(printf '%s\n' \ + 'darwin-amd64 server/plugin-darwin-amd64' \ + 'darwin-arm64 server/plugin-darwin-arm64' \ + 'linux-amd64 server/plugin-linux-amd64' \ + 'linux-arm64 server/plugin-linux-arm64' \ + 'windows-amd64 server/plugin-windows-amd64.exe' | LC_ALL=C sort) +[ "$manifest_executables" = "$expected_executables" ] || fail 'manifest runtime.executables does not match the supported platform set' + +case "$mode" in + full) + executable_paths=$(printf '%s\n' "$manifest_executables" | awk '{ print $2 }') + ;; + host) + [ -n "$host_platform" ] || fail 'host mode requires a platform name' + executable_paths=$(printf '%s\n' "$manifest_executables" | awk -v platform="$host_platform" '$1 == platform { print $2 }') + [ -n "$executable_paths" ] || fail "host platform is not declared: $host_platform" + ;; + *) + fail "unknown verification mode: $mode" + ;; +esac + +for executable in $executable_paths; do + [ -f "$package_dir/$executable" ] || fail "missing declared executable: $executable" +done + +unexpected_entry=$(find "$package_dir" -mindepth 1 ! -type f ! -type d -print -quit) +[ -z "$unexpected_entry" ] || fail "package contains a non-file entry: ${unexpected_entry#"$package_dir"/}" +unexpected_dir=$(find "$package_dir" -mindepth 1 -type d -print | sed "s#^$package_dir/##" | awk '$0 != "server" && $0 != "ui" { print; exit }') +[ -z "$unexpected_dir" ] || fail "package contains an unexpected directory: $unexpected_dir" + +expected_files=$(printf '%s\n' manifest.yaml README.md ui/bundle.js checksums.txt $executable_paths | LC_ALL=C sort) +actual_files=$(cd "$package_dir" && find . -type f -print | sed 's#^\./##' | LC_ALL=C sort) +[ "$actual_files" = "$expected_files" ] || { + printf 'unexpected package file inventory\nexpected:\n%s\nfound:\n%s\n' "$expected_files" "$actual_files" >&2 + exit 1 +} + +checksum_entries=$(awk ' + NF != 2 { invalid = 1; next } + { path = $2; sub(/^\*/, "", path); print path } + END { if (NR == 0 || invalid) exit 1 } +' "$package_dir/checksums.txt") || fail 'checksums.txt has invalid lines' +checksum_files=$(printf '%s\n' "$checksum_entries" | LC_ALL=C sort) +expected_checksum_files=$(printf '%s\n' manifest.yaml README.md ui/bundle.js $executable_paths | LC_ALL=C sort) +[ "$checksum_files" = "$expected_checksum_files" ] || fail 'checksums.txt does not list every package file exactly once' + +if command -v sha256sum >/dev/null 2>&1; then + (cd "$package_dir" && sha256sum -c checksums.txt) || fail 'checksum verification failed' +elif command -v shasum >/dev/null 2>&1; then + (cd "$package_dir" && shasum -a 256 -c checksums.txt) || fail 'checksum verification failed' +else + fail 'sha256sum or shasum is required' +fi diff --git a/scripts/verify-release-version.sh b/scripts/verify-release-version.sh new file mode 100644 index 0000000..06869f2 --- /dev/null +++ b/scripts/verify-release-version.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -eu + +fail() { + printf 'release version verification failed: %s\n' "$1" >&2 + exit 1 +} + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + fail 'usage: verify-release-version.sh TAG [PACKAGE_FILE]' +fi + +tag=$1 +version_pattern='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' +printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || fail "tag is not a release version: $tag" +tag_version=${tag#v} +manifest_version=$(sed -nE 's/^version: "([^"]+)"$/\1/p' manifest.yaml) +make_version=$(sed -nE 's/^VERSION := ([^[:space:]]+)$/\1/p' Makefile) +[ -n "$manifest_version" ] || fail 'manifest.yaml has no version' +[ -n "$make_version" ] || fail 'Makefile has no VERSION' +printf '%s\n' "$manifest_version" | grep -Eq "$version_pattern" || fail "manifest version is invalid: $manifest_version" +printf '%s\n' "$make_version" | grep -Eq "$version_pattern" || fail "Makefile version is invalid: $make_version" +[ "$manifest_version" = "$make_version" ] || fail "manifest version $manifest_version differs from Makefile version $make_version" +[ "$tag_version" = "$manifest_version" ] || fail "tag $tag differs from manifest version $manifest_version" + +if [ "$#" -eq 2 ]; then + package_file=$2 + expected_package=$(make -s package-file) + [ -f "$package_file" ] || fail "package file not found: $package_file" + [ "$(basename "$package_file")" = "$expected_package" ] || fail "package filename $(basename "$package_file") differs from $expected_package" + package_version=$(tar -xOzf "$package_file" manifest.yaml | sed -nE 's/^version: "([^"]+)"$/\1/p') || fail 'could not read package manifest' + [ "$package_version" = "$tag_version" ] || fail "package manifest version ${package_version:-missing} differs from tag $tag" +fi diff --git a/ui/bundle.js b/ui/bundle.js index aefc93e..08e9e28 100644 --- a/ui/bundle.js +++ b/ui/bundle.js @@ -2722,11 +2722,11 @@ function sceneFor(biome, level, seed, timeOfDay, season, ancestors, ancestorBoun // --------------------------------------------------------------------------- var KANDY_CSS = - // The host's chat topbar uses 28px controls on desktop and 44px touch - // targets on phones. The ID selector keeps this plugin-owned geometry - // authoritative over the utility classes on the shared host button. - "#kandev-kandy-widget{width:28px;height:28px}" + - "@media (max-width:639px){#kandev-kandy-widget{width:44px;height:44px}}" + + // Older hosts without ui.Action use the original button shell. Current + // hosts own the action dimensions and icon box in the chat top bar. + "#kandev-kandy-widget.kandev-kandy-widget-legacy{width:28px;height:28px}" + + "@media (max-width:639px){#kandev-kandy-widget.kandev-kandy-widget-legacy{width:44px;height:44px}}" + + ".kandev-kandy-widget-art{position:relative;display:inline-flex;align-items:center;justify-content:center}" + // The shared TooltipContent always renders a small rotated-square arrow // (a direct span child wrapping an svg). On our full-bleed scene card it // reads as a stray floating square — hide it. :has() keeps the OTHER @@ -3043,6 +3043,77 @@ function removeStyles() { // Hover card + top-bar widget. // --------------------------------------------------------------------------- +var KANDY_ACTION_TRANSLATIONS = { + en: { + topbarActionAwake: "Kandy: level {{level}} {{stageName}}, {{mood}}", + topbarActionAsleep: "Kandy: level {{level}} {{stageName}}, {{mood}}, sleeping", + }, +}; + +function kandyTopbarLabel(translate, shown, asleep) { + var level = shown.level; + var stageName = shown.stage_name; + var mood = shown.mood || "content"; + var fallback = "Kandy: level " + level + " " + stageName + ", " + mood + (asleep ? ", sleeping" : ""); + if (typeof translate !== "function") return fallback; + + var key = asleep ? "topbarActionAsleep" : "topbarActionAwake"; + var label = translate(key, { + defaultValue: fallback, + values: { level: level, stageName: stageName, mood: mood }, + }); + return typeof label === "string" && label && label !== key ? label : fallback; +} + +function kandyTopbarAction(h, ui, options) { + var Action = ui && ui.Action; + var chipClass = ""; + if (options.celebration) { + chipClass = bigCelebration(options.celebration) ? "kandev-kandy-levelup" : "kandev-kandy-celebrate"; + } else if (options.greetFx) { + chipClass = "kandev-kandy-celebrate"; + } + + var icon = h( + "span", + { + className: "kandev-kandy-widget-art" + (chipClass ? " " + chipClass : ""), + "aria-hidden": "true", + }, + creatureSvg(h, options.chipShown, Action ? 16 : 22, Action ? "size-4" : "", true), + ); + var label = kandyTopbarLabel(options.translate, options.shown, options.chipAsleep); + + if (Action) { + return h(Action, { + id: "kandev-kandy-widget", + label: label, + icon: icon, + // The Kandy card is the rich preview. Do not add a second, short host + // tooltip around the same trigger. + tooltip: "", + onMouseEnter: options.onLoad, + onFocus: options.onLoad, + onClick: options.onOpen, + }); + } + + return h( + "button", + { + id: "kandev-kandy-widget", + type: "button", + className: + "kandev-kandy-widget-legacy relative h-7 w-7 flex items-center justify-center cursor-pointer rounded-md border border-border/60 bg-muted/30 hover:bg-muted/60", + "aria-label": label, + onMouseEnter: options.onLoad, + onFocus: options.onLoad, + onClick: options.onOpen, + }, + icon, + ); +} + var EGG_PLACEHOLDER = { level: 1, stage: 0, @@ -7791,6 +7862,9 @@ function makeKandyWidget(host) { var DialogTitle = ui.DialogTitle; return function KandyWidget() { + var translation = + host.i18n && typeof host.i18n.useTranslation === "function" ? host.i18n.useTranslation() : null; + var translate = translation && translation.t; var stateHook = React.useState(null); var data = stateHook[0]; var setData = stateHook[1]; @@ -8911,47 +8985,26 @@ function makeKandyWidget(host) { var chipAsleep = kandyAsleep; if (chipAsleep) chipShown = Object.assign({}, shown, { sleep_state: "asleep" }); - // The chip is a real button: hover/focus gives the desktop quick-peek - // tooltip, tap/click opens the same card as a dialog (touch devices - // have no hover, so the dialog is the mobile path). - var chipCelebrateCls = ""; - if (celebration) { - chipCelebrateCls = - bigCelebration(celebration) ? " kandev-kandy-levelup" : " kandev-kandy-celebrate"; - } else if (greetFx) { - // The chip does its existing small hop alongside the arrival wave. - chipCelebrateCls = " kandev-kandy-celebrate"; - } - var trigger = h( - "button", - { - id: "kandev-kandy-widget", - type: "button", - className: - "relative h-7 w-7 flex items-center justify-center cursor-pointer rounded-md border border-border/60 bg-muted/30 hover:bg-muted/60" + - chipCelebrateCls, - "aria-label": - "Kandy: level " + - shown.level + - " " + - shown.stage_name + - ", " + - (shown.mood || "content") + - (chipAsleep ? ", sleeping" : ""), - onMouseEnter: load, - onFocus: load, - onClick: function () { - load(); - returnToPhotoEntryRef.current = false; - setPhotoOpen(false); - setPhotoStatus("idle"); - setDialogOpen(true); - // The dialog always greets on open (arrival gets the hop too). - greetOnOpen(); - }, + // Hover/focus keeps the desktop quick peek warm; clicking, tapping, or + // using the keyboard opens the same card as a dialog on every surface. + var trigger = kandyTopbarAction(h, ui, { + shown: shown, + chipShown: chipShown, + chipAsleep: chipAsleep, + translate: translate, + celebration: celebration, + greetFx: greetFx, + onLoad: load, + onOpen: function () { + load(); + returnToPhotoEntryRef.current = false; + setPhotoOpen(false); + setPhotoStatus("idle"); + setDialogOpen(true); + // The dialog always greets on open (arrival gets the hop too). + greetOnOpen(); }, - creatureSvg(h, chipShown, 22, "", true), - ); + }); // Shared interaction wiring for BOTH card surfaces (hover preview and // click dialog): treat on click, bucket on right-click, plus the fx @@ -9221,6 +9274,9 @@ window.registerKandevPlugin(PLUGIN_ID, { initialize: function (registry, host) { h0 = host.jsx; injectStyles(); + if (typeof registry.registerTranslations === "function") { + registry.registerTranslations(KANDY_ACTION_TRANSLATIONS); + } registry.registerComponent("chat-top-bar", makeKandyWidget(host)); // Live updates: refetch when work happens, instead of waiting for the // backstop poll (or a page reload). @@ -9255,6 +9311,8 @@ window.registerKandevPlugin(PLUGIN_ID, { photoAncestorsFor: photoAncestorsFor, growthForLevel: growthForLevel, kandyCard: kandyCard, + kandyTopbarAction: kandyTopbarAction, + kandyTopbarLabel: kandyTopbarLabel, petOverlay: petOverlay, bonkOverlay: bonkOverlay, distrustOverlay: distrustOverlay, diff --git a/ui/bundle.test.js b/ui/bundle.test.js index 1630bda..47f04bc 100644 --- a/ui/bundle.test.js +++ b/ui/bundle.test.js @@ -103,6 +103,89 @@ function findNode(root, predicate) { return found; } +function makeHookHarness() { + let index = 0; + const slots = []; + const stateUpdates = []; + const React = { + Fragment: "react.fragment", + useState(initialValue) { + const slot = index++; + if (!slots[slot]) { + slots[slot] = { + kind: "state", + value: typeof initialValue === "function" ? initialValue() : initialValue, + }; + } + return [slots[slot].value, (value) => stateUpdates.push([slot, value])]; + }, + useRef(initialValue) { + const slot = index++; + if (!slots[slot]) slots[slot] = { kind: "ref", value: { current: initialValue } }; + return slots[slot].value; + }, + useEffect() {}, + useLayoutEffect() {}, + }; + return { + React, + stateUpdates, + render(component) { + index = 0; + return component(); + }, + }; +} + +function renderTopbarWidget(options = {}) { + const { document, plugin } = loadBundle(); + const harness = makeHookHarness(); + const ui = { + Tooltip: "host.Tooltip", + TooltipTrigger: "host.TooltipTrigger", + TooltipContent: "host.TooltipContent", + Dialog: "host.Dialog", + DialogContent: "host.DialogContent", + DialogTitle: "host.DialogTitle", + }; + if (options.withAction) ui.Action = function HostAction() {}; + const fetchCalls = []; + const registrations = []; + const host = { + React: harness.React, + jsx, + ui, + api: { + fetch(path) { + fetchCalls.push(path); + return Promise.resolve({ json: () => Promise.resolve(sampleKandy()) }); + }, + }, + }; + if (options.translation) { + host.i18n = { + useTranslation() { + return { t: options.translation }; + }, + }; + } + const registry = { + registerComponent(slot, component) { + registrations.push({ slot, component }); + }, + registerWsHandler() {}, + }; + let translations = null; + if (options.withTranslations) { + registry.registerTranslations = (catalogs) => { + translations = catalogs; + }; + } + plugin.initialize(registry, host); + const tree = harness.render(registrations[0].component); + return { document, fetchCalls, harness, plugin, registrations, translations, tree, ui }; +} + function sampleKandy(overrides = {}) { return Object.assign( { @@ -1458,23 +1541,134 @@ test("token grotto resolves removed chambers to hub and subscribes to live usage assert.ok(actions.includes("session.prompt_usage")); }); -test("chat topbar control uses desktop and phone geometry", () => { - const { document, plugin } = loadBundle(); - plugin.initialize( - { - registerComponent() {}, - registerWsHandler() {}, - }, - { jsx, ui: {} }, - ); +test("chat topbar uses host Action with one decorative creature inside the host glyph", () => { + const { document, tree, ui } = renderTopbarWidget({ withAction: true }); + const wrapper = findNode(tree, (node) => node.type === ui.TooltipTrigger); + const action = wrapper.props.children; + assert.equal(action.type, ui.Action); + assert.equal(action.props.id, "kandev-kandy-widget"); + assert.equal(action.props.label, "Kandy: level 1 Egg, content"); + assert.equal(action.props.tooltip, ""); + assert.equal(typeof action.props.onMouseEnter, "function"); + assert.equal(typeof action.props.onFocus, "function"); + assert.equal(typeof action.props.onClick, "function"); + assert.equal("className" in action.props, false); + assert.equal("style" in action.props, false); + assert.equal("size" in action.props, false); + assert.equal(action.props.children, undefined); + + const icon = action.props.icon; + assert.equal(icon.type, "span"); + assert.equal(icon.props["aria-hidden"], "true"); + assert.equal(icon.props.children.type, "svg"); + assert.equal(icon.props.children.props.width, 16); + assert.equal(icon.props.children.props.height, 16); + assert.match(icon.props.children.props.className, /\bsize-4\b/); + assert.equal(icon.props.children.props["aria-hidden"], "true"); - const style = document.getElementById("kandev-kandy-style"); - assert.ok(style); - assert.match(style.textContent, /#kandev-kandy-widget[^}]*width:28px[^}]*height:28px/); + const css = document.getElementById("kandev-kandy-style").textContent; + assert.doesNotMatch(css, /#kandev-kandy-widget\{[^}]*width/); + assert.match(css, /#kandev-kandy-widget\.kandev-kandy-widget-legacy\{width:28px;height:28px\}/); assert.match( - style.textContent, - /@media \(max-width:639px\)\{#kandev-kandy-widget[^}]*width:44px[^}]*height:44px/, + css, + /@media \(max-width:639px\)\{#kandev-kandy-widget\.kandev-kandy-widget-legacy\{width:44px;height:44px\}/, + ); +}); + +test("chat topbar falls back to the native control and keeps its legacy target", () => { + const { tree } = renderTopbarWidget(); + const trigger = findNode(tree, (node) => node.type === "host.TooltipTrigger").props.children; + assert.equal(trigger.type, "button"); + assert.equal(trigger.props.type, "button"); + assert.match(trigger.props.className, /kandev-kandy-widget-legacy/); + assert.equal(trigger.props["aria-label"], "Kandy: level 1 Egg, content"); + assert.equal(trigger.props.children.type, "span"); + assert.equal(trigger.props.children.props.children.props.width, 22); +}); + +test("chat topbar registers English catalog and uses host-localized accessible copy", () => { + const calls = []; + const { tree, translations, ui } = renderTopbarWidget({ + withAction: true, + withTranslations: true, + translation(key, options) { + calls.push([key, options]); + const prefix = key === "topbarActionAsleep" ? "Localized sleeping" : "Localized awake"; + return `${prefix}: level ${options.values.level} ${options.values.stageName}, ${options.values.mood}`; + }, + }); + const action = findNode(tree, (node) => node.type === ui.Action); + assert.equal(action.props.label, "Localized awake: level 1 Egg, content"); + assert.deepEqual(Object.keys(translations).sort(), ["en"]); + assert.equal(translations.en.topbarActionAwake, "Kandy: level {{level}} {{stageName}}, {{mood}}"); + assert.equal( + translations.en.topbarActionAsleep, + "Kandy: level {{level}} {{stageName}}, {{mood}}, sleeping", ); + assert.equal(calls[0][0], "topbarActionAwake"); + assert.equal(calls[0][1].values.level, 1); + assert.equal(calls[0][1].values.stageName, "Egg"); + assert.equal(calls[0][1].values.mood, "content"); + assert.equal(calls[0][1].defaultValue, "Kandy: level 1 Egg, content"); + + const label = loadBundle().plugin.__render.kandyTopbarLabel; + const sleeping = label( + (key, options) => { + assert.equal(key, "topbarActionAsleep"); + return `Dormant Kandy level ${options.values.level} ${options.values.stageName}, ${options.values.mood}`; + }, + { level: 8, stage_name: "Cloud Sporeling", mood: "calm" }, + true, + ); + assert.equal(sleeping, "Dormant Kandy level 8 Cloud Sporeling, calm"); + assert.equal( + label(null, { level: 8, stage_name: "Cloud Sporeling", mood: "calm" }, true), + "Kandy: level 8 Cloud Sporeling, calm, sleeping", + ); +}); + +test("topbar hover, focus, and activation keep the preview, data fetch, and dialog path", async () => { + const { fetchCalls, harness, registrations, tree, ui } = renderTopbarWidget({ withAction: true }); + assert.equal(registrations.length, 1); + assert.equal(registrations[0].slot, "chat-top-bar"); + const wrapper = findNode(tree, (node) => node.type === ui.Tooltip); + assert.ok(findNode(wrapper, (node) => node.type === ui.TooltipContent)); + const action = findNode(tree, (node) => node.type === ui.Action); + action.props.onMouseEnter({ type: "mouseenter" }); + action.props.onFocus({ type: "focus" }); + action.props.onClick({ type: "click" }); + + assert.deepEqual(fetchCalls, ["webhooks/kandy", "webhooks/kandy", "webhooks/kandy"]); + assert.ok(harness.stateUpdates.some(([slot, value]) => slot === 1 && value === true)); + await Promise.resolve(); + await Promise.resolve(); +}); + +test("disable and re-enable removes and restores the widget style and registration", () => { + const { document, plugin } = loadBundle(); + const registrations = []; + const handlers = []; + const registry = { + registerComponent(slot, component) { + registrations.push({ slot, component }); + }, + registerWsHandler(action) { + handlers.push(action); + }, + }; + const host = { jsx, ui: {}, React: {} }; + plugin.initialize(registry, host); + assert.ok(document.getElementById("kandev-kandy-style")); + plugin.destroy(); + assert.equal(document.getElementById("kandev-kandy-style"), null); + plugin.initialize(registry, host); + + assert.equal(registrations.length, 2); + assert.ok(registrations.every((entry) => entry.slot === "chat-top-bar")); + assert.notEqual(registrations[0].component, registrations[1].component); + assert.ok(document.getElementById("kandev-kandy-style")); + assert.ok(handlers.length > 0); + plugin.destroy(); }); test("token grotto CSS uses vertical responsive grids without paging tracks", () => {