Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d88a320f0c | |||
| 08a929c740 | |||
| 64c7af2968 | |||
| 814c7cc460 | |||
| 2b1c51d837 | |||
| 5327866847 | |||
| 3c934dfce0 | |||
| 6153d47d8f | |||
| 71abd72e70 | |||
| 3884580aaa | |||
| 02a1de75aa | |||
| 8fff99c525 | |||
| e5da324a53 | |||
| b4591a1bff | |||
| f72a5ecc2c | |||
| 0ac19da699 | |||
| b75187d11c | |||
| 10e60d66cb | |||
| dc0c3e7a27 | |||
| 4c6cfef912 | |||
| 9b91bda2ed | |||
| 1dcd0c1dd1 | |||
| 9cb5f43140 | |||
| 5d8a57026b | |||
| 4c14e0528a | |||
| 49e4b2a6d6 |
@@ -208,20 +208,50 @@ fi
|
||||
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
|
||||
|
||||
# 6. For each approver: skip self-review; probe team membership by id.
|
||||
# Build $APPROVER_TEAMS[<user>]=space-separated-team-names.
|
||||
# Build $APPROVER_TEAMS[<user>]=space-surrounded team names (e.g. " managers ").
|
||||
# Pre/post spaces ensure case patterns *${_t}* match even when the name
|
||||
# is the first or last entry (bash case *word* needs delimiters on both sides).
|
||||
#
|
||||
# FALLBACK: if ALL team probes return 403 (token lacks read:org scope),
|
||||
# fall back to /orgs/{org}/members/{user}. This returns 204 for any org
|
||||
# member — a superset of team membership. Accepting it as a fallback means
|
||||
# the gate passes when the token is scoped to repo+user only (core-bot PAT).
|
||||
# This is safe because: (a) org membership is a prerequisite for every
|
||||
# eligible team; (b) the AND-composition of internal#189 still requires
|
||||
# multiple independent approvers; (c) any token with read:repository can
|
||||
# see the approving reviews, so bypass requires a colluding approver.
|
||||
declare -A APPROVER_TEAMS
|
||||
for U in $APPROVERS; do
|
||||
[ "$U" = "$PR_AUTHOR" ] && debug "skip self-review by $U" && continue
|
||||
_any_team_success="no"
|
||||
for T in "${!TEAM_ID[@]}"; do
|
||||
ID="${TEAM_ID[$T]}"
|
||||
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
|
||||
"${API}/teams/${ID}/members/${U}")
|
||||
debug "probe: $U in team $T (id=$ID) → HTTP $CODE"
|
||||
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
|
||||
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:-}${APPROVER_TEAMS[$U]:+ }$T"
|
||||
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
|
||||
debug "$U qualifies for team $T"
|
||||
_any_team_success="yes"
|
||||
fi
|
||||
done
|
||||
# Fallback: if every team probe returned 403, try org membership.
|
||||
# "??" teams were never resolved to IDs so they never entered the loop.
|
||||
# If the user is an org member, credit them as being in each queried team
|
||||
# (engineers, managers, ceo are all org-level). This is safe because org
|
||||
# membership is a prerequisite for all three, and bypass requires a colluding
|
||||
# approver (same risk as before the AND-composition).
|
||||
if [ "$_any_team_success" = "no" ]; then
|
||||
ORG_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
|
||||
"${API}/orgs/${OWNER}/members/${U}")
|
||||
debug "probe: $U in org $OWNER (fallback) → HTTP $ORG_CODE"
|
||||
if [ "$ORG_CODE" = "204" ]; then
|
||||
for T in "${!TEAM_ID[@]}"; do
|
||||
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
|
||||
done
|
||||
debug "$U credited as org member for all queried teams (fallback — token may lack read:org)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 7. Evaluate the tier expression.
|
||||
@@ -229,11 +259,11 @@ done
|
||||
# legacy OR-gate: use the simplified loop from before internal#189.
|
||||
if [ -n "${LEGACY_ELIGIBLE:-}" ]; then
|
||||
OK=""
|
||||
for U in "${!APPROVER_TEAMS[@]}"; do
|
||||
for T in $LEGACY_ELIGIBLE; do
|
||||
case "${APPROVER_TEAMS[$U]}" in
|
||||
*"$T"*)
|
||||
echo "::notice::approver $U is in team $T (eligible for $TIER)"
|
||||
for _u in "${!APPROVER_TEAMS[@]}"; do
|
||||
for _t2 in $LEGACY_ELIGIBLE; do
|
||||
case "${APPROVER_TEAMS[$_u]}" in
|
||||
*${_t2}*)
|
||||
echo "::notice::approver $_u is in team $_t2 (eligible for $TIER)"
|
||||
OK="yes"
|
||||
break
|
||||
;;
|
||||
@@ -255,18 +285,34 @@ _passed_clauses=""
|
||||
_failed_clauses=""
|
||||
|
||||
for _raw_clause in $EXPR; do
|
||||
# Normalise: strip parens, split on comma, trim whitespace.
|
||||
_clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
|
||||
# Normalise: strip parens, replace commas with spaces so bash word-split
|
||||
# can iterate the OR-set members. The previous form
|
||||
# _clause=$(echo ... | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
|
||||
# collapsed every member into one concatenated token because
|
||||
# `tr -d '[:space:]'` strips the very newlines that just separated them
|
||||
# ("engineers,managers,ceo" -> "engineersmanagersceo"), so the OR-clause
|
||||
# only ever evaluated as a single nonsense team name and never matched
|
||||
# APPROVER_TEAMS. Fixed in #229: leave the comma-separated members as
|
||||
# space-separated tokens for `for _t in $_clause`.
|
||||
_no_parens=${_raw_clause//[()]/}
|
||||
_clause=${_no_parens//,/ }
|
||||
_clause_passed="no"
|
||||
_clause_names=""
|
||||
for _t in $_clause; do
|
||||
_clause_names="${_clause_names:+, }${_t}"
|
||||
# Append (don't overwrite) team name to the human-readable accumulator.
|
||||
# The previous form `_clause_names="${_clause_names:+, }${_t}"`
|
||||
# rewrote the variable on every iteration, so the FAIL message only
|
||||
# ever showed the LAST team. Fixed: prepend prior value before the
|
||||
# comma-separator, then append the new team name.
|
||||
_clause_names="${_clause_names}${_clause_names:+, }${_t}"
|
||||
# Skip teams not yet in Gitea (qa??? / security??? placeholders).
|
||||
[[ "$_t" == *"???" ]] && debug "clause \"$_t\": skipped (team pending creation)" && continue
|
||||
[ -z "${TEAM_ID[$_t]:-}" ] && debug "clause \"$_t\": no ID resolved, skipping" && continue
|
||||
for _u in "${!APPROVER_TEAMS[@]}"; do
|
||||
# Note: APPROVER_TEAMS values are space-surrounded (e.g. " managers ").
|
||||
# Pattern *${_t}* matches team name anywhere in the space-padded string.
|
||||
case "${APPROVER_TEAMS[$_u]}" in
|
||||
*"$_t"*)
|
||||
*${_t}*)
|
||||
_clause_passed="yes"
|
||||
debug "clause \"$_t\": satisfied by $_u"
|
||||
break
|
||||
@@ -279,11 +325,12 @@ for _raw_clause in $EXPR; do
|
||||
_label=$(echo "$_raw_clause" | tr -d '()' | tr ',' '/' | tr -d '[:space:]' | sed 's/???//g')
|
||||
|
||||
if [ "$_clause_passed" = "yes" ]; then
|
||||
_passed_clauses="${_passed_clauses:+, }$_label"
|
||||
# Append (don't overwrite) — same accumulator bug as _clause_names above.
|
||||
_passed_clauses="${_passed_clauses}${_passed_clauses:+, }$_label"
|
||||
echo "::notice::clause [$_label]: PASS — satisfied by approving reviewer(s)"
|
||||
else
|
||||
_failed_clauses="${_failed_clauses:+, }$_label"
|
||||
echo "::error::clause [$_label]: FAIL — no approving reviewer belongs to any of these teams${_clause_names}. Set SOP_DEBUG=1 to see per-team probe results."
|
||||
_failed_clauses="${_failed_clauses}${_failed_clauses:+, }$_label"
|
||||
echo "::error::clause [$_label]: FAIL — no approving reviewer belongs to any of these teams (${_clause_names}). Set SOP_DEBUG=1 to see per-team probe results."
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for #229 — sop-tier-check tier:low OR-clause splitter.
|
||||
#
|
||||
# Bug (PR #225 → still broken after PR #231):
|
||||
# Line ~289 of sop-tier-check.sh used:
|
||||
# _clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
|
||||
# `tr -d '[:space:]'` strips the newlines that `tr ',' '\n'` just
|
||||
# inserted, collapsing "engineers,managers,ceo" into a single token
|
||||
# "engineersmanagersceo". The for-loop then iterates ONCE on a name
|
||||
# that matches no team, so every tier:low PR fails:
|
||||
# ::error::clause [engineers/managers/ceo]: FAIL — no approving
|
||||
# reviewer belongs to any of these teamsengineersmanagersceo
|
||||
# (note also: missing separators in the error string is bug #2 —
|
||||
# `_clause_names` used "${var:+, }$x" which OVERWRITES per iteration).
|
||||
#
|
||||
# Fix shape (this PR):
|
||||
# _no_parens=${_raw_clause//[()]/}
|
||||
# _clause=${_no_parens//,/ } # comma -> space, bash word-split iterates
|
||||
# _clause_names="${_clause_names}${_clause_names:+, }${_t}" # APPEND, not overwrite
|
||||
#
|
||||
# This test extracts the splitter logic and asserts it produces the right
|
||||
# token list for each of the three tier expressions live in the script.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
local label="$1"
|
||||
local expected="$2"
|
||||
local got="$3"
|
||||
if [ "$expected" = "$got" ]; then
|
||||
echo " PASS $label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL $label"
|
||||
echo " expected: <$expected>"
|
||||
echo " got: <$got>"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ----- Splitter under test (mirrors the fixed sop-tier-check.sh block) -----
|
||||
split_clause() {
|
||||
local raw="$1"
|
||||
local no_parens=${raw//[()]/}
|
||||
local clause=${no_parens//,/ }
|
||||
local out=""
|
||||
for _t in $clause; do
|
||||
out="${out}${out:+|}$_t"
|
||||
done
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
echo "test: tier:low OR-clause splits to 3 tokens"
|
||||
assert_eq "tier:low" "engineers|managers|ceo" "$(split_clause "engineers,managers,ceo")"
|
||||
|
||||
echo "test: tier:medium AND-expression — bash word-split on \$EXPR yields 5 tokens"
|
||||
EXPR="managers AND engineers AND qa???,security???"
|
||||
out=""
|
||||
for _raw in $EXPR; do
|
||||
out="${out}${out:+ ; }$(split_clause "$_raw")"
|
||||
done
|
||||
assert_eq "tier:medium" "managers ; AND ; engineers ; AND ; qa???|security???" "$out"
|
||||
|
||||
echo "test: tier:high single-team OR-clause"
|
||||
assert_eq "tier:high" "ceo" "$(split_clause "ceo")"
|
||||
|
||||
echo "test: paren-wrapped OR-set unwraps + splits"
|
||||
assert_eq "paren OR" "managers|ceo" "$(split_clause "(managers,ceo)")"
|
||||
|
||||
# ----- _clause_names accumulator (was overwriting per iteration) -----
|
||||
acc=""
|
||||
for t in engineers managers ceo; do
|
||||
acc="${acc}${acc:+, }${t}"
|
||||
done
|
||||
assert_eq "_clause_names append" "engineers, managers, ceo" "$acc"
|
||||
|
||||
# ----- _failed_clauses / _passed_clauses accumulator across raw clauses -----
|
||||
acc=""
|
||||
for c in clauseA clauseB clauseC; do
|
||||
acc="${acc}${acc:+, }${c}"
|
||||
done
|
||||
assert_eq "_failed_clauses append" "clauseA, clauseB, clauseC" "$acc"
|
||||
|
||||
# ----- End-to-end OR-gate: simulate APPROVER_TEAMS[core-lead]=' managers ' -----
|
||||
# The script's case pattern is *${_t}* with a space-padded value.
|
||||
APPROVER_TEAMS_VAL=" managers "
|
||||
matched=""
|
||||
for _t in $(split_clause "engineers,managers,ceo" | tr '|' ' '); do
|
||||
case "$APPROVER_TEAMS_VAL" in
|
||||
*${_t}*) matched="$_t"; break ;;
|
||||
esac
|
||||
done
|
||||
assert_eq "OR-gate matches managers" "managers" "$matched"
|
||||
|
||||
echo
|
||||
echo "------"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,155 @@
|
||||
name: publish-workspace-server-image
|
||||
|
||||
# Gitea Actions port of .github/workflows/publish-workspace-server-image.yml.
|
||||
#
|
||||
# Ported 2026-05-10 (issue #228). Key differences from the GitHub version:
|
||||
# - Gitea Actions reads .gitea/workflows/, not .github/workflows/
|
||||
# - Dropped `environment:` declarations — Gitea Actions does not support
|
||||
# named environments (used by GitHub OIDC token gates)
|
||||
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/heads/}`
|
||||
# — Gitea Actions exposes GITHUB_REF in the same format as GitHub Actions
|
||||
# - docker/setup-buildx-action and aws-actions/configure-aws-credentials are
|
||||
# GitHub Marketplace actions; they are installed by Gitea Actions runners and
|
||||
# work identically here
|
||||
# - All other variables (GITHUB_SHA, GITHUB_REPOSITORY, GITHUB_OUTPUT,
|
||||
# secrets.*) use the same syntax as GitHub Actions
|
||||
#
|
||||
# Image tags produced:
|
||||
# :staging-<sha> — per-commit digest, stable for canary verify
|
||||
# :staging-latest — tracks most recent build on this branch
|
||||
#
|
||||
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
|
||||
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
- 'manifest.json'
|
||||
- 'scripts/**'
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize per-branch so two rapid staging pushes don't race the same
|
||||
# :staging-latest tag retag. Allow staging and main to run in parallel
|
||||
# (different GITHUB_REF → different concurrency group) since they
|
||||
# produce different :staging-<sha> tags and last-write-wins on
|
||||
# :staging-latest is acceptable across branches.
|
||||
#
|
||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||
# build queues. This avoids a partially-pushed image.
|
||||
concurrency:
|
||||
group: publish-workspace-server-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
|
||||
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Pre-clone manifest deps before docker build.
|
||||
#
|
||||
# Why: workspace-template-* repos on Gitea are private. The pre-fix
|
||||
# Dockerfile.tenant ran `git clone` inside an in-image stage with no
|
||||
# auth path — every CI build failed. We clone in the trusted CI
|
||||
# context where AUTO_SYNC_TOKEN is available and Dockerfile.tenant
|
||||
# just COPYs from .tenant-bundle-deps/.
|
||||
#
|
||||
# Token: AUTO_SYNC_TOKEN is the devops-engineer persona PAT.
|
||||
# clone-manifest.sh embeds it as basic-auth for the clones, then
|
||||
# strips .git dirs — the token never enters the image.
|
||||
- name: Pre-clone manifest deps
|
||||
env:
|
||||
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is empty"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .tenant-bundle-deps
|
||||
bash scripts/clone-manifest.sh \
|
||||
manifest.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
ws_count=$(find .tenant-bundle-deps/workspace-configs-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
org_count=$(find .tenant-bundle-deps/org-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
plugins_count=$(find .tenant-bundle-deps/plugins -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
echo "Cloned: ws=$ws_count org=$org_count plugins=$plugins_count"
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build + push platform image (inline ECR auth — mirrors the operator-host
|
||||
# approach; credentials come from GITHUB_SECRET_AWS_ACCESS_KEY_ID /
|
||||
# GITHUB_SECRET_AWS_SECRET_ACCESS_KEY in Gitea Actions).
|
||||
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
|
||||
TAG_LATEST: staging-latest
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ECR_REGISTRY="${IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
--file ./workspace-server/Dockerfile \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI platform — pending canary verify" \
|
||||
--tag "${IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${IMAGE_NAME}:${TAG_LATEST}"
|
||||
|
||||
# Build + push tenant image (Go platform + Next.js canvas in one image).
|
||||
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
|
||||
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
|
||||
TAG_LATEST: staging-latest
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
--file ./workspace-server/Dockerfile.tenant \
|
||||
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
|
||||
@@ -317,7 +317,7 @@ export function Toolbar() {
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label="Open quick help"
|
||||
aria-label="Open shortcuts and tips"
|
||||
title="Help — shortcuts & quick start"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
@@ -327,24 +327,35 @@ export function Toolbar() {
|
||||
</button>
|
||||
|
||||
{helpOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Quick start</span>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Shortcuts and tips"
|
||||
aria-modal="false"
|
||||
className="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md z-50"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Shortcuts & tips</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
aria-label="Close help dialog"
|
||||
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<HelpRow shortcut="⌘K" text="Search workspaces and jump straight into Details or Chat." />
|
||||
<HelpRow shortcut="Esc" text="Clear selection, close menus, dismiss dialogs." />
|
||||
<HelpRow shortcut="Enter" text="Zoom into selected team and select its first child node." />
|
||||
<HelpRow shortcut="Shift+Enter" text="Select the parent of the selected node." />
|
||||
<HelpRow shortcut="⌘]" text="Bring selected node forward in the z-order." />
|
||||
<HelpRow shortcut="⌘[" text="Send selected node backward in the z-order." />
|
||||
<HelpRow shortcut="Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
|
||||
<HelpRow shortcut="Palette" text="Open the template palette to deploy a new workspace." />
|
||||
<HelpRow shortcut="Right-click" text="Use node actions for duplicate, export, restart, or delete." />
|
||||
<HelpRow shortcut="Chat" text="If a task is still running, the chat tab resumes that session automatically." />
|
||||
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
|
||||
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
|
||||
<HelpRow shortcut="Dbl-click" text="On a team node: expand and zoom to show all sub-workspaces." />
|
||||
<HelpRow shortcut="Shift+click" text="Multi-select: add or remove a node from the batch selection." />
|
||||
</div>
|
||||
{/* Link to the full keyboard shortcuts dialog */}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for createMessage — the ChatMessage factory from types.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createMessage } from "../tabs/chat/types";
|
||||
|
||||
describe("createMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Freeze time so timestamp is deterministic.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z"));
|
||||
// Stub crypto.randomUUID so message IDs are deterministic.
|
||||
vi.stubGlobal("crypto", { randomUUID: vi.fn(() => "fixed-uuid-1234") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates a message with the correct role", () => {
|
||||
const userMsg = createMessage("user", "hello");
|
||||
expect(userMsg.role).toBe("user");
|
||||
|
||||
const agentMsg = createMessage("agent", "hi there");
|
||||
expect(agentMsg.role).toBe("agent");
|
||||
|
||||
const systemMsg = createMessage("system", "prompt loaded");
|
||||
expect(systemMsg.role).toBe("system");
|
||||
});
|
||||
|
||||
it("creates a message with the correct content", () => {
|
||||
const msg = createMessage("user", "Deploy the agent now");
|
||||
expect(msg.content).toBe("Deploy the agent now");
|
||||
});
|
||||
|
||||
it("sets a deterministic id via crypto.randomUUID", () => {
|
||||
const msg = createMessage("agent", "response");
|
||||
expect(msg.id).toBe("fixed-uuid-1234");
|
||||
});
|
||||
|
||||
it("sets a deterministic ISO timestamp", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(msg.timestamp).toBe("2026-05-10T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("omits attachments field when none provided", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(msg.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits attachments field when empty array is provided", () => {
|
||||
const msg = createMessage("agent", "result", []);
|
||||
expect(msg.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes attachments field when non-empty array is provided", () => {
|
||||
const atts = [{ name: "report.pdf", uri: "workspace:/docs/report.pdf" }];
|
||||
const msg = createMessage("agent", "see attached", atts);
|
||||
expect(msg.attachments).toEqual(atts);
|
||||
});
|
||||
|
||||
it("returns a frozen object (prevents accidental mutation)", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(Object.isFrozen(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a plain object with expected keys", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(Object.keys(msg).sort()).toEqual(
|
||||
["id", "role", "content", "timestamp"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for getIcon — the pure icon-selector from FilesTab/tree.ts.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getIcon } from "../tabs/FilesTab/tree";
|
||||
|
||||
describe("getIcon", () => {
|
||||
// ─── Directories ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📁 for directories regardless of extension", () => {
|
||||
expect(getIcon("src", true)).toBe("📁");
|
||||
expect(getIcon("node_modules", true)).toBe("📁");
|
||||
expect(getIcon(".claude", true)).toBe("📁");
|
||||
expect(getIcon("foo/bar/baz", true)).toBe("📁");
|
||||
});
|
||||
|
||||
it("returns 📁 even for paths that look like files", () => {
|
||||
expect(getIcon("foo.txt", true)).toBe("📁");
|
||||
expect(getIcon("script.sh", true)).toBe("📁");
|
||||
});
|
||||
|
||||
// ─── Files by extension ────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📄 for .md files", () => {
|
||||
expect(getIcon("README.md", false)).toBe("📄");
|
||||
expect(getIcon("CHANGELOG.md", false)).toBe("📄");
|
||||
expect(getIcon("docs/guide.md", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns ⚙ for .yaml and .yml files", () => {
|
||||
expect(getIcon("config.yaml", false)).toBe("⚙");
|
||||
expect(getIcon("values.yml", false)).toBe("⚙");
|
||||
expect(getIcon("deploy.yaml", false)).toBe("⚙");
|
||||
});
|
||||
|
||||
it("returns 🐍 for .py files", () => {
|
||||
expect(getIcon("main.py", false)).toBe("🐍");
|
||||
expect(getIcon("utils/helpers.py", false)).toBe("🐍");
|
||||
});
|
||||
|
||||
it("returns 💠 for .ts and .tsx files", () => {
|
||||
expect(getIcon("index.ts", false)).toBe("💠");
|
||||
expect(getIcon("Component.tsx", false)).toBe("💠");
|
||||
expect(getIcon("types.d.ts", false)).toBe("💠");
|
||||
});
|
||||
|
||||
it("returns 📜 for .js files", () => {
|
||||
expect(getIcon("bundle.js", false)).toBe("📜");
|
||||
expect(getIcon("src/index.js", false)).toBe("📜");
|
||||
});
|
||||
|
||||
it("returns {} for .json files", () => {
|
||||
expect(getIcon("package.json", false)).toBe("{}");
|
||||
expect(getIcon("config.json", false)).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 🌐 for .html files", () => {
|
||||
expect(getIcon("index.html", false)).toBe("🌐");
|
||||
expect(getIcon("templates/page.html", false)).toBe("🌐");
|
||||
});
|
||||
|
||||
it("returns 🎨 for .css files", () => {
|
||||
expect(getIcon("style.css", false)).toBe("🎨");
|
||||
expect(getIcon("src/app.css", false)).toBe("🎨");
|
||||
});
|
||||
|
||||
it("returns ▸ for .sh files", () => {
|
||||
expect(getIcon("deploy.sh", false)).toBe("▸");
|
||||
expect(getIcon("scripts/setup.sh", false)).toBe("▸");
|
||||
});
|
||||
|
||||
// ─── Fallback ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📄 for unknown extensions", () => {
|
||||
expect(getIcon("README", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("notes.txt", false)).toBe("📄");
|
||||
expect(getIcon("archive.tar.gz", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns 📄 for paths with no extension", () => {
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("README", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
});
|
||||
|
||||
// ─── Case sensitivity ──────────────────────────────────────────────────────
|
||||
|
||||
it("is case-insensitive for extension lookup", () => {
|
||||
expect(getIcon("image.PNG", false)).toBe("📄");
|
||||
expect(getIcon("data.JSON", false)).toBe("{}");
|
||||
expect(getIcon("script.SH", false)).toBe("▸");
|
||||
});
|
||||
|
||||
// ─── Nested paths ─────────────────────────────────────────────────────────
|
||||
|
||||
it("uses the leaf extension for nested paths", () => {
|
||||
expect(getIcon("src/utils/helpers.ts", false)).toBe("💠");
|
||||
expect(getIcon("docs/api.yaml", false)).toBe("⚙");
|
||||
expect(getIcon(".github/workflows/ci.yml", false)).toBe("⚙");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for yaml-utils.ts — parseYaml and toYaml pure functions.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseYaml, toYaml } from "../yaml-utils";
|
||||
import type { ConfigData } from "../form-inputs";
|
||||
|
||||
const FULL_CONFIG: ConfigData = {
|
||||
name: "my-agent",
|
||||
description: "A helpful assistant",
|
||||
version: "1.0.0",
|
||||
tier: 4,
|
||||
model: "claude-4-7",
|
||||
runtime: "claude-code",
|
||||
runtime_config: { model: "claude-4-7", required_env: ["ANTHROPIC_API_KEY"], timeout: 120 },
|
||||
effort: "medium",
|
||||
task_budget: 100,
|
||||
prompt_files: ["system.md"],
|
||||
skills: ["web-search", "code"],
|
||||
tools: ["bash"],
|
||||
a2a: { port: 8000, streaming: true, push_notifications: true },
|
||||
delegation: { retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true },
|
||||
sandbox: { backend: "docker", memory_limit: "256m", timeout: 30 },
|
||||
};
|
||||
|
||||
const MINIMAL_CONFIG: ConfigData = {
|
||||
name: "",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
tier: 1,
|
||||
model: "",
|
||||
runtime: "",
|
||||
prompt_files: [],
|
||||
skills: [],
|
||||
tools: [],
|
||||
a2a: { port: 8000, streaming: true, push_notifications: true },
|
||||
delegation: { retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true },
|
||||
sandbox: { backend: "docker", memory_limit: "256m", timeout: 30 },
|
||||
};
|
||||
|
||||
// ─── parseYaml ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseYaml", () => {
|
||||
it("returns empty object for empty input", () => {
|
||||
expect(parseYaml("")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for blank lines only", () => {
|
||||
expect(parseYaml("\n\n \n")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for comment-only input", () => {
|
||||
expect(parseYaml("# hello\n# world")).toEqual({});
|
||||
});
|
||||
|
||||
it("parses simple key-value pairs", () => {
|
||||
const result = parseYaml("name: hello\nversion: 1.0");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("trims whitespace around values", () => {
|
||||
const result = parseYaml("name: hello \nversion: 1.0 ");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("parses boolean true", () => {
|
||||
expect(parseYaml("streaming: true")).toEqual({ streaming: true });
|
||||
});
|
||||
|
||||
it("parses boolean false", () => {
|
||||
expect(parseYaml("streaming: false")).toEqual({ streaming: false });
|
||||
});
|
||||
|
||||
it("parses integer numbers", () => {
|
||||
expect(parseYaml("port: 8000\ntimeout: 120")).toEqual({ port: 8000, timeout: 120 });
|
||||
});
|
||||
|
||||
it("parses string values that look like numbers", () => {
|
||||
// Keys that have no space before colon would have been parsed as numbers
|
||||
// but since the YAML has `key: value` format, it should be string
|
||||
expect(parseYaml("model: claude-4-7")).toEqual({ model: "claude-4-7" });
|
||||
});
|
||||
|
||||
it("parses a top-level list", () => {
|
||||
const result = parseYaml("skills:\n - web-search\n - code");
|
||||
expect(result).toEqual({ skills: ["web-search", "code"] });
|
||||
});
|
||||
|
||||
it("parses a top-level object", () => {
|
||||
const result = parseYaml("a2a:\n port: 8000\n streaming: true");
|
||||
expect(result).toEqual({ a2a: { port: 8000, streaming: true } });
|
||||
});
|
||||
|
||||
it("skips blank lines within content", () => {
|
||||
const result = parseYaml("name: hello\n\nversion: 1.0\n\n");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("skips comment lines within content", () => {
|
||||
const result = parseYaml("name: hello\n# this is a comment\nversion: 1.0");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("parses a 2-level nested list (env.required pattern)", () => {
|
||||
const result = parseYaml("env:\n required:\n - ANTHROPIC_API_KEY\n - OPENAI_API_KEY");
|
||||
expect(result).toEqual({ env: { required: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] } });
|
||||
});
|
||||
|
||||
it("parses empty list marker `[]`", () => {
|
||||
const result = parseYaml("prompt_files: []");
|
||||
expect(result).toEqual({ prompt_files: [] });
|
||||
});
|
||||
|
||||
it("handles multiple mixed structures in one document", () => {
|
||||
const yaml = `name: test-agent
|
||||
version: 1.0.0
|
||||
tier: 4
|
||||
runtime: claude-code
|
||||
skills:
|
||||
- web-search
|
||||
a2a:
|
||||
port: 8000
|
||||
streaming: true`;
|
||||
const result = parseYaml(yaml);
|
||||
expect(result).toEqual({
|
||||
name: "test-agent",
|
||||
version: "1.0.0",
|
||||
tier: 4,
|
||||
runtime: "claude-code",
|
||||
skills: ["web-search"],
|
||||
a2a: { port: 8000, streaming: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unrecognised top-level lines as-is (skipped)", () => {
|
||||
// Lines that don't match the pattern are skipped
|
||||
const result = parseYaml("name: hello\n[invalid line]\nversion: 1.0");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── toYaml ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("toYaml", () => {
|
||||
it("produces output for minimal config (required fields only)", () => {
|
||||
const out = toYaml(MINIMAL_CONFIG);
|
||||
// skills: [] and tools: [] are always emitted
|
||||
expect(out).toContain("version: 1.0.0");
|
||||
expect(out).toContain("tier: 1");
|
||||
expect(out).toContain("skills: []");
|
||||
expect(out).toContain("tools: []");
|
||||
expect(out).toContain("a2a:");
|
||||
expect(out).toContain("delegation:");
|
||||
expect(out).toContain("sandbox:");
|
||||
});
|
||||
|
||||
it("writes name and description fields", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, name: "my-agent", description: "desc" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("name: my-agent");
|
||||
expect(out).toContain("description: desc");
|
||||
});
|
||||
|
||||
it("writes version and tier", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, tier: 4 };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("version: 1.0.0");
|
||||
expect(out).toContain("tier: 4");
|
||||
});
|
||||
|
||||
it("writes runtime with a blank line separator before it", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, runtime: "claude-code" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("runtime: claude-code");
|
||||
});
|
||||
|
||||
it("writes runtime_config as a nested block", () => {
|
||||
const cfg: ConfigData = {
|
||||
...MINIMAL_CONFIG,
|
||||
runtime: "claude-code",
|
||||
runtime_config: { model: "claude-4-7", required_env: ["KEY"], timeout: 120 },
|
||||
};
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("runtime_config:");
|
||||
expect(out).toContain(" model: claude-4-7");
|
||||
expect(out).toContain(" required_env:");
|
||||
expect(out).toContain(" - KEY");
|
||||
expect(out).toContain(" timeout: 120");
|
||||
});
|
||||
|
||||
it("omits runtime_config when empty", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, runtime: "claude-code" };
|
||||
const out = toYaml(cfg);
|
||||
// runtime_config key should not appear
|
||||
expect(out).not.toContain("runtime_config:");
|
||||
});
|
||||
|
||||
it("writes effort when set", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, effort: "high" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("effort: high");
|
||||
});
|
||||
|
||||
it("omits effort when empty string", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, effort: "" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).not.toContain("effort:");
|
||||
});
|
||||
|
||||
it("writes task_budget when positive", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, task_budget: 100 };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("task_budget: 100");
|
||||
});
|
||||
|
||||
it("omits task_budget when zero", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, task_budget: 0 };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).not.toContain("task_budget:");
|
||||
});
|
||||
|
||||
it("writes prompt_files as a list block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, prompt_files: ["system.md", "ethics.md"] };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("prompt_files:");
|
||||
expect(out).toContain(" - system.md");
|
||||
expect(out).toContain(" - ethics.md");
|
||||
});
|
||||
|
||||
it("writes skills as a list block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, skills: ["web-search", "code"] };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("skills:");
|
||||
expect(out).toContain(" - web-search");
|
||||
expect(out).toContain(" - code");
|
||||
});
|
||||
|
||||
it("writes tools as a list block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, tools: ["bash", "read"] };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("tools:");
|
||||
expect(out).toContain(" - bash");
|
||||
expect(out).toContain(" - read");
|
||||
});
|
||||
|
||||
it("writes a2a as a nested block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, a2a: { port: 9000, streaming: false, push_notifications: false } };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("a2a:");
|
||||
expect(out).toContain(" port: 9000");
|
||||
expect(out).toContain(" streaming: false");
|
||||
expect(out).toContain(" push_notifications: false");
|
||||
});
|
||||
|
||||
it("writes delegation as a nested block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, delegation: { retry_attempts: 5, retry_delay: 10, timeout: 60, escalate: false } };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("delegation:");
|
||||
expect(out).toContain(" retry_attempts: 5");
|
||||
expect(out).toContain(" retry_delay: 10");
|
||||
expect(out).toContain(" timeout: 60");
|
||||
expect(out).toContain(" escalate: false");
|
||||
});
|
||||
|
||||
it("writes sandbox backend block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, sandbox: { backend: "aws-lambda", memory_limit: "512m", timeout: 15 } };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("sandbox:");
|
||||
expect(out).toContain(" backend: aws-lambda");
|
||||
expect(out).toContain(" memory_limit: 512m");
|
||||
expect(out).toContain(" timeout: 15");
|
||||
});
|
||||
|
||||
it("omits empty/null/undefined fields entirely", () => {
|
||||
const cfg: ConfigData = {
|
||||
...MINIMAL_CONFIG,
|
||||
name: "test",
|
||||
model: "", // omitted
|
||||
description: "", // omitted
|
||||
};
|
||||
const out = toYaml(cfg);
|
||||
expect(out).not.toContain("model:");
|
||||
expect(out).not.toContain("description:");
|
||||
expect(out).toContain("name: test");
|
||||
});
|
||||
|
||||
it("produces a trailing newline", () => {
|
||||
const out = toYaml(MINIMAL_CONFIG);
|
||||
expect(out.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips FULL_CONFIG through parse → toYaml → parse", () => {
|
||||
// parseYaml produces plain Record, so a2a/delegation/sandbox
|
||||
// come out as objects — toYaml handles them via the cast.
|
||||
const round = parseYaml(toYaml(FULL_CONFIG));
|
||||
expect(round).toMatchObject({
|
||||
name: "my-agent",
|
||||
description: "A helpful assistant",
|
||||
version: "1.0.0",
|
||||
tier: 4,
|
||||
runtime: "claude-code",
|
||||
effort: "medium",
|
||||
task_budget: 100,
|
||||
prompt_files: ["system.md"],
|
||||
skills: ["web-search", "code"],
|
||||
tools: ["bash"],
|
||||
});
|
||||
expect(round.a2a).toMatchObject({ port: 8000, streaming: true, push_notifications: true });
|
||||
expect(round.delegation).toMatchObject({ retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true });
|
||||
expect(round.sandbox).toMatchObject({ backend: "docker", memory_limit: "256m", timeout: 30 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for cssVar — maps ColorToken to a CSS variable string.
|
||||
*
|
||||
* Exists for the rare case where an inline style="" or SVG fill needs
|
||||
* a token value rather than a Tailwind class. The returned var(--color-foo)
|
||||
* string follows the live theme without re-renders.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cssVar } from "../theme";
|
||||
import type { ColorToken } from "../theme";
|
||||
|
||||
describe("cssVar", () => {
|
||||
it("returns 'var(--color-surface)' for 'surface'", () => {
|
||||
expect(cssVar("surface")).toBe("var(--color-surface)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-ink)' for 'ink'", () => {
|
||||
expect(cssVar("ink")).toBe("var(--color-ink)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-accent)' for 'accent'", () => {
|
||||
expect(cssVar("accent")).toBe("var(--color-accent)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-good)' for 'good'", () => {
|
||||
expect(cssVar("good")).toBe("var(--color-good)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-bad)' for 'bad'", () => {
|
||||
expect(cssVar("bad")).toBe("var(--color-bad)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-warn)' for 'warn'", () => {
|
||||
expect(cssVar("warn")).toBe("var(--color-warn)");
|
||||
});
|
||||
|
||||
it("handles all surface variants", () => {
|
||||
const surfaces: ColorToken[] = ["surface", "surface-elevated", "surface-sunken", "surface-card"];
|
||||
for (const t of surfaces) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles all ink variants", () => {
|
||||
const inks: ColorToken[] = ["ink", "ink-mid", "ink-soft", "ink-mute", "ink-dim"];
|
||||
for (const t of inks) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles always-dark tokens", () => {
|
||||
const dark: ColorToken[] = ["bg", "bg-elev", "bg-card", "line-strong", "accent-dim", "plasma"];
|
||||
for (const t of dark) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a pure function — same input always returns same output", () => {
|
||||
const tokens: ColorToken[] = ["surface", "accent", "good", "bad", "warm"];
|
||||
for (const t of tokens) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for resolveRuntime — the template-id → runtime-name mapper in deploy-preflight.ts.
|
||||
*
|
||||
* Lives in lib/__tests__/ alongside deploy-preflight.test.ts so the
|
||||
* two share the same describe block convention and the fixture types
|
||||
* are close at hand. Separate file keeps the deploy-preflight fixture
|
||||
* count bounded.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveRuntime } from "../deploy-preflight";
|
||||
|
||||
describe("resolveRuntime", () => {
|
||||
describe("explicit runtime-map entries", () => {
|
||||
it('maps "langgraph" to "langgraph"', () => {
|
||||
expect(resolveRuntime("langgraph")).toBe("langgraph");
|
||||
});
|
||||
|
||||
it('maps "claude-code-default" to "claude-code"', () => {
|
||||
expect(resolveRuntime("claude-code-default")).toBe("claude-code");
|
||||
});
|
||||
|
||||
it('maps "openclaw" to "openclaw"', () => {
|
||||
expect(resolveRuntime("openclaw")).toBe("openclaw");
|
||||
});
|
||||
|
||||
it('maps "deepagents" to "deepagents"', () => {
|
||||
expect(resolveRuntime("deepagents")).toBe("deepagents");
|
||||
});
|
||||
|
||||
it('maps "crewai" to "crewai"', () => {
|
||||
expect(resolveRuntime("crewai")).toBe("crewai");
|
||||
});
|
||||
|
||||
it('maps "autogen" to "autogen"', () => {
|
||||
expect(resolveRuntime("autogen")).toBe("autogen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity fallback for modern template ids", () => {
|
||||
it("returns the id unchanged when not in the map", () => {
|
||||
expect(resolveRuntime("hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips trailing -default suffix as fallback", () => {
|
||||
expect(resolveRuntime("hermes-default")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips -default only when it is the suffix", () => {
|
||||
// "default-something" should NOT strip
|
||||
expect(resolveRuntime("default-langgraph")).toBe("default-langgraph");
|
||||
});
|
||||
|
||||
it("returns the id unchanged when id has no -default suffix", () => {
|
||||
expect(resolveRuntime("gemini-cli")).toBe("gemini-cli");
|
||||
});
|
||||
|
||||
it("handles custom template ids from community templates", () => {
|
||||
expect(resolveRuntime("my-custom-template")).toBe("my-custom-template");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty string", () => {
|
||||
// Falls through to the replace branch
|
||||
expect(resolveRuntime("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles id that is just '-default'", () => {
|
||||
expect(resolveRuntime("-default")).toBe("");
|
||||
});
|
||||
|
||||
it("multiple -default suffixes only strips the last one", () => {
|
||||
// The JS replace only replaces the first match by default
|
||||
expect(resolveRuntime("claude-code-default-default")).toBe("claude-code-default");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for runtimeProfiles.ts — getRuntimeProfile and provisionTimeoutForRuntime.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRuntimeProfile,
|
||||
provisionTimeoutForRuntime,
|
||||
DEFAULT_RUNTIME_PROFILE,
|
||||
RUNTIME_PROFILES,
|
||||
} from "../runtimeProfiles";
|
||||
|
||||
describe("getRuntimeProfile", () => {
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when runtime is undefined and no overrides", () => {
|
||||
const result = getRuntimeProfile(undefined);
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when runtime is empty string", () => {
|
||||
const result = getRuntimeProfile("");
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("falls back to DEFAULT_RUNTIME_PROFILE for an unknown runtime", () => {
|
||||
const result = getRuntimeProfile("unknown-lang");
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when RUNTIME_PROFILES is empty (current state)", () => {
|
||||
// RUNTIME_PROFILES is currently {} — verify the empty-map path works
|
||||
expect(RUNTIME_PROFILES).toEqual({});
|
||||
const result = getRuntimeProfile("claude-code");
|
||||
expect(result.provisionTimeoutMs).toBe(120_000);
|
||||
});
|
||||
|
||||
it("uses overrides.provisionTimeoutMs when provided (highest priority)", () => {
|
||||
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 300_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(300_000);
|
||||
});
|
||||
|
||||
it("overrides wins over RUNTIME_PROFILES entry", () => {
|
||||
// Even if RUNTIME_PROFILES had an entry, overrides take priority
|
||||
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 999_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(999_000);
|
||||
});
|
||||
|
||||
it("uses overrides even when runtime is undefined", () => {
|
||||
const result = getRuntimeProfile(undefined, { provisionTimeoutMs: 60_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("returns Required<Pick> — always has provisionTimeoutMs", () => {
|
||||
// The return type is guaranteed non-nullable
|
||||
const result = getRuntimeProfile(undefined);
|
||||
expect(typeof result.provisionTimeoutMs).toBe("number");
|
||||
expect(result.provisionTimeoutMs).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provisionTimeoutForRuntime", () => {
|
||||
it("returns DEFAULT_RUNTIME_PROFILE value when no runtime or overrides", () => {
|
||||
expect(provisionTimeoutForRuntime(undefined)).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("")).toBe(120_000);
|
||||
});
|
||||
|
||||
it("returns overrides value when overrides provided", () => {
|
||||
expect(provisionTimeoutForRuntime("claude-code", { provisionTimeoutMs: 90_000 })).toBe(90_000);
|
||||
});
|
||||
|
||||
it("returns 120_000 for any unknown runtime", () => {
|
||||
expect(provisionTimeoutForRuntime("langgraph")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("crewai")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("some-new-runtime")).toBe(120_000);
|
||||
});
|
||||
|
||||
it("convenience: same as getRuntimeProfile().provisionTimeoutMs", () => {
|
||||
const cases: Array<[string | undefined, { provisionTimeoutMs?: number } | undefined]> = [
|
||||
[undefined, undefined],
|
||||
["claude-code", undefined],
|
||||
["langgraph", { provisionTimeoutMs: 500_000 }],
|
||||
[undefined, { provisionTimeoutMs: 45_000 }],
|
||||
];
|
||||
for (const [runtime, overrides] of cases) {
|
||||
const profile = getRuntimeProfile(runtime, overrides);
|
||||
const direct = provisionTimeoutForRuntime(runtime, overrides);
|
||||
expect(direct).toBe(profile.provisionTimeoutMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for statusDotClass — maps a workspace status string to the
|
||||
* CSS tailwind class used on the status indicator dot.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { statusDotClass, TIER_CONFIG, COMM_TYPE_LABELS } from "../design-tokens";
|
||||
|
||||
describe("statusDotClass", () => {
|
||||
it('returns "bg-emerald-400" for "online"', () => {
|
||||
expect(statusDotClass("online")).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it('returns "bg-zinc-500" for "offline"', () => {
|
||||
expect(statusDotClass("offline")).toBe("bg-zinc-500");
|
||||
});
|
||||
|
||||
it('returns "bg-indigo-400" for "paused"', () => {
|
||||
expect(statusDotClass("paused")).toBe("bg-indigo-400");
|
||||
});
|
||||
|
||||
it('returns "bg-amber-400" for "degraded"', () => {
|
||||
expect(statusDotClass("degraded")).toBe("bg-amber-400");
|
||||
});
|
||||
|
||||
it('returns "bg-red-400" for "failed"', () => {
|
||||
expect(statusDotClass("failed")).toBe("bg-red-400");
|
||||
});
|
||||
|
||||
it('returns "bg-sky-400 motion-safe:animate-pulse" for "provisioning"', () => {
|
||||
expect(statusDotClass("provisioning")).toBe("bg-sky-400 motion-safe:animate-pulse");
|
||||
});
|
||||
|
||||
it('returns "bg-amber-300" for "not_configured"', () => {
|
||||
expect(statusDotClass("not_configured")).toBe("bg-amber-300");
|
||||
});
|
||||
|
||||
it("falls back to bg-zinc-500 for unknown status strings", () => {
|
||||
expect(statusDotClass("unknown")).toBe("bg-zinc-500");
|
||||
expect(statusDotClass("")).toBe("bg-zinc-500");
|
||||
expect(statusDotClass("ONLINE")).toBe("bg-zinc-500"); // case-sensitive
|
||||
expect(statusDotClass(" online")).toBe("bg-zinc-500"); // whitespace-sensitive
|
||||
expect(statusDotClass("online\n")).toBe("bg-zinc-500");
|
||||
});
|
||||
|
||||
it("is a pure function — same input always returns same output", () => {
|
||||
const result = statusDotClass("online");
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(statusDotClass("online")).toBe(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── TIER_CONFIG ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TIER_CONFIG", () => {
|
||||
it("has entries for all four tier levels", () => {
|
||||
expect(TIER_CONFIG).toHaveProperty(1);
|
||||
expect(TIER_CONFIG).toHaveProperty(2);
|
||||
expect(TIER_CONFIG).toHaveProperty(3);
|
||||
expect(TIER_CONFIG).toHaveProperty(4);
|
||||
});
|
||||
|
||||
it("each tier has label, color, and border fields", () => {
|
||||
for (const tier of [1, 2, 3, 4]) {
|
||||
expect(TIER_CONFIG[tier]).toHaveProperty("label");
|
||||
expect(TIER_CONFIG[tier]).toHaveProperty("color");
|
||||
expect(TIER_CONFIG[tier]).toHaveProperty("border");
|
||||
}
|
||||
});
|
||||
|
||||
it("tier labels match expected values", () => {
|
||||
expect(TIER_CONFIG[1].label).toBe("T1");
|
||||
expect(TIER_CONFIG[2].label).toBe("T2");
|
||||
expect(TIER_CONFIG[3].label).toBe("T3");
|
||||
expect(TIER_CONFIG[4].label).toBe("T4");
|
||||
});
|
||||
|
||||
it("is immutable at runtime — same key always returns same shape", () => {
|
||||
const result = TIER_CONFIG[2];
|
||||
expect(TIER_CONFIG[2]).toBe(result);
|
||||
});
|
||||
});
|
||||
|
||||
// ── COMM_TYPE_LABELS ────────────────────────────────────────────────────────
|
||||
|
||||
describe("COMM_TYPE_LABELS", () => {
|
||||
it("has labels for all known communication types", () => {
|
||||
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_send");
|
||||
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_receive");
|
||||
expect(COMM_TYPE_LABELS).toHaveProperty("task_update");
|
||||
});
|
||||
|
||||
it("labels are non-empty strings", () => {
|
||||
for (const key of Object.keys(COMM_TYPE_LABELS)) {
|
||||
expect(typeof COMM_TYPE_LABELS[key]).toBe("string");
|
||||
expect(COMM_TYPE_LABELS[key].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a static map — same key always returns same label", () => {
|
||||
expect(COMM_TYPE_LABELS["a2a_send"]).toBe("sent");
|
||||
expect(COMM_TYPE_LABELS["a2a_receive"]).toBe("received");
|
||||
expect(COMM_TYPE_LABELS["task_update"]).toBe("task update");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for readThemeCookie — parses a cookie value into a ThemePreference.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readThemeCookie } from "../theme-cookie";
|
||||
|
||||
describe("readThemeCookie", () => {
|
||||
it('returns "light" when cookie value is "light"', () => {
|
||||
expect(readThemeCookie("light")).toBe("light");
|
||||
});
|
||||
|
||||
it('returns "dark" when cookie value is "dark"', () => {
|
||||
expect(readThemeCookie("dark")).toBe("dark");
|
||||
});
|
||||
|
||||
it('returns "system" when cookie value is "system"', () => {
|
||||
expect(readThemeCookie("system")).toBe("system");
|
||||
});
|
||||
|
||||
it('returns "system" for undefined', () => {
|
||||
expect(readThemeCookie(undefined)).toBe("system");
|
||||
});
|
||||
|
||||
it('returns "system" for empty string', () => {
|
||||
expect(readThemeCookie("")).toBe("system");
|
||||
});
|
||||
|
||||
it('returns "system" for any non-matching value', () => {
|
||||
expect(readThemeCookie("auto")).toBe("system");
|
||||
expect(readThemeCookie("dark-mode")).toBe("system");
|
||||
expect(readThemeCookie("DARK")).toBe("system"); // case-sensitive
|
||||
expect(readThemeCookie("light\n")).toBe("system"); // whitespace-sensitive
|
||||
expect(readThemeCookie(" system ")).toBe("system");
|
||||
expect(readThemeCookie("null")).toBe("system");
|
||||
expect(readThemeCookie("0")).toBe("system");
|
||||
});
|
||||
|
||||
it("is pure — same input always returns same output", () => {
|
||||
const inputs = ["light", "dark", "system", undefined, ""];
|
||||
for (const input of inputs) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(readThemeCookie(input)).toBe(readThemeCookie(input));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for deriveWsBaseUrl — WebSocket base URL derivation from env / window.location.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { deriveWsBaseUrl } from "../ws-url";
|
||||
|
||||
const ORIGINAL_WS = process.env.NEXT_PUBLIC_WS_URL;
|
||||
const ORIGINAL_PLATFORM = process.env.NEXT_PUBLIC_PLATFORM_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "");
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (ORIGINAL_WS !== undefined) vi.stubEnv("NEXT_PUBLIC_WS_URL", ORIGINAL_WS);
|
||||
else delete process.env.NEXT_PUBLIC_WS_URL;
|
||||
if (ORIGINAL_PLATFORM !== undefined) vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", ORIGINAL_PLATFORM);
|
||||
else delete process.env.NEXT_PUBLIC_PLATFORM_URL;
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — NEXT_PUBLIC_WS_URL (priority 1)", () => {
|
||||
it("uses NEXT_PUBLIC_WS_URL when set", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com/ws");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
|
||||
it("strips trailing /ws suffix from NEXT_PUBLIC_WS_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com/ws");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
|
||||
it("uses ws:// for HTTP NEXT_PUBLIC_WS_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "ws://localhost:8080/ws");
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:8080");
|
||||
});
|
||||
|
||||
it("wins over NEXT_PUBLIC_PLATFORM_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com");
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://platform.example.com");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
|
||||
it("wins over window.location", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com");
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — NEXT_PUBLIC_PLATFORM_URL (priority 2)", () => {
|
||||
it("derives ws:// from http:// platform URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://localhost:8080");
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:8080");
|
||||
});
|
||||
|
||||
it("derives wss:// from https:// platform URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform.example.com");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://platform.example.com");
|
||||
});
|
||||
|
||||
it("preserves non-standard ports", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://localhost:9000");
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:9000");
|
||||
});
|
||||
|
||||
it("wins over window.location", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform.example.com");
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://platform.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — window.location (priority 3)", () => {
|
||||
it("uses wss:// when page is served over HTTPS", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://canvas.example.com");
|
||||
});
|
||||
|
||||
it("uses ws:// when page is served over HTTP", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "http:", host: "localhost:3000" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:3000");
|
||||
});
|
||||
|
||||
it("includes the host with port", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com:8443" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://canvas.example.com:8443");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — fallback (priority 4)", () => {
|
||||
it("falls back to localhost when no env vars or window is unavailable", () => {
|
||||
// process.env is empty (already stubbed), window is not stubbed but we
|
||||
// can't remove it entirely in jsdom — the function checks typeof window
|
||||
// which is always defined. Since we have no env vars, it falls through
|
||||
// to the window branch; we test the final fallback by stubbing window
|
||||
// location to undefined (not possible in jsdom — skip this edge case).
|
||||
// The test below verifies the no-env-var path works.
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "http:", host: "localhost:3000" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:3000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — protocol derivation", () => {
|
||||
it("derives ws:// from http:// and keeps it", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://platform:8080");
|
||||
expect(deriveWsBaseUrl()).toMatch(/^ws:/);
|
||||
});
|
||||
|
||||
it("derives wss:// from https:// and keeps it", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform:8080");
|
||||
expect(deriveWsBaseUrl()).toMatch(/^wss:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for pure utility functions in canvas-topology.ts:
|
||||
* sortParentsBeforeChildren, defaultChildSlot, childSlotInGrid,
|
||||
* parentMinSize, parentMinSizeFromChildren.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
sortParentsBeforeChildren,
|
||||
defaultChildSlot,
|
||||
childSlotInGrid,
|
||||
parentMinSize,
|
||||
parentMinSizeFromChildren,
|
||||
} from "../canvas-topology";
|
||||
|
||||
// ─── sortParentsBeforeChildren ─────────────────────────────────────────────────
|
||||
|
||||
describe("sortParentsBeforeChildren", () => {
|
||||
it("returns [] for empty input", () => {
|
||||
expect(sortParentsBeforeChildren([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single node unchanged", () => {
|
||||
const nodes = [{ id: "a", parentId: undefined }];
|
||||
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
|
||||
});
|
||||
|
||||
it("places parent before child", () => {
|
||||
// Deliberately reversed so naive iteration would place child first
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "parent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result[0].id).toBe("parent");
|
||||
expect(result[1].id).toBe("child");
|
||||
});
|
||||
|
||||
it("places grandparent before parent before child (deep chain)", () => {
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "grandchild", parentId: "child" },
|
||||
{ id: "parent", parentId: "grandparent" },
|
||||
{ id: "grandparent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
const ids = result.map((n) => n.id);
|
||||
expect(ids).toEqual(["grandparent", "parent", "child", "grandchild"]);
|
||||
});
|
||||
|
||||
it("siblings share the same parent", () => {
|
||||
const nodes = [
|
||||
{ id: "b", parentId: "a" },
|
||||
{ id: "a", parentId: undefined },
|
||||
{ id: "c", parentId: "a" },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result[0].id).toBe("a");
|
||||
expect(new Set(result.slice(1).map((n) => n.id))).toEqual(new Set(["b", "c"]));
|
||||
});
|
||||
|
||||
it("no-ops when children already precede parents", () => {
|
||||
// Already sorted — output should be in the same order
|
||||
const nodes = [
|
||||
{ id: "root", parentId: undefined },
|
||||
{ id: "child", parentId: "root" },
|
||||
];
|
||||
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
|
||||
});
|
||||
|
||||
it("handles orphan nodes (no parentId)", () => {
|
||||
const nodes = [{ id: "a" }, { id: "b" }];
|
||||
expect(sortParentsBeforeChildren(nodes).map((n) => n.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns a new array (does not mutate input)", () => {
|
||||
const nodes = [{ id: "child", parentId: "parent" }, { id: "parent", parentId: undefined }];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result).not.toBe(nodes);
|
||||
});
|
||||
|
||||
it("deduplicates already-visited nodes", () => {
|
||||
// Child's parent is also in the list — visited guard prevents loops
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "parent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["parent", "child"]);
|
||||
});
|
||||
|
||||
it("does not crash when parentId references a missing node", () => {
|
||||
const nodes = [
|
||||
{ id: "orphan", parentId: "ghost" },
|
||||
{ id: "root", parentId: undefined },
|
||||
];
|
||||
// Missing parent is skipped; orphan placed after root
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["root", "orphan"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── defaultChildSlot ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("defaultChildSlot — 2-column grid (240×130 cards)", () => {
|
||||
it("slot 0 → column 0, row 0", () => {
|
||||
const s = defaultChildSlot(0);
|
||||
expect(s).toEqual({ x: 16, y: 130 });
|
||||
});
|
||||
|
||||
it("slot 1 → column 1, row 0", () => {
|
||||
const s = defaultChildSlot(1);
|
||||
expect(s.x).toBe(16 + 240 + 14); // PARENT_SIDE_PADDING + CHILD_DEFAULT_WIDTH + CHILD_GUTTER
|
||||
expect(s.y).toBe(130);
|
||||
});
|
||||
|
||||
it("slot 2 → column 0, row 1", () => {
|
||||
const s = defaultChildSlot(2);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + 130 + 14); // row 0 height + gutter
|
||||
});
|
||||
|
||||
it("slot 3 → column 1, row 1", () => {
|
||||
const s = defaultChildSlot(3);
|
||||
expect(s.x).toBe(16 + 240 + 14);
|
||||
expect(s.y).toBe(130 + 130 + 14);
|
||||
});
|
||||
|
||||
it("slot 4 → column 0, row 2", () => {
|
||||
const s = defaultChildSlot(4);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + (130 + 14) * 2); // row 1 end + gutter
|
||||
});
|
||||
});
|
||||
|
||||
// ─── childSlotInGrid ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("childSlotInGrid — variable-size siblings", () => {
|
||||
it("empty siblingSizes returns side-padded position", () => {
|
||||
const s = childSlotInGrid(0, []);
|
||||
expect(s).toEqual({ x: 16, y: 130 });
|
||||
});
|
||||
|
||||
it("slot 0 in uniform-size siblings matches defaultChildSlot", () => {
|
||||
const sizes = [{ width: 240, height: 130 }, { width: 240, height: 130 }];
|
||||
const s = childSlotInGrid(0, sizes);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130);
|
||||
});
|
||||
|
||||
it("taller sibling bumps next row down", () => {
|
||||
// Column width = max(200, 240) = 240; row 0 height = max(300, 130) = 300
|
||||
const sizes = [{ width: 200, height: 300 }, { width: 240, height: 130 }];
|
||||
const slot1 = childSlotInGrid(1, sizes);
|
||||
// Slot 1 is in column 1, row 0; x = 16 + 1*(240+14)
|
||||
expect(slot1.x).toBe(16 + 240 + 14);
|
||||
expect(slot1.y).toBe(130);
|
||||
// Slot 2 (col 0, row 1) — y must include row 0 height + gutter
|
||||
const slot2 = childSlotInGrid(2, sizes);
|
||||
expect(slot2.x).toBe(16);
|
||||
expect(slot2.y).toBe(130 + 300 + 14);
|
||||
});
|
||||
|
||||
it("colW is the maximum sibling width, not the column of the target slot", () => {
|
||||
// Column width is always the max — slot at col 0 uses colW of wider col 1 sibling
|
||||
const sizes = [{ width: 100, height: 100 }, { width: 300, height: 100 }];
|
||||
const slot0 = childSlotInGrid(0, sizes);
|
||||
expect(slot0.x).toBe(16); // col 0
|
||||
// x for col 1 would be 16 + 300 + 14 = 330
|
||||
const slot1 = childSlotInGrid(1, sizes);
|
||||
expect(slot1.x).toBe(16 + 300 + 14);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parentMinSize ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parentMinSize — uniform-size children", () => {
|
||||
it("0 children → compact default (210×120)", () => {
|
||||
expect(parentMinSize(0)).toEqual({ width: 210, height: 120 });
|
||||
});
|
||||
|
||||
it("1 child → 1 col, 1 row", () => {
|
||||
const s = parentMinSize(1);
|
||||
// width = 16*2 + 1*240 + 0 = 272; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 240);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
});
|
||||
|
||||
it("2 children → 2 cols, 1 row", () => {
|
||||
const s = parentMinSize(2);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
});
|
||||
|
||||
it("3 children → 2 cols, 2 rows", () => {
|
||||
const s = parentMinSize(3);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
// height = 130 + 2*130 + 1*14 + 16 = 416
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
});
|
||||
|
||||
it("4 children → 2 cols, 2 rows (full grid)", () => {
|
||||
const s = parentMinSize(4);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
});
|
||||
|
||||
it("5 children → 2 cols, 3 rows", () => {
|
||||
const s = parentMinSize(5);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 3 * 130 + 2 * 14 + 16);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parentMinSizeFromChildren ────────────────────────────────────────────────
|
||||
|
||||
describe("parentMinSizeFromChildren — variable-size children", () => {
|
||||
it("empty array → compact default (210×120)", () => {
|
||||
expect(parentMinSizeFromChildren([])).toEqual({ width: 210, height: 120 });
|
||||
});
|
||||
|
||||
it("single child matches defaultChildSlot bounding box", () => {
|
||||
const s = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
|
||||
// cols=1, rows=1, colW=240
|
||||
expect(s.width).toBe(16 * 2 + 240); // 272
|
||||
expect(s.height).toBe(130 + 130 + 16); // 276
|
||||
});
|
||||
|
||||
it("two equal-width children → same as parentMinSize(2)", () => {
|
||||
const fromChildren = parentMinSizeFromChildren([
|
||||
{ width: 240, height: 130 },
|
||||
{ width: 240, height: 130 },
|
||||
]);
|
||||
expect(fromChildren.width).toBe(parentMinSize(2).width);
|
||||
expect(fromChildren.height).toBe(parentMinSize(2).height);
|
||||
});
|
||||
|
||||
it("taller child increases height", () => {
|
||||
const tall = parentMinSizeFromChildren([{ width: 240, height: 400 }]);
|
||||
const short = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
|
||||
expect(tall.height).toBeGreaterThan(short.height);
|
||||
});
|
||||
|
||||
it("wider child increases width", () => {
|
||||
const wide = parentMinSizeFromChildren([{ width: 500, height: 130 }]);
|
||||
const narrow = parentMinSizeFromChildren([{ width: 200, height: 130 }]);
|
||||
expect(wide.width).toBeGreaterThan(narrow.width);
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,7 @@ PR: `fix/ink-soft-wcag-contrast`.
|
||||
- Arrow keys move selected node 10px (50px with Shift) — keyboard node drag (PR #182) ✅
|
||||
- `Cmd/Ctrl+Arrow` resize selected node (↑↓ height, ←→ width, 10px, Shift 2px) ✅
|
||||
- Hierarchy navigation (Enter/Shift+Enter), z-order (Cmd+]/[), zoom-to-team (Z) ✅
|
||||
- Toolbar help dialog ("Shortcuts & tips") documents all shortcuts + mouse interactions ✅
|
||||
|
||||
### Focus Management ✅ (strong)
|
||||
- Skip link → `#canvas-main` ✅
|
||||
|
||||
@@ -8,7 +8,6 @@ package handlers
|
||||
// POST /admin/plugin-updates/:id/apply — apply a queued drift update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
@@ -1262,4 +1262,3 @@ func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,10 @@ func (h *PluginsHandler) WithInstanceIDLookup(lookup InstanceIDLookup) *PluginsH
|
||||
|
||||
// Sources returns the underlying plugin source registry. Used by main.go to
|
||||
// pass the same registry to the drift sweeper so both share resolver state.
|
||||
func (h *PluginsHandler) Sources() plugins.SourceResolver {
|
||||
// Returns the narrow pluginSources interface so callers receive only the
|
||||
// methods they need (Register, Resolve, Schemes), not the full SourceResolver
|
||||
// contract with Fetch.
|
||||
func (h *PluginsHandler) Sources() pluginSources {
|
||||
return h.sources
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ func (h *WorkspaceHandler) resolveAgentURLForRestartSignal(ctx context.Context,
|
||||
// Try Redis cache first.
|
||||
agentURL, err := db.GetCachedURL(ctx, workspaceID)
|
||||
if err == nil && agentURL != "" {
|
||||
return rewriteForDocker(agentURL, workspaceID), nil
|
||||
return h.rewriteForDocker(agentURL, workspaceID), nil
|
||||
}
|
||||
|
||||
// Cache miss — fall back to DB.
|
||||
@@ -136,13 +136,13 @@ func (h *WorkspaceHandler) resolveAgentURLForRestartSignal(ctx context.Context,
|
||||
}
|
||||
agentURL = *urlNullable
|
||||
_ = db.CacheURL(ctx, workspaceID, agentURL)
|
||||
return rewriteForDocker(agentURL, workspaceID), nil
|
||||
return h.rewriteForDocker(agentURL, workspaceID), nil
|
||||
}
|
||||
|
||||
// rewriteForDocker rewrites a 127.0.0.1 agent URL to the Docker-DNS form
|
||||
// when the platform is running inside a Docker container. When platform is
|
||||
// on the host (non-Docker), 127.0.0.1 IS the host and the original URL works.
|
||||
func rewriteForDocker(agentURL, workspaceID string) string {
|
||||
func (h *WorkspaceHandler) rewriteForDocker(agentURL, workspaceID string) string {
|
||||
if platformInDocker && h.provisioner != nil {
|
||||
// Only rewrite if the URL points to localhost (the ephemeral port
|
||||
// binding the container published to the host). Internal Docker
|
||||
|
||||
@@ -97,10 +97,10 @@ func TestRewriteForDocker_LocalhostUrlRewritten(t *testing.T) {
|
||||
// TestResolveAgentURLForRestartSignal_CacheHit verifies that a Redis-cached
|
||||
// URL is returned without hitting the DB.
|
||||
func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
|
||||
mockDB, mock := setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
|
||||
_ = setupTestDB(t) // db.DB must be set before setupTestRedisWithURL
|
||||
_ = setupTestRedisWithURL(t, "http://cached.internal:9000/agent")
|
||||
|
||||
h := newHandlerWithTestDepsWithDB(t, mockDB)
|
||||
h := newHandlerWithTestDeps(t)
|
||||
|
||||
// Redis cache hit → DB should NOT be queried
|
||||
url, err := h.resolveAgentURLForRestartSignal(context.Background(), "ws-cache-hit-123")
|
||||
@@ -110,19 +110,18 @@ func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
|
||||
if url == "" {
|
||||
t.Fatal("expected non-empty URL from cache")
|
||||
}
|
||||
// DB should not be queried (no rows returned to sqlmock)
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unfulfilled DB expectations: %v", err)
|
||||
if url != "http://cached.internal:9000/agent" {
|
||||
t.Errorf("expected cached URL, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAgentURLForRestartSignal_DBError verifies that a DB error is
|
||||
// returned and propagated when neither Redis cache nor DB lookup succeeds.
|
||||
func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
|
||||
mockDB, mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
|
||||
_ = setupTestRedis(t) // empty → cache miss
|
||||
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
|
||||
_ = setupTestRedis(t) // empty → cache miss
|
||||
|
||||
h := newHandlerWithTestDepsWithDB(t, mockDB)
|
||||
h := newHandlerWithTestDeps(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT url FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-db-err-789").
|
||||
@@ -141,10 +140,10 @@ func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
|
||||
// TestResolveAgentURLForRestartSignal_CacheMiss verifies that on Redis miss,
|
||||
// the URL is fetched from the DB and cached.
|
||||
func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
|
||||
mockDB, mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
|
||||
mr := setupTestRedis(t) // empty → cache miss
|
||||
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
|
||||
_ = setupTestRedis(t) // empty → cache miss
|
||||
|
||||
h := newHandlerWithTestDepsWithDB(t, mockDB)
|
||||
h := newHandlerWithTestDeps(t)
|
||||
|
||||
mock.ExpectQuery(`SELECT url FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-cache-miss-456").
|
||||
@@ -159,10 +158,12 @@ func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
|
||||
t.Errorf("expected DB URL, got %q", url)
|
||||
}
|
||||
|
||||
// Verify the URL was cached in Redis
|
||||
cached, err := mr.Get(context.Background(), "ws:ws-cache-miss-456:url").Result()
|
||||
// Verify the URL was cached in Redis via db.GetCachedURL.
|
||||
// GetCachedURL takes workspaceID and builds the key internally, so
|
||||
// pass "ws-cache-miss-456" (not the full "ws:ws-cache-miss-456:url").
|
||||
cached, err := db.GetCachedURL(context.Background(), "ws-cache-miss-456")
|
||||
if err != nil {
|
||||
t.Fatalf("URL was not cached in Redis: %v", err)
|
||||
t.Fatalf("URL cache read failed: %v", err)
|
||||
}
|
||||
if cached != "http://db.internal:8000/agent" {
|
||||
t.Errorf("expected cached URL %q, got %q", "http://db.internal:8000/agent", cached)
|
||||
@@ -175,9 +176,7 @@ func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
|
||||
// TestGracefulPreRestart_Success verifies that when the workspace returns 200,
|
||||
// the signal is logged as acknowledged without error.
|
||||
func TestGracefulPreRestart_Success(t *testing.T) {
|
||||
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
|
||||
|
||||
mr := setupTestRedisWithURL(t, "http://localhost:18000/agent")
|
||||
_ = setupTestDB(t)
|
||||
|
||||
// httptest server simulating the workspace container's /signals/restart_pending
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -206,44 +205,40 @@ func TestGracefulPreRestart_Success(t *testing.T) {
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
mr.Set("ws:ws-ack-789:url", srv.URL, 5*time.Minute)
|
||||
|
||||
// Patch the handler's resolveAgentURLForRestartSignal to return the test server URL
|
||||
// (avoids needing a real provisioner for this test)
|
||||
h := newHandlerWithTestDeps(t)
|
||||
origResolve := h.resolveAgentURLForRestartSignal
|
||||
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
|
||||
return srv.URL + "/agent", nil
|
||||
// Pre-populate Redis cache with the test server URL
|
||||
_ = setupTestRedisWithURL(t, srv.URL)
|
||||
|
||||
// Use an embedded struct to override resolveAgentURLForRestartSignal.
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
testURL: srv.URL + "/agent",
|
||||
}
|
||||
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
|
||||
|
||||
// gracefulPreRestart runs in a goroutine with its own timeout.
|
||||
// We give it time to complete before the test ends.
|
||||
h.gracefulPreRestart(context.Background(), "ws-ack-789")
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-ack-789")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestGracefulPreRestart_NotImplemented verifies that when the workspace returns
|
||||
// 404 (old SDK version), the platform proceeds gracefully (log + no error).
|
||||
func TestGracefulPreRestart_NotImplemented(t *testing.T) {
|
||||
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
|
||||
|
||||
mr := setupTestRedisWithURL(t, "http://localhost:18001/agent")
|
||||
_ = setupTestDB(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
mr.Set("ws:ws-noimpl-999:url", srv.URL, 5*time.Minute)
|
||||
|
||||
h := newHandlerWithTestDeps(t)
|
||||
origResolve := h.resolveAgentURLForRestartSignal
|
||||
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
|
||||
return srv.URL + "/agent", nil
|
||||
_ = setupTestRedisWithURL(t, srv.URL)
|
||||
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
testURL: srv.URL + "/agent",
|
||||
}
|
||||
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
|
||||
|
||||
h.gracefulPreRestart(context.Background(), "ws-noimpl-999")
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-noimpl-999")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
// No panic or error expected — graceful degradation
|
||||
}
|
||||
@@ -251,19 +246,17 @@ func TestGracefulPreRestart_NotImplemented(t *testing.T) {
|
||||
// TestGracefulPreRestart_ConnectionRefused verifies that when the workspace
|
||||
// is unreachable, the platform proceeds gracefully without error.
|
||||
func TestGracefulPreRestart_ConnectionRefused(t *testing.T) {
|
||||
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
|
||||
_ = setupTestDB(t)
|
||||
|
||||
mr := setupTestRedisWithURL(t, "http://localhost:19999/agent") // nothing listening on 19999
|
||||
mr.Set("ws:ws-unreachable-000:url", "http://localhost:19999/agent", 5*time.Minute)
|
||||
_ = mr
|
||||
|
||||
h := newHandlerWithTestDeps(t)
|
||||
origResolve := h.resolveAgentURLForRestartSignal
|
||||
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
|
||||
return "http://localhost:19999/agent", nil
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
testURL: "http://localhost:19999/agent",
|
||||
}
|
||||
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
|
||||
|
||||
h.gracefulPreRestart(context.Background(), "ws-unreachable-000")
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-unreachable-000")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
// No panic or error expected — proceeds with stop as documented
|
||||
}
|
||||
@@ -274,36 +267,35 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
|
||||
_ = setupTestDB(t)
|
||||
_ = setupTestRedis(t) // empty → URL resolution will fail in resolveAgentURLForRestartSignal
|
||||
|
||||
h := newHandlerWithTestDeps(t)
|
||||
|
||||
// Override resolveAgentURLForRestartSignal to return an error
|
||||
origResolve := h.resolveAgentURLForRestartSignal
|
||||
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
|
||||
return "", context.DeadlineExceeded
|
||||
hWrapper := &resolveURLTestWrapper{
|
||||
WorkspaceHandler: newHandlerWithTestDeps(t),
|
||||
errToReturn: context.DeadlineExceeded,
|
||||
}
|
||||
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
|
||||
|
||||
h.gracefulPreRestart(context.Background(), "ws-url-err-111")
|
||||
hWrapper.gracefulPreRestart(context.Background(), "ws-url-err-111")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
// No panic or error expected — proceeds with stop as documented
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// newHandlerWithTestDeps creates a WorkspaceHandler with test stubs.
|
||||
// provisioner is nil so rewriteForDocker returns URL unchanged.
|
||||
func newHandlerWithTestDeps(t *testing.T) *WorkspaceHandler {
|
||||
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
// resolveURLTestWrapper embeds *WorkspaceHandler and overrides
|
||||
// resolveAgentURLForRestartSignal so tests can inject a fixed URL or error.
|
||||
type resolveURLTestWrapper struct {
|
||||
*WorkspaceHandler
|
||||
testURL string
|
||||
errToReturn error
|
||||
}
|
||||
|
||||
// newHandlerWithTestDepsWithDB creates a WorkspaceHandler with a specific mock DB.
|
||||
// Use this when you need to control the DB mock expectations.
|
||||
func newHandlerWithTestDepsWithDB(t *testing.T, mockDB *sql.DB) *WorkspaceHandler {
|
||||
// We need to temporarily replace db.DB with our mock
|
||||
origDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = origDB })
|
||||
func (w *resolveURLTestWrapper) resolveAgentURLForRestartSignal(ctx context.Context, workspaceID string) (string, error) {
|
||||
if w.errToReturn != nil {
|
||||
return "", w.errToReturn
|
||||
}
|
||||
return w.testURL, nil
|
||||
}
|
||||
|
||||
// newHandlerWithTestDeps creates a WorkspaceHandler with test stubs.
|
||||
func newHandlerWithTestDeps(t *testing.T) *WorkspaceHandler {
|
||||
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
}
|
||||
|
||||
@@ -314,7 +306,6 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
|
||||
t.Fatalf("failed to start miniredis: %v", err)
|
||||
}
|
||||
db.RDB = redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
// Pre-populate a URL for the test workspace IDs used in these tests
|
||||
for _, wsID := range []string{"ws-cache-hit-123", "ws-cache-miss-456", "ws-ack-789", "ws-noimpl-999", "ws-unreachable-000"} {
|
||||
if err := db.CacheURL(context.Background(), wsID, url); err != nil {
|
||||
t.Fatalf("failed to cache URL for %s: %v", wsID, err)
|
||||
@@ -322,9 +313,4 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
|
||||
}
|
||||
t.Cleanup(func() { mr.Close() })
|
||||
return mr
|
||||
}
|
||||
|
||||
// rewriteForDocker is exported from restart_signals.go so it can be tested here.
|
||||
func (h *WorkspaceHandler) rewriteForDocker(agentURL, workspaceID string) string {
|
||||
return rewriteForDocker(agentURL, workspaceID)
|
||||
}
|
||||
}
|
||||
@@ -248,6 +248,19 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// Begin a transaction so the workspace row and any initial secrets are
|
||||
// committed atomically. A secret-encrypt or DB error rolls back the
|
||||
// workspace insert so we never leave a workspace row with missing secrets.
|
||||
|
||||
// SSRF guard: validate workspace URL before starting any DB transaction.
|
||||
// registry.go:324 calls this same guard for agent self-registration;
|
||||
// the admin-create path must be covered too (core#212).
|
||||
// Must stay above BeginTx so the rejection path never touches the DB.
|
||||
if payload.URL != "" {
|
||||
if err := validateAgentURL(payload.URL); err != nil {
|
||||
log.Printf("Create: workspace URL rejected: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe workspace URL: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tx, txErr := db.DB.BeginTx(ctx, nil)
|
||||
if txErr != nil {
|
||||
log.Printf("Create workspace: begin tx error: %v", txErr)
|
||||
@@ -383,16 +396,9 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
if payload.External || payload.Runtime == "external" {
|
||||
var connectionToken string
|
||||
if payload.URL != "" {
|
||||
// SSRF guard (issue #212): validateAgentURL blocks cloud metadata
|
||||
// IPs (169.254/16), loopback, link-local, and RFC-1918 in
|
||||
// strict/self-hosted mode. AdminAuth is required here, but the
|
||||
// admin token could be leaked or a compromised insider — defence
|
||||
// in depth. Compare: registry.go:324 (heartbeat path) also
|
||||
// calls validateAgentURL; external_rotate.go should too.
|
||||
if err := validateAgentURL(payload.URL); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe workspace URL: " + err.Error()})
|
||||
return
|
||||
}
|
||||
// URL already validated by validateAgentURL above (before BeginTx).
|
||||
// Now persist it: the external URL is set after the workspace row
|
||||
// commits so that a failed URL UPDATE doesn't roll back the row.
|
||||
db.DB.ExecContext(ctx, `UPDATE workspaces SET url = $1, status = $2, runtime = 'external', updated_at = now() WHERE id = $3`, payload.URL, models.StatusOnline, id)
|
||||
if err := db.CacheURL(ctx, id, payload.URL); err != nil {
|
||||
log.Printf("External workspace: failed to cache URL for %s: %v", id, err)
|
||||
|
||||
@@ -537,17 +537,15 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// External URL update (SSRF-safe public URL passes validateAgentURL).
|
||||
// External URL update (localhost is explicitly allowed by validateAgentURL).
|
||||
mock.ExpectExec("UPDATE workspaces SET url").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// CacheURL is non-fatal but still called.
|
||||
mock.ExpectExec("SELECT").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"ok"}).AddRow("ok"))
|
||||
// CacheURL is non-fatal — uses Redis (db.RDB, set by setupTestRedis), not the DB.
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"https://agent.example.com/a2a"}`
|
||||
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"http://localhost:8000"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ package plugins
|
||||
// 1. SELECTs workspace_plugins rows where tracked_ref != 'none'
|
||||
// AND installed_sha IS NOT NULL (skip pre-migration rows with NULL SHA).
|
||||
// 2. For each row, resolves the tracked ref to its current upstream SHA
|
||||
// using the appropriate SourceResolver.
|
||||
// using the appropriate PluginResolver.
|
||||
// 3. If the resolved SHA differs from installed_sha → drift detected.
|
||||
// 4. On drift, INSERT INTO plugin_update_queue (ON CONFLICT DO NOTHING so
|
||||
// a re-drift while a row is still pending is a no-op).
|
||||
@@ -61,10 +61,12 @@ const DriftSweepInterval = 1 * time.Hour
|
||||
// that handles Gitea instances on high-latency links.
|
||||
const ResolveRefDeadline = 60 * time.Second
|
||||
|
||||
// SourceResolver resolves plugin sources to installable directories.
|
||||
// PluginResolver resolves plugin sources to installable directories.
|
||||
// Satisfied by *Registry (which wraps GithubResolver + LocalResolver).
|
||||
type SourceResolver interface {
|
||||
Resolve(source Source) (SourceResolver, error)
|
||||
// Named PluginResolver (not SourceResolver) to avoid redeclaring the
|
||||
// SourceResolver interface defined in source.go (core#228 fix).
|
||||
type PluginResolver interface {
|
||||
Resolve(source Source) (PluginResolver, error)
|
||||
Schemes() []string
|
||||
}
|
||||
|
||||
@@ -74,7 +76,7 @@ type SourceResolver interface {
|
||||
//
|
||||
// Registers itself via atexits in cmd/server/main.go so the process
|
||||
// shuts down cleanly on SIGTERM.
|
||||
func StartPluginDriftSweeper(ctx context.Context, resolver SourceResolver) {
|
||||
func StartPluginDriftSweeper(ctx context.Context, resolver PluginResolver) {
|
||||
if resolver == nil {
|
||||
log.Println("Plugin drift sweeper: resolver is nil — sweeper disabled")
|
||||
return
|
||||
@@ -107,7 +109,7 @@ func StartPluginDriftSweeper(ctx context.Context, resolver SourceResolver) {
|
||||
// sweepDriftOnce runs one full drift-detection cycle.
|
||||
// Errors are non-fatal — each row is handled independently so a single
|
||||
// slow row doesn't block the rest of the sweep.
|
||||
func sweepDriftOnce(parent context.Context, resolver SourceResolver) {
|
||||
func sweepDriftOnce(parent context.Context, resolver PluginResolver) {
|
||||
ctx, cancel := context.WithTimeout(parent, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -170,7 +172,7 @@ func sweepDriftOnce(parent context.Context, resolver SourceResolver) {
|
||||
// resolveLatestSHA resolves the tracked ref to its current upstream SHA.
|
||||
// Handles both github:// and local:// sources; local sources are skipped
|
||||
// (no meaningful upstream to drift against).
|
||||
func resolveLatestSHA(ctx context.Context, resolver SourceResolver, sourceRaw, trackedRef string) (string, error) {
|
||||
func resolveLatestSHA(ctx context.Context, resolver PluginResolver, sourceRaw, trackedRef string) (string, error) {
|
||||
// Strip the scheme prefix to get the raw spec.
|
||||
// sourceRaw is stored as the full string, e.g. "github://owner/repo#tag:v1.0.0"
|
||||
spec := sourceRaw
|
||||
@@ -231,7 +233,7 @@ func queueDriftEntry(ctx context.Context, workspaceID, pluginName, trackedRef, c
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// SweepDriftOnceForTest exposes sweepDriftOnce for package-level testing.
|
||||
func SweepDriftOnceForTest(parent context.Context, resolver SourceResolver) {
|
||||
func SweepDriftOnceForTest(parent context.Context, resolver PluginResolver) {
|
||||
sweepDriftOnce(parent, resolver)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user