mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add harness engineering layer for agent workflows (#24791)
This PR adds an opinionated harness-engineering layer for agent-driven workflows: a small set of agent-readable docs, mechanical structure checks, structured CI failure summaries, an architecture-lint umbrella, and per-worktree dev-server isolation. The goal is to make local dev, tests, and CI mechanically inspectable by agents without changing app runtime behavior. ## What landed **Agent docs and navigation** - `.claude/docs/OBSERVABILITY.md`, `.claude/docs/DEV_ISOLATION.md`, `.claude/docs/AGENT_FAILURES.md`: task-oriented guides for logs, tracing, Prometheus, dev-server isolation, and a seeded failure catalog. - `AGENTS.md`: added an `Agent navigation` block, then trimmed the file from 375 to 229 lines by migrating duplicated detail into `WORKFLOWS.md`, `GO.md`, `TESTING.md`, and `DATABASE.md`. The user-managed custom-instructions block is preserved. - `.agents/docs`: symlink mirror of `.claude/docs` for agent runtimes that look under `.agents`. **Mechanical checks** - `scripts/check_agents_structure.sh`: validates `@...` references in tracked `AGENTS.md` files and warns when root grows past 600 lines. Wired as `make lint/agents` and into `make lint`. - `scripts/audit-agent-readiness.sh`: report-first audit of harness readiness. Currently `10 ok, 0 warn, 0 fail`. - `scripts/check_architecture.sh` / `make lint/architecture`: umbrella architecture-lint target. Consolidates the existing `check_enterprise_imports.sh` and `check_codersdk_imports.sh` so they run exactly once via the umbrella. Slot is open for new high-confidence rules. **Structured CI failure summaries** - `scripts/playwright-failure-summary.sh`: parses `site/test-results/results.json` and writes Markdown to `$GITHUB_STEP_SUMMARY` on failure. Wired into the `test-e2e` matrix job. - `scripts/go-test-failure-summary.sh`: parses `go test -json` line-delimited output the same way. Wired into `test-go-pg`, `test-go-pg-17`, and `test-go-race-pg` by injecting `gotestsum --jsonfile` in the workflow without touching `Makefile`. JSON also uploaded as a CI artifact on failure. - `site/e2e/playwright.config.ts`: enables `screenshot: only-on-failure`, `trace: retain-on-failure`, JSON reporter, and HTML reporter alongside existing reporters. - `.github/workflows/ci.yaml`: failure artifact uploads for Playwright now use `if: failure()` and predictable names (`playwright-artifacts-<variant>-<sha>`). **Per-worktree dev-server isolation** (`scripts/develop/main.go`) - Deterministic FNV-64a hash of the worktree path produces a port offset in `[0, 1000)` (50 buckets, step 20 to avoid API/proxy overlap across adjacent buckets). - Offset is applied only to defaults; both env vars (`CODER_DEV_PORT`, `CODER_DEV_WEB_PORT`, `CODER_DEV_PROXY_PORT`, `CODER_DEV_PROMETHEUS_PORT`) and CLI flags retain priority. - Hardcoded ports `9090` (embedded Prometheus UI) and `12345` (Delve) are unchanged by design. - Startup banner shows each port's source: `default`, `offset`, or `explicit`. - Unit tests in `scripts/develop/main_test.go` cover determinism, bounds, no-overlap across the four ports, and explicit-skip behavior. - State (`.coderv2/`) was already worktree-isolated via `os.Getwd()`, so no state-dir changes were needed. ## Validation `make lint/agents`, `make lint/architecture`, `make lint/emdash`, `bash scripts/audit-agent-readiness.sh` (10 ok, 0 warn, 0 fail), `shellcheck` on all 5 new scripts, `go test ./scripts/develop/...`, and `js-yaml` parse of `ci.yaml` all pass. Synthetic fixtures verify both failure-summary scripts handle empty/missing input (silent exit 0), ANSI-stripped output, and parent/subtest formatting. ## Known follow-ups (deferred) - Frontend Storybook/Vitest failure summary: lowest-leverage slice of the failure-summary work. Skipping until observed pain. - Architecture lint currently only delegates to existing import checks; new rules (`InTx` outer-store detection, swagger-annotation lint) plug in as needed. - 50 port-offset buckets means two worktree paths can occasionally collide. The DEV_ISOLATION doc tells users to set the relevant env var when this happens. > Mux opened this PR on Mike's behalf.
This commit is contained in:
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# shellcheck source=scripts/lib.sh
|
||||
# shellcheck disable=SC1091
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
cdroot
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/audit-agent-readiness.sh [--help]
|
||||
|
||||
Print a report-first audit of agent harness readiness. Warnings identify
|
||||
aspirational checks and do not fail the script. Missing required harness docs
|
||||
fail the script. Run manually with:
|
||||
|
||||
bash scripts/audit-agent-readiness.sh
|
||||
USAGE
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ok_count=0
|
||||
warn_count=0
|
||||
fail_count=0
|
||||
|
||||
ok() {
|
||||
printf '[ok] %s\n' "$1"
|
||||
((ok_count++)) || true
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf '[warn] %s\n' "$1"
|
||||
((warn_count++)) || true
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[fail] %s\n' "$1"
|
||||
((fail_count++)) || true
|
||||
}
|
||||
|
||||
contains() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
grep -qiE "$pattern" "$file"
|
||||
}
|
||||
|
||||
echo "Agent harness readiness audit"
|
||||
echo
|
||||
echo "Required harness docs"
|
||||
|
||||
for doc in \
|
||||
".claude/docs/OBSERVABILITY.md" \
|
||||
".claude/docs/DEV_ISOLATION.md" \
|
||||
".claude/docs/AGENT_FAILURES.md"; do
|
||||
if [[ -f "$doc" ]]; then
|
||||
ok "$doc exists."
|
||||
else
|
||||
fail "$doc is missing."
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -L ".agents/docs" ]]; then
|
||||
agents_docs_target="$(readlink ".agents/docs")"
|
||||
if [[ "$agents_docs_target" == "../.claude/docs" ]]; then
|
||||
ok ".agents/docs points to .claude/docs."
|
||||
else
|
||||
fail ".agents/docs points to $agents_docs_target, expected ../.claude/docs."
|
||||
fi
|
||||
else
|
||||
fail ".agents/docs compatibility symlink is missing."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Navigation and report-first checks"
|
||||
|
||||
if contains AGENTS.md '^##[[:space:]].*(Agent navigation|Where to look)' ||
|
||||
{ grep -qF ".claude/docs/OBSERVABILITY.md" AGENTS.md &&
|
||||
grep -qF ".claude/docs/DEV_ISOLATION.md" AGENTS.md &&
|
||||
grep -qF ".claude/docs/AGENT_FAILURES.md" AGENTS.md; }; then
|
||||
ok "Root AGENTS.md appears to include agent navigation."
|
||||
else
|
||||
warn "Root AGENTS.md may be missing agent navigation."
|
||||
fi
|
||||
|
||||
if contains site/e2e/playwright.config.ts 'screenshot' &&
|
||||
contains site/e2e/playwright.config.ts 'video' &&
|
||||
contains site/e2e/playwright.config.ts 'trace' &&
|
||||
contains site/e2e/playwright.config.ts 'failure'; then
|
||||
ok "Playwright failure artifact settings appear configured."
|
||||
else
|
||||
warn "Playwright failure artifact settings were not all detected."
|
||||
fi
|
||||
|
||||
if grep -qi "playwright" .github/workflows/ci.yaml &&
|
||||
grep -q "upload-artifact" .github/workflows/ci.yaml &&
|
||||
grep -qF "failure()" .github/workflows/ci.yaml; then
|
||||
ok "E2E CI failure artifact upload appears configured."
|
||||
else
|
||||
warn "E2E CI failure artifact upload was not detected."
|
||||
fi
|
||||
|
||||
if contains .claude/docs/OBSERVABILITY.md 'Prometheus' &&
|
||||
contains .claude/docs/OBSERVABILITY.md 'log'; then
|
||||
ok "Observability doc mentions logs and Prometheus."
|
||||
else
|
||||
warn "Observability doc may be missing logs or Prometheus coverage."
|
||||
fi
|
||||
|
||||
if contains .claude/docs/DEV_ISOLATION.md 'port' &&
|
||||
contains .claude/docs/DEV_ISOLATION.md 'CODER_DEV|override'; then
|
||||
ok "Development isolation doc mentions ports and overrides."
|
||||
else
|
||||
warn "Development isolation doc may be missing ports or override coverage."
|
||||
fi
|
||||
|
||||
if grep -q 'lint/architecture' Makefile; then
|
||||
ok "Architecture lint target exists."
|
||||
else
|
||||
warn "Architecture lint target is not present yet."
|
||||
fi
|
||||
|
||||
echo
|
||||
printf 'Summary: %d ok, %d warn, %d fail.\n' "$ok_count" "$warn_count" "$fail_count"
|
||||
|
||||
if ((fail_count > 0)); then
|
||||
exit 1
|
||||
fi
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# shellcheck source=scripts/lib.sh
|
||||
# shellcheck disable=SC1091
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
cdroot
|
||||
|
||||
echo "--- check agent docs structure"
|
||||
|
||||
required_docs=(
|
||||
".claude/docs/OBSERVABILITY.md"
|
||||
".claude/docs/DEV_ISOLATION.md"
|
||||
".claude/docs/AGENT_FAILURES.md"
|
||||
)
|
||||
|
||||
fail=0
|
||||
|
||||
for doc in "${required_docs[@]}"; do
|
||||
if [[ ! -f "$doc" ]]; then
|
||||
echo "error: required harness doc is missing: $doc"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -L ".agents/docs" ]]; then
|
||||
echo "error: agent docs compatibility symlink is missing: .agents/docs -> ../.claude/docs"
|
||||
fail=1
|
||||
elif [[ "$(readlink ".agents/docs")" != "../.claude/docs" ]]; then
|
||||
echo "error: agent docs compatibility symlink points to $(readlink ".agents/docs"), expected ../.claude/docs"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
is_reference_path() {
|
||||
local ref="$1"
|
||||
case "$ref" in
|
||||
*/* | package.json | AGENTS.local.md)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# TODO: Add circular AGENTS.md include detection if nested agent docs begin
|
||||
# referencing each other. Current checks validate file existence only.
|
||||
mapfile -t agent_files < <(git ls-files '*AGENTS.md' | sort)
|
||||
|
||||
for agent_file in "${agent_files[@]}"; do
|
||||
agent_dir="$(dirname "$agent_file")"
|
||||
while IFS=$'\t' read -r line_number ref; do
|
||||
if [[ -z "${line_number:-}" || -z "${ref:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! is_reference_path "$ref"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
candidate="$agent_dir/$ref"
|
||||
candidate="${candidate#./}"
|
||||
if [[ -e "$candidate" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$(basename "$ref")" == "AGENTS.local.md" ]]; then
|
||||
echo "warning: $agent_file:$line_number: optional local agent file is not present: $ref"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "error: $agent_file:$line_number: referenced file does not exist: $ref"
|
||||
fail=1
|
||||
done < <(
|
||||
awk '
|
||||
/^[[:space:]]*(-[[:space:]]+)?@/ {
|
||||
ref = $0
|
||||
sub(/^[[:space:]]*(-[[:space:]]+)?@/, "", ref)
|
||||
sub(/[[:space:]`)>].*$/, "", ref)
|
||||
sub(/[,:;)]+$/, "", ref)
|
||||
print FNR "\t" ref
|
||||
}
|
||||
' "$agent_file"
|
||||
)
|
||||
done
|
||||
|
||||
if [[ -f AGENTS.md ]]; then
|
||||
root_agent_lines=$(wc -l <AGENTS.md)
|
||||
if ((root_agent_lines > 600)); then
|
||||
echo "warning: AGENTS.md is $root_agent_lines lines, consider keeping the root guide concise."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: agent docs structure looks valid."
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Umbrella architecture-boundary check.
|
||||
#
|
||||
# Delegates to existing import-boundary scripts. New architecture rules can be
|
||||
# added here as needed.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "--- check architecture (import boundaries)"
|
||||
|
||||
"$SCRIPT_DIR/check_enterprise_imports.sh"
|
||||
"$SCRIPT_DIR/check_codersdk_imports.sh"
|
||||
|
||||
echo "OK: architecture checks passed."
|
||||
+164
-27
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -52,7 +53,13 @@ const (
|
||||
prometheusContainerName = "coder-prometheus"
|
||||
// defaultPrometheusPort avoids 2112 (agent prometheus) and
|
||||
// 2113 (agent debug) already bound inside Coder workspaces.
|
||||
defaultPrometheusPort = "2114"
|
||||
defaultPrometheusPort = "2114"
|
||||
// portOffsetBuckets keeps the offset below 1000 while leaving
|
||||
// enough hash buckets for common multi-worktree use.
|
||||
portOffsetBuckets = 50
|
||||
// portOffsetStep avoids overlap between the default API and proxy
|
||||
// ports when two worktrees land in adjacent buckets.
|
||||
portOffsetStep = 20
|
||||
prometheusImage = "prom/prometheus:v3.11.2"
|
||||
defaultAccessURL = "http://127.0.0.1:%d"
|
||||
defaultPassword = "SomeSecurePassword!"
|
||||
@@ -96,6 +103,13 @@ func main() {
|
||||
Description: "Prometheus metrics port. Set to 0 to disable.",
|
||||
Value: serpent.Int64Of(&cfg.coderMetricsPort),
|
||||
},
|
||||
{
|
||||
Flag: "port-offset",
|
||||
Env: "CODER_DEV_PORT_OFFSET",
|
||||
Default: "false",
|
||||
Description: "Apply a deterministic per-worktree offset to default API, web, proxy, and Coder metrics ports. Useful when running multiple worktrees in parallel.",
|
||||
Value: serpent.BoolOf(&cfg.portOffsetEnabled),
|
||||
},
|
||||
{
|
||||
Flag: "prometheus-server",
|
||||
Env: "CODER_DEV_PROMETHEUS_SERVER",
|
||||
@@ -171,12 +185,13 @@ func main() {
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
cfg.serverExtraArgs = inv.Args
|
||||
cfg.portExplicit = portExplicitFromInvocation(inv)
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stderr))
|
||||
if err := cfg.validate(); err != nil {
|
||||
if err := cfg.resolveEnv(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.resolveEnv(); err != nil {
|
||||
if err := cfg.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return develop(inv.Context(), logger, &cfg)
|
||||
@@ -191,30 +206,123 @@ func main() {
|
||||
}
|
||||
|
||||
type devConfig struct {
|
||||
apiPort int64
|
||||
webPort int64
|
||||
proxyPort int64
|
||||
coderMetricsPort int64
|
||||
prometheusServer bool
|
||||
agpl bool
|
||||
accessURL string
|
||||
password string
|
||||
useProxy bool
|
||||
debug bool
|
||||
skipSetup bool
|
||||
multiOrg bool
|
||||
starterTemplate string
|
||||
dbRollback bool
|
||||
dbReset bool
|
||||
dbContinue bool
|
||||
projectRoot string
|
||||
binaryPath string
|
||||
configDir string
|
||||
childEnv []string
|
||||
apiPort int64
|
||||
webPort int64
|
||||
proxyPort int64
|
||||
coderMetricsPort int64
|
||||
portOffsetEnabled bool
|
||||
prometheusServer bool
|
||||
agpl bool
|
||||
accessURL string
|
||||
password string
|
||||
useProxy bool
|
||||
debug bool
|
||||
skipSetup bool
|
||||
multiOrg bool
|
||||
starterTemplate string
|
||||
dbRollback bool
|
||||
dbReset bool
|
||||
dbContinue bool
|
||||
projectRoot string
|
||||
binaryPath string
|
||||
configDir string
|
||||
childEnv []string
|
||||
portExplicit portExplicit
|
||||
portOffset int
|
||||
apiPortSource portSource
|
||||
webPortSource portSource
|
||||
proxyPortSource portSource
|
||||
metricsPortSource portSource
|
||||
// Extra args after flags forwarded to "coder server".
|
||||
serverExtraArgs []string
|
||||
}
|
||||
|
||||
type portExplicit struct {
|
||||
api bool
|
||||
web bool
|
||||
proxy bool
|
||||
metrics bool
|
||||
}
|
||||
|
||||
type portSource string
|
||||
|
||||
const (
|
||||
portSourceDefault portSource = "default"
|
||||
portSourceExplicit portSource = "explicit"
|
||||
portSourceOffset portSource = "offset"
|
||||
)
|
||||
|
||||
func portExplicitFromInvocation(inv *serpent.Invocation) portExplicit {
|
||||
return portExplicit{
|
||||
api: isPortExplicit(inv, "port", "CODER_DEV_PORT"),
|
||||
web: isPortExplicit(inv, "web-port", "CODER_DEV_WEB_PORT"),
|
||||
proxy: isPortExplicit(inv, "proxy-port", "CODER_DEV_PROXY_PORT"),
|
||||
metrics: isPortExplicit(inv, "prometheus-port", "CODER_DEV_PROMETHEUS_PORT"),
|
||||
}
|
||||
}
|
||||
|
||||
func isPortExplicit(inv *serpent.Invocation, flagName, envName string) bool {
|
||||
if flag := inv.ParsedFlags().Lookup(flagName); flag != nil && flag.Changed {
|
||||
return true
|
||||
}
|
||||
if val, ok := inv.Environ.Lookup(envName); ok && val != "" {
|
||||
return true
|
||||
}
|
||||
for _, opt := range inv.Command.Options {
|
||||
if opt.Flag == flagName {
|
||||
return opt.ValueSource == serpent.ValueSourceFlag ||
|
||||
opt.ValueSource == serpent.ValueSourceEnv
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// portOffset returns a deterministic offset in [0, 1000) derived from the
|
||||
// worktree path. Successive callers with the same projectRoot get the same
|
||||
// offset; different projectRoots get different offsets with high probability.
|
||||
func portOffset(projectRoot string) int {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(projectRoot))
|
||||
bucket := h.Sum64() % uint64(portOffsetBuckets)
|
||||
return int(bucket) * portOffsetStep //nolint:gosec // Bucket is less than portOffsetBuckets.
|
||||
}
|
||||
|
||||
func (c *devConfig) applyPortOffset() {
|
||||
c.portOffset = 0
|
||||
if !c.portOffsetEnabled {
|
||||
return
|
||||
}
|
||||
c.portOffset = portOffset(c.projectRoot)
|
||||
if c.portExplicit.api {
|
||||
c.apiPortSource = portSourceExplicit
|
||||
} else {
|
||||
c.apiPortSource = c.applyDefaultPortOffset(&c.apiPort)
|
||||
}
|
||||
if c.portExplicit.web {
|
||||
c.webPortSource = portSourceExplicit
|
||||
} else {
|
||||
c.webPortSource = c.applyDefaultPortOffset(&c.webPort)
|
||||
}
|
||||
if c.portExplicit.proxy {
|
||||
c.proxyPortSource = portSourceExplicit
|
||||
} else {
|
||||
c.proxyPortSource = c.applyDefaultPortOffset(&c.proxyPort)
|
||||
}
|
||||
if c.portExplicit.metrics {
|
||||
c.metricsPortSource = portSourceExplicit
|
||||
} else {
|
||||
c.metricsPortSource = c.applyDefaultPortOffset(&c.coderMetricsPort)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *devConfig) applyDefaultPortOffset(port *int64) portSource {
|
||||
if c.portOffset == 0 {
|
||||
return portSourceDefault
|
||||
}
|
||||
*port += int64(c.portOffset)
|
||||
return portSourceOffset
|
||||
}
|
||||
|
||||
func (c *devConfig) validate() error {
|
||||
if c.agpl && c.useProxy {
|
||||
return xerrors.New("cannot use both --agpl and --use-proxy")
|
||||
@@ -293,10 +401,6 @@ func (c *devConfig) validate() error {
|
||||
// resolveEnv sets defaults, unsets leaked credentials, resolves
|
||||
// filesystem paths, and computes the child process environment.
|
||||
func (c *devConfig) resolveEnv() error {
|
||||
if strings.Contains(c.accessURL, "%d") {
|
||||
c.accessURL = fmt.Sprintf(c.accessURL, c.apiPort)
|
||||
}
|
||||
|
||||
// Prevent inherited credentials from leaking into child
|
||||
// processes or being picked up by config reads.
|
||||
_ = os.Unsetenv("CODER_SESSION_TOKEN")
|
||||
@@ -311,6 +415,11 @@ func (c *devConfig) resolveEnv() error {
|
||||
fmt.Sprintf("coder_%s_%s", runtime.GOOS, runtime.GOARCH))
|
||||
c.configDir = filepath.Join(c.projectRoot, ".coderv2")
|
||||
|
||||
c.applyPortOffset()
|
||||
if strings.Contains(c.accessURL, "%d") {
|
||||
c.accessURL = fmt.Sprintf(c.accessURL, c.apiPort)
|
||||
}
|
||||
|
||||
// Compute once, reused by cmd().
|
||||
c.childEnv = filterEnv(os.Environ(), "CODER_SESSION_TOKEN", "CODER_URL")
|
||||
|
||||
@@ -1120,6 +1229,28 @@ func prometheusBannerEntry(cfg *devConfig, prometheusServerStarted bool) (label
|
||||
}
|
||||
}
|
||||
|
||||
func portBannerLine(label string, port int64, source portSource, offset int) string {
|
||||
portValue := strconv.FormatInt(port, 10)
|
||||
if port == 0 {
|
||||
portValue = "disabled"
|
||||
}
|
||||
if source == "" {
|
||||
return fmt.Sprintf("%s: %s", label, portValue)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s (%s)", label, portValue, portSourceLabel(source, offset))
|
||||
}
|
||||
|
||||
func portSourceLabel(source portSource, offset int) string {
|
||||
switch source {
|
||||
case portSourceExplicit:
|
||||
return fmt.Sprintf("explicit, offset +%d skipped", offset)
|
||||
case portSourceOffset:
|
||||
return fmt.Sprintf("offset +%d", offset)
|
||||
default:
|
||||
return fmt.Sprintf("default, offset +%d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func printBanner(ctx context.Context, logger slog.Logger, cfg *devConfig, prometheusServerStarted bool) {
|
||||
ifaces := []string{"localhost"}
|
||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||
@@ -1153,6 +1284,12 @@ func printBanner(ctx context.Context, logger slog.Logger, cfg *devConfig, promet
|
||||
"",
|
||||
indent("Coder is now running in development mode."),
|
||||
"",
|
||||
"Effective ports:",
|
||||
indent(portBannerLine("API", cfg.apiPort, cfg.apiPortSource, cfg.portOffset)),
|
||||
indent(portBannerLine("Web UI", cfg.webPort, cfg.webPortSource, cfg.portOffset)),
|
||||
indent(portBannerLine("Proxy", cfg.proxyPort, cfg.proxyPortSource, cfg.portOffset)),
|
||||
indent(portBannerLine("Coder metrics", cfg.coderMetricsPort, cfg.metricsPortSource, cfg.portOffset)),
|
||||
"",
|
||||
"API:",
|
||||
)
|
||||
|
||||
|
||||
@@ -146,6 +146,127 @@ func TestShellBool(t *testing.T) {
|
||||
assert.Equal(t, "0", shellBool(false))
|
||||
}
|
||||
|
||||
func TestPortOffset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := "/tmp/coder/worktree-a"
|
||||
offset := portOffset(root)
|
||||
assert.Equal(t, offset, portOffset(root))
|
||||
assert.GreaterOrEqual(t, offset, 0)
|
||||
assert.Less(t, offset, 1000)
|
||||
assert.Equal(t, 0, offset%10)
|
||||
|
||||
var foundDifferent bool
|
||||
for _, otherRoot := range []string{
|
||||
"/tmp/coder/worktree-b",
|
||||
"/tmp/coder/worktree-c",
|
||||
"/tmp/coder/worktree-d",
|
||||
} {
|
||||
if portOffset(otherRoot) != offset {
|
||||
foundDifferent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundDifferent, "expected typical worktree paths to use different offsets")
|
||||
}
|
||||
|
||||
func TestApplyPortOffsetSkipsExplicitPorts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
projectRoot := "/tmp/coder/worktree-offset"
|
||||
for i := range 100 {
|
||||
candidate := fmt.Sprintf("/tmp/coder/worktree-offset-%d", i)
|
||||
if portOffset(candidate) != 0 {
|
||||
projectRoot = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
offset := portOffset(projectRoot)
|
||||
require.NotZero(t, offset)
|
||||
|
||||
cfg := &devConfig{
|
||||
apiPort: 3000,
|
||||
webPort: 8080,
|
||||
proxyPort: 3010,
|
||||
coderMetricsPort: 2114,
|
||||
portOffsetEnabled: true,
|
||||
projectRoot: projectRoot,
|
||||
portExplicit: portExplicit{
|
||||
web: true,
|
||||
metrics: true,
|
||||
},
|
||||
}
|
||||
cfg.applyPortOffset()
|
||||
|
||||
assert.Equal(t, int64(3000+offset), cfg.apiPort)
|
||||
assert.Equal(t, int64(8080), cfg.webPort)
|
||||
assert.Equal(t, int64(3010+offset), cfg.proxyPort)
|
||||
assert.Equal(t, int64(2114), cfg.coderMetricsPort)
|
||||
assert.Equal(t, portSourceOffset, cfg.apiPortSource)
|
||||
assert.Equal(t, portSourceExplicit, cfg.webPortSource)
|
||||
assert.Equal(t, portSourceOffset, cfg.proxyPortSource)
|
||||
assert.Equal(t, portSourceExplicit, cfg.metricsPortSource)
|
||||
}
|
||||
|
||||
func TestApplyPortOffsetDisabledUsesDefaultPorts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
projectRoot := "/tmp/coder/worktree-offset"
|
||||
for i := range 100 {
|
||||
candidate := fmt.Sprintf("/tmp/coder/worktree-offset-disabled-%d", i)
|
||||
if portOffset(candidate) != 0 {
|
||||
projectRoot = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotZero(t, portOffset(projectRoot))
|
||||
|
||||
cfg := &devConfig{
|
||||
apiPort: 3000,
|
||||
webPort: 8080,
|
||||
proxyPort: 3010,
|
||||
coderMetricsPort: 2114,
|
||||
projectRoot: projectRoot,
|
||||
}
|
||||
cfg.applyPortOffset()
|
||||
|
||||
assert.Equal(t, int64(3000), cfg.apiPort)
|
||||
assert.Equal(t, int64(8080), cfg.webPort)
|
||||
assert.Equal(t, int64(3010), cfg.proxyPort)
|
||||
assert.Equal(t, int64(2114), cfg.coderMetricsPort)
|
||||
assert.Zero(t, cfg.portOffset)
|
||||
assert.Empty(t, cfg.apiPortSource)
|
||||
assert.Empty(t, cfg.webPortSource)
|
||||
assert.Empty(t, cfg.proxyPortSource)
|
||||
assert.Empty(t, cfg.metricsPortSource)
|
||||
assert.Equal(t, "API: 3000", portBannerLine("API", cfg.apiPort, cfg.apiPortSource, cfg.portOffset))
|
||||
}
|
||||
|
||||
func TestPortOffsetDefaultPortsDoNotOverlap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ports := []struct {
|
||||
name string
|
||||
base int
|
||||
}{
|
||||
{name: "API", base: 3000},
|
||||
{name: "Web UI", base: 8080},
|
||||
{name: "Proxy", base: 3010},
|
||||
{name: "Coder metrics", base: 2114},
|
||||
}
|
||||
seen := make(map[int]string)
|
||||
for bucket := range portOffsetBuckets {
|
||||
offset := bucket * portOffsetStep
|
||||
for _, port := range ports {
|
||||
effective := port.base + offset
|
||||
if other, ok := seen[effective]; ok {
|
||||
t.Fatalf("%s collides with %s on port %d", port.name, other, effective)
|
||||
}
|
||||
seen[effective] = fmt.Sprintf("%s with offset %d", port.name, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopInCoder(t *testing.T) {
|
||||
t.Run("DEVELOP_IN_CODER", func(t *testing.T) {
|
||||
t.Setenv("DEVELOP_IN_CODER", "1")
|
||||
@@ -431,15 +552,17 @@ func TestDevConfigResolveEnv(t *testing.T) {
|
||||
t.Setenv("CODER_SESSION_TOKEN", "leaked")
|
||||
t.Setenv("CODER_URL", "https://leaked.example.com")
|
||||
|
||||
wd, _ := os.Getwd()
|
||||
cfg := &devConfig{apiPort: 3000, accessURL: defaultAccessURL}
|
||||
require.NoError(t, cfg.resolveEnv())
|
||||
|
||||
wd, _ := os.Getwd()
|
||||
assert.Equal(t, wd, cfg.projectRoot)
|
||||
assert.Equal(t, filepath.Join(wd, "build",
|
||||
fmt.Sprintf("coder_%s_%s", runtime.GOOS, runtime.GOARCH)), cfg.binaryPath)
|
||||
assert.Equal(t, filepath.Join(wd, ".coderv2"), cfg.configDir)
|
||||
assert.Equal(t, "http://127.0.0.1:3000", cfg.accessURL)
|
||||
assert.Equal(t, int64(3000), cfg.apiPort)
|
||||
assert.Zero(t, cfg.portOffset)
|
||||
|
||||
// Should have unset leaked env vars.
|
||||
assert.Empty(t, os.Getenv("CODER_SESSION_TOKEN"))
|
||||
@@ -454,11 +577,92 @@ func TestDevConfigResolveEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevConfigResolveEnvUsesDefaultPortsWithoutPortOffset(t *testing.T) {
|
||||
t.Setenv("CODER_SESSION_TOKEN", "")
|
||||
t.Setenv("CODER_URL", "")
|
||||
|
||||
baseRoot := t.TempDir()
|
||||
projectRoot := filepath.Join(baseRoot, "worktree")
|
||||
for i := range 100 {
|
||||
candidate := filepath.Join(baseRoot, fmt.Sprintf("worktree-default-%d", i))
|
||||
if portOffset(candidate) != 0 {
|
||||
projectRoot = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotZero(t, portOffset(projectRoot))
|
||||
require.NoError(t, os.MkdirAll(projectRoot, 0o755))
|
||||
t.Chdir(projectRoot)
|
||||
|
||||
cfg := &devConfig{
|
||||
apiPort: 3000,
|
||||
webPort: 8080,
|
||||
proxyPort: 3010,
|
||||
coderMetricsPort: 2114,
|
||||
accessURL: defaultAccessURL,
|
||||
}
|
||||
require.NoError(t, cfg.resolveEnv())
|
||||
|
||||
assert.Equal(t, projectRoot, cfg.projectRoot)
|
||||
assert.Equal(t, int64(3000), cfg.apiPort)
|
||||
assert.Equal(t, int64(8080), cfg.webPort)
|
||||
assert.Equal(t, int64(3010), cfg.proxyPort)
|
||||
assert.Equal(t, int64(2114), cfg.coderMetricsPort)
|
||||
assert.Zero(t, cfg.portOffset)
|
||||
assert.Empty(t, cfg.apiPortSource)
|
||||
assert.Empty(t, cfg.webPortSource)
|
||||
assert.Empty(t, cfg.proxyPortSource)
|
||||
assert.Empty(t, cfg.metricsPortSource)
|
||||
assert.Equal(t, "http://127.0.0.1:3000", cfg.accessURL)
|
||||
}
|
||||
|
||||
func TestDevConfigResolveEnvAppliesPortOffsetWhenEnabled(t *testing.T) {
|
||||
t.Setenv("CODER_SESSION_TOKEN", "")
|
||||
t.Setenv("CODER_URL", "")
|
||||
|
||||
baseRoot := t.TempDir()
|
||||
projectRoot := filepath.Join(baseRoot, "worktree")
|
||||
for i := range 100 {
|
||||
candidate := filepath.Join(baseRoot, fmt.Sprintf("worktree-%d", i))
|
||||
if portOffset(candidate) != 0 {
|
||||
projectRoot = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotZero(t, portOffset(projectRoot))
|
||||
require.NoError(t, os.MkdirAll(projectRoot, 0o755))
|
||||
t.Chdir(projectRoot)
|
||||
|
||||
cfg := &devConfig{
|
||||
apiPort: 3000,
|
||||
webPort: 8080,
|
||||
proxyPort: 3010,
|
||||
coderMetricsPort: 2114,
|
||||
portOffsetEnabled: true,
|
||||
accessURL: defaultAccessURL,
|
||||
}
|
||||
require.NoError(t, cfg.resolveEnv())
|
||||
|
||||
offset := portOffset(projectRoot)
|
||||
assert.Equal(t, projectRoot, cfg.projectRoot)
|
||||
assert.Equal(t, int64(3000+offset), cfg.apiPort)
|
||||
assert.Equal(t, int64(8080+offset), cfg.webPort)
|
||||
assert.Equal(t, int64(3010+offset), cfg.proxyPort)
|
||||
assert.Equal(t, int64(2114+offset), cfg.coderMetricsPort)
|
||||
assert.Equal(t, offset, cfg.portOffset)
|
||||
assert.Equal(t, portSourceOffset, cfg.apiPortSource)
|
||||
assert.Equal(t, fmt.Sprintf("http://127.0.0.1:%d", 3000+offset), cfg.accessURL)
|
||||
}
|
||||
|
||||
func TestDevConfigResolveEnvExplicitAccessURL(t *testing.T) {
|
||||
t.Setenv("CODER_SESSION_TOKEN", "")
|
||||
t.Setenv("CODER_URL", "")
|
||||
|
||||
cfg := &devConfig{apiPort: 5000, accessURL: "http://myhost:5000"}
|
||||
cfg := &devConfig{
|
||||
apiPort: 5000,
|
||||
accessURL: "http://myhost:5000",
|
||||
portExplicit: portExplicit{api: true},
|
||||
}
|
||||
require.NoError(t, cfg.resolveEnv())
|
||||
assert.Equal(t, "http://myhost:5000", cfg.accessURL)
|
||||
}
|
||||
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Summarize failed Go tests from go test JSON output.
|
||||
|
||||
set -euo pipefail
|
||||
# shellcheck source=scripts/lib.sh
|
||||
# shellcheck disable=SC1091
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
cdroot
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
error "Usage: go-test-failure-summary.sh <go-test.json>"
|
||||
fi
|
||||
|
||||
results_file=$1
|
||||
if [[ ! -s "$results_file" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null; then
|
||||
error "jq is required to summarize Go test failures."
|
||||
fi
|
||||
|
||||
jq -sr '
|
||||
def clean_block:
|
||||
tostring
|
||||
| gsub("\u001b\\[[0-9;?]*[ -/]*[@-~]"; "")
|
||||
| gsub("```"; "``");
|
||||
def clean_inline:
|
||||
tostring | gsub("`"; "") | gsub("[\r\n]"; " ");
|
||||
def truncate($max):
|
||||
if length > $max then .[0:$max] + "..." else . end;
|
||||
def terminal_action:
|
||||
.Action == "pass" or .Action == "fail" or .Action == "skip";
|
||||
def test_key:
|
||||
(.Package // "") + "\u0000" + (.Test // "");
|
||||
def output_for($events; $package; $test):
|
||||
[
|
||||
$events[]
|
||||
| select(.Action == "output")
|
||||
| select((.Package // "") == $package)
|
||||
| select((.Test // "") == $test)
|
||||
| .Output // ""
|
||||
]
|
||||
| join("")
|
||||
| clean_block
|
||||
| if . == "" then "No output recorded." else . end
|
||||
| truncate(600);
|
||||
|
||||
map(select(type == "object")) as $events
|
||||
| [
|
||||
$events
|
||||
| to_entries[]
|
||||
| .value + {idx: .key}
|
||||
| select((.Test // "") != "")
|
||||
| select(terminal_action)
|
||||
] as $terminal_tests
|
||||
| [
|
||||
$terminal_tests
|
||||
| group_by(test_key)
|
||||
| .[]
|
||||
| max_by(.idx)
|
||||
| select(.Action == "fail")
|
||||
| {
|
||||
package: ((.Package // "unknown") | clean_inline),
|
||||
test: ((.Test // "unknown") | clean_inline),
|
||||
elapsed: (.Elapsed // 0),
|
||||
output: output_for($events; (.Package // ""); (.Test // ""))
|
||||
}
|
||||
] as $failures
|
||||
| if ($failures | length) == 0 then
|
||||
empty
|
||||
else
|
||||
($failures | length) as $failed
|
||||
| ($failures | map(.package) | unique | length) as $packages
|
||||
| ([
|
||||
$events[]
|
||||
| select((.Test // "") == "")
|
||||
| select(.Action == "pass" or .Action == "fail")
|
||||
| .Elapsed // 0
|
||||
] | add // 0) as $duration
|
||||
| ([
|
||||
$events[]
|
||||
| select((.Test // "") == "")
|
||||
| select(.Action == "fail")
|
||||
| .Package // empty
|
||||
] | unique | length) as $package_failures
|
||||
| [
|
||||
"## Go test failures (\($failed) in \($packages))",
|
||||
"- Duration: \($duration)s",
|
||||
"- Package failures: \($package_failures)",
|
||||
"",
|
||||
($failures[]
|
||||
| "### \(.package) :: \(.test)\n"
|
||||
+ "- Elapsed: \(.elapsed)s\n\n"
|
||||
+ "```\n\(.output)\n```\n")
|
||||
]
|
||||
| join("\n")
|
||||
end
|
||||
' "$results_file"
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Summarize failed Playwright tests from the JSON reporter output.
|
||||
|
||||
set -euo pipefail
|
||||
# shellcheck source=scripts/lib.sh
|
||||
# shellcheck disable=SC1091
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
cdroot
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
error "Usage: playwright-failure-summary.sh <results.json>"
|
||||
fi
|
||||
|
||||
results_file=$1
|
||||
if [[ ! -f "$results_file" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null; then
|
||||
error "jq is required to summarize Playwright failures."
|
||||
fi
|
||||
|
||||
artifact="playwright-artifacts-${MATRIX_VARIANT:-unknown}-${GITHUB_SHA_SHORT:-unknown}"
|
||||
|
||||
jq -r --arg artifact "$artifact" --arg root "$PROJECT_ROOT" '
|
||||
def clean_block:
|
||||
tostring
|
||||
| gsub("\u001b\\[[0-9;]*[A-Za-z]"; "")
|
||||
| gsub("```"; "``");
|
||||
def clean_inline:
|
||||
tostring | gsub("`"; "");
|
||||
def truncate($max):
|
||||
if length > $max then .[0:$max] + "..." else . end;
|
||||
def failure_status:
|
||||
. == "failed" or . == "timedOut" or . == "interrupted";
|
||||
def relpath($root):
|
||||
if startswith($root + "/") then .[($root | length) + 1:]
|
||||
elif startswith("site/") then .
|
||||
elif startswith("e2e/") then "site/" + .
|
||||
else "site/e2e/" + .
|
||||
end;
|
||||
def all_specs($titles):
|
||||
([$titles[], (.title // empty)] | map(select(. != ""))) as $next_titles
|
||||
| (
|
||||
.specs[]?
|
||||
| . + {
|
||||
titlePath: ($next_titles + ([.title // ""] | map(select(. != ""))))
|
||||
}
|
||||
),
|
||||
(.suites[]? | all_specs($next_titles));
|
||||
def failure_entries:
|
||||
[
|
||||
.suites[]?
|
||||
| all_specs([]) as $spec
|
||||
| $spec.tests[]? as $test
|
||||
| select(($test.status // "") != "flaky")
|
||||
| select(
|
||||
(($test.status // "") == "unexpected")
|
||||
or any($test.results[]?; .status | failure_status)
|
||||
)
|
||||
| ([ $test.results[]? | select(.status | failure_status) ][0]
|
||||
// ($test.results[0] // {})) as $result
|
||||
| ((($result.error.message // "") | clean_block) as $message
|
||||
| (($result.error.stack // "") | clean_block) as $stack
|
||||
| {
|
||||
file: (($spec.file // "") | relpath($root)),
|
||||
line: ($spec.line // 0),
|
||||
title: (($spec.titlePath // [$spec.title // ""]) | join(" > ") | clean_inline),
|
||||
project: (($test.projectName // "unknown") | clean_inline),
|
||||
message: (if $message != "" then $message else $stack end | if . != "" then . else "No error message recorded." end | truncate(600)),
|
||||
attachments: ([ $result.attachments[]? | .name // empty | clean_inline ] | unique)
|
||||
})
|
||||
];
|
||||
failure_entries as $entries
|
||||
| if ($entries | length) == 0 then
|
||||
empty
|
||||
else
|
||||
(.stats // {}) as $stats
|
||||
| ($stats.unexpected // 0) as $stats_failed
|
||||
| ([($stats_failed | tonumber), ($entries | length)] | max) as $failed
|
||||
| (($stats.expected // 0) + ($stats.unexpected // 0) + ($stats.flaky // 0) + ($stats.skipped // 0)) as $computed_total
|
||||
| ($stats.total // $computed_total) as $total
|
||||
| [
|
||||
"## Playwright failures (\($failed) of \($total))",
|
||||
"- Duration: \($stats.duration // 0)ms",
|
||||
"- Skipped: \($stats.skipped // 0), Flaky: \($stats.flaky // 0)",
|
||||
"- Artifact: `\($artifact)` (download from the run summary)",
|
||||
"",
|
||||
($entries[]
|
||||
| "### \(.file):\(.line)\n"
|
||||
+ "- Test: `\(.title)`\n"
|
||||
+ "- Project: `\(.project)`\n"
|
||||
+ "- Attachments:\n"
|
||||
+ (if (.attachments | length) == 0 then
|
||||
" - None recorded in artifact `\($artifact)`"
|
||||
else
|
||||
(.attachments | map(" - `\(.)` in artifact `\($artifact)`") | join("\n"))
|
||||
end)
|
||||
+ "\n\n```\n\(.message)\n```\n")
|
||||
]
|
||||
| join("\n")
|
||||
end
|
||||
' "$results_file" | sed -E $'s/\x1b\[[0-9;]*m//g'
|
||||
Reference in New Issue
Block a user