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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 120 additions & 19 deletions .notturno/gates
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,20 @@
# loopback suites below (fleet-dependent; they self-skip without
# an sshd and mutate ~/.ssh/authorized_keys when one exists).
# Vault-dependent tests self-skip on their own existsSync/env
# guards, exactly as in CI. A `pnpm -r run build` precedes the
# suites, as in CI (several suites read built bundles).
# guards, exactly as in CI. A `pnpm -r run build` (step `build`)
# precedes the suites, as in CI (several suites read built bundles).
# The suites run in CI's environment (#2007), not the sandbox's:
# a scrubbed env (`env -i` + PATH/TMPDIR/locale/USER) with CI=true,
# GITHUB_ACTIONS=true, RUNNER_TEMP, GITHUB_BASE_REF=<base> (a
# pull_request run), TZ=UTC, and a fresh EMPTY scratch HOME — the
# GitHub runner's HOME has no ~/.amico, no ~/.config/opencode, no
# fleet; the sandbox's HOME has the implementer's opencode config
# and the env carries AWS/GH tokens + NOTTURNO_*, which tests read.
# A red suite names its failing test files (first 5) in RESULT:
# failed: vitest:extension [test/a.test.ts, test/b.test.ts]
# Divergences the gate cannot erase are printed as `warn:` lines:
# node major != CI's 20, running as root (CI's runner is not —
# chmod-000 tests cannot fail for root).
# NOT here (need the vendored engine binary / bun materialization / Julia /
# PyPI): build-binary, engine-tests, vsix-gate, boot-smoke, schema-roundtrip.
#
Expand All @@ -30,7 +42,9 @@
# Unset/unresolvable → the nearest of origin/lane/* and
# origin/main (fewest commits from merge-base to HEAD).
# No base at all → every package's suite runs.
# NOTTURNO_GATES_STEPS comma list subset of drift,typecheck,vitest (default all)
# NOTTURNO_GATES_STEPS comma list subset of drift,typecheck,build,vitest (default all;
# build only runs when vitest has packages to test)
# NOTTURNO_GATES_HERMETIC=0 run the suites in the ambient env/HOME (debugging only)
set -uo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
Expand All @@ -45,16 +59,16 @@ PLAN=0
for a in "$@"; do
case "$a" in
--plan) PLAN=1 ;;
-h|--help) sed -n '2,36p' "$0"; exit 0 ;;
-h|--help) sed -n '2,52p' "$0"; exit 0 ;;
*) echo "[notturno-gates] RESULT: RED — unknown argument: $a"; exit 2 ;;
esac
done

STEPS="${NOTTURNO_GATES_STEPS:-drift,typecheck,vitest}"
STEPS="${NOTTURNO_GATES_STEPS:-drift,typecheck,build,vitest}"
for s in ${STEPS//,/ }; do
case "$s" in
drift|typecheck|vitest) ;;
*) echo "[notturno-gates] RESULT: RED — unknown step '$s' in NOTTURNO_GATES_STEPS (known: drift,typecheck,vitest)"; exit 2 ;;
drift|typecheck|build|vitest) ;;
*) echo "[notturno-gates] RESULT: RED — unknown step '$s' in NOTTURNO_GATES_STEPS (known: drift,typecheck,build,vitest)"; exit 2 ;;
esac
done
want() { case ",$STEPS," in *",$1,"*) return 0 ;; *) return 1 ;; esac; }
Expand Down Expand Up @@ -146,38 +160,125 @@ PASSED=""
FAILED=""
HINTS=""
T0=$SECONDS
STEP_DETAIL="" # a step may set this to a short " [..]" suffix for the RESULT line
step() { # step <name> <hint-on-failure> <cmd...>
local name="$1" hint="$2" t=$SECONDS rc
shift 2
STEP_DETAIL=""
say "── $name: $*"
"$@"
rc=$?
if [ "$rc" -eq 0 ]; then
PASSED="$PASSED $name"
say "── $name: ok ($((SECONDS - t))s)"
else
FAILED="$FAILED $name"
FAILED="$FAILED $name$STEP_DETAIL"
[ -n "$hint" ] && HINTS="$HINTS $hint;"
say "── $name: FAIL exit $rc ($((SECONDS - t))s)"
say "── $name: FAIL exit $rc ($((SECONDS - t))s)${STEP_DETAIL}"
fi
}

# ── CI's environment for the vitest suites (#2007) ───────────────────────────
SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/notturno-gates.XXXXXX")" || { echo "[notturno-gates] RESULT: RED — mktemp failed"; exit 2; }
trap 'chmod -R u+rwx "$SCRATCH" 2>/dev/null; rm -rf "$SCRATCH"' EXIT
CI_TMP="$SCRATCH/tmp"
mkdir -p "$CI_TMP" "$SCRATCH/runner-temp"

# run a command the way CI's `fast` job runs a test step
ci_env() {
if [ "${NOTTURNO_GATES_HERMETIC:-1}" = "0" ]; then "$@"; return; fi
local home
home="$(mktemp -d "$SCRATCH/home.XXXXXX")" || return 2 # fresh + empty per suite
local keep=() v
for v in PATH LANG LC_ALL USER LOGNAME SHELL; do
[ -n "${!v:-}" ] && keep+=("$v=${!v}")
done
env -i "${keep[@]}" \
HOME="$home" TMPDIR="$CI_TMP" RUNNER_TEMP="$SCRATCH/runner-temp" TZ=UTC \
CI=true GITHUB_ACTIONS=true ${BASE:+GITHUB_BASE_REF="${BASE#origin/}"} \
"$@"
}

vitest_bin() { # the package's own vitest bin (no pnpm/corepack under the scratch HOME)
if [ -x "packages/$1/node_modules/.bin/vitest" ]; then echo "$ROOT/packages/$1/node_modules/.bin/vitest"; else echo ""; fi
}

# failed_files <vitest-json> [quarantine-gate-log] → "test/a.test.ts, test/b.test.ts, +N more" (first 5)
failed_files() {
node - "$@" <<'NODE' 2>/dev/null
const fs = require("fs"), path = require("path");
const [json, gateLog] = process.argv.slice(2);
const files = [];
const add = (f) => { if (f && !files.includes(f)) files.push(f); };
if (gateLog && fs.existsSync(gateLog)) {
// quarantine-gate violations: "... FAIL <kind> <suite>::<file>::<test>: ..."
for (const m of fs.readFileSync(gateLog, "utf8").matchAll(/ FAIL [\w-]+ [^:\s]+::([^:]+)::/g)) add(m[1]);
}
if (!files.length && json && fs.existsSync(json)) {
let r; try { r = JSON.parse(fs.readFileSync(json, "utf8")); } catch { r = null; }
for (const t of (r && r.testResults) || []) {
const bad = t.status === "failed" || (t.assertionResults || []).some((a) => a.status === "failed");
if (bad) add(path.relative(process.env.PKG_DIR || process.cwd(), t.name));
}
}
if (files.length) {
const head = files.slice(0, 5);
if (files.length > 5) head.push(`+${files.length - 5} more`);
process.stdout.write(head.join(", "));
}
NODE
}

set_detail() { # set_detail <pkg> <json> [gate-log]
local f
f="$(PKG_DIR="$ROOT/packages/$1" failed_files "$2" "${3:-}")"
[ -n "$f" ] && STEP_DETAIL=" [$f]"
return 0
}

vitest_args() { # common CI flags + exclusions + a JSON report
local g
VARGS=(run --passWithNoTests --exclude '**/slow/**')
for g in $ENV_DEPENDENT_EXCLUDES; do VARGS+=(--exclude "$g"); done
VARGS+=(--reporter=default --reporter=json "--outputFile.json=$1")
}

run_vitest() { # run_vitest <pkg> <json>
local bin
bin="$(vitest_bin "$1")"
vitest_args "$2"
if [ -n "$bin" ]; then
(cd "packages/$1" && ci_env "$bin" "${VARGS[@]}")
else
(cd "packages/$1" && ci_env pnpm exec vitest "${VARGS[@]}")
fi
}

run_amico_run_suite() {
local out vexit excl=() g
out="${TMPDIR:-/tmp}/notturno-amico-run-$$.json"
for g in $ENV_DEPENDENT_EXCLUDES; do excl+=(--exclude "$g"); done
(cd packages/amico-run && pnpm exec vitest run --passWithNoTests --exclude '**/slow/**' "${excl[@]}" \
--reporter=default --reporter=json --outputFile.json="$out")
local out="$SCRATCH/vitest-amico-run.json" glog="$SCRATCH/quarantine-amico-run.log" vexit qexit
run_vitest amico-run "$out"
vexit=$?
(cd packages/amico-run && node ../../scripts/test_quarantine_gate.mjs --runner-exit "$vexit" "$out")
(cd packages/amico-run && node ../../scripts/test_quarantine_gate.mjs --runner-exit "$vexit" "$out") 2>&1 | tee "$glog"
qexit=${PIPESTATUS[0]}
[ "$qexit" -ne 0 ] && set_detail amico-run "$out" "$glog"
return "$qexit"
}

run_pkg_suite() {
local p="$1" excl=() g
for g in $ENV_DEPENDENT_EXCLUDES; do excl+=(--exclude "$g"); done
(cd "packages/$p" && pnpm exec vitest run --passWithNoTests --exclude '**/slow/**' "${excl[@]}")
local p="$1" out="$SCRATCH/vitest-$1.json" rc
run_vitest "$p" "$out"
rc=$?
[ "$rc" -ne 0 ] && set_detail "$p" "$out"
return "$rc"
}

if want vitest && [ -n "$SELECTED" ]; then
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo '?')"
say "vitest env: $([ "${NOTTURNO_GATES_HERMETIC:-1}" = "0" ] && echo 'AMBIENT (NOTTURNO_GATES_HERMETIC=0)' || echo "CI mirror — env -i, CI=true GITHUB_ACTIONS=true TZ=UTC, scratch HOME under $SCRATCH${BASE:+, GITHUB_BASE_REF=${BASE#origin/}}"); node $(node --version 2>/dev/null || echo '?')"
[ "$NODE_MAJOR" != "20" ] && say "warn: node major $NODE_MAJOR != CI's 20 (actions/setup-node node-version: 20) — a red that CI would not show may be a node-version difference"
[ "$(id -u 2>/dev/null)" = "0" ] && say "warn: running as root — CI's runner is unprivileged; permission tests (chmod 000/0500 dirs) cannot fail for root"
fi

if want drift; then
step drift "overlay edit without a manifest refresh? run 'node packages/app-bundle/scripts/refresh_manifest.mjs' then drift_gate, and commit manifest.json" \
node packages/app-bundle/scripts/drift_gate.mjs
Expand All @@ -186,7 +287,7 @@ if want typecheck; then
step typecheck "" pnpm -r run typecheck
fi
if want vitest && [ -n "$SELECTED" ]; then
step build "" pnpm -r run build
want build && step build "" pnpm -r run build
for p in $SELECTED; do
if [ "$p" = "amico-run" ]; then
step "vitest:$p" "" run_amico_run_suite
Expand Down
79 changes: 78 additions & 1 deletion packages/amico-run/test/notturno_gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// origin/lane/* (or origin/main), and maps the diff to vitest packages.
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, appendFileSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";

Expand Down Expand Up @@ -174,6 +174,83 @@ describe(".notturno/gates — the night runner's pre-push gate (#1980)", () => {
});
});

// #2007: in the Modal sandbox the extension/amico-run vitest steps went red
// whatever the diff, and the summary hid which tests. The vitest steps must run
// the way CI's `fast` job does (CI=true, GITHUB_ACTIONS=true, an empty runner
// HOME, no ambient secrets/dev-shell env), and a red suite must name its
// failing files in the RESULT line (the runner surfaces only the last lines).
describe(".notturno/gates — vitest steps mirror CI and name failing files (#2007)", () => {
let fx: string;
const VITEST_PKG = join(REPO, "packages", "amico-run", "node_modules", "vitest");

/** give a fixture package a real vitest (symlinked from this checkout) */
function linkVitest(pkg: string) {
const nm = join(fx, "packages", pkg, "node_modules");
mkdirSync(join(nm, ".bin"), { recursive: true });
symlinkSync(VITEST_PKG, join(nm, "vitest"));
symlinkSync(join(VITEST_PKG, "vitest.mjs"), join(nm, ".bin", "vitest"));
}

beforeEach(() => {
fx = makeFixture();
linkVitest("foo");
linkVitest("bar");
});
afterEach(() => {
rmSync(fx, { recursive: true, force: true });
rmSync(upstreamOf(fx), { recursive: true, force: true });
});

test("the vitest step runs under CI's env: CI=true, GITHUB_ACTIONS=true, a scratch empty HOME, ambient secrets scrubbed", () => {
const outerHome = process.env.HOME ?? "";
put(fx, "packages/foo/test/env.test.ts", [
`import { test, expect } from "vitest";`,
`import { existsSync, readdirSync } from "node:fs";`,
`import { join } from "node:path";`,
`test("ci env", () => {`,
` expect(process.env.CI).toBe("true");`,
` expect(process.env.GITHUB_ACTIONS).toBe("true");`,
` const home = process.env.HOME ?? "";`,
` expect(home).not.toBe(${JSON.stringify(outerHome)});`,
` expect(home).not.toBe("");`,
` expect(existsSync(join(home, ".amico"))).toBe(false);`,
` expect(existsSync(join(home, ".config", "opencode"))).toBe(false);`,
` expect(readdirSync(home)).toEqual([]);`,
` expect(process.env.AWS_SECRET_ACCESS_KEY).toBeUndefined();`,
` expect(process.env.GH_TOKEN).toBeUndefined();`,
` expect(process.env.AMICO_LEAK).toBeUndefined();`,
` expect(process.env.NOTTURNO_ISSUE).toBeUndefined();`,
`});`,
``,
].join("\n"));
const r = run("./.notturno/gates", [], fx, {
NOTTURNO_GATES_STEPS: "vitest",
AWS_SECRET_ACCESS_KEY: "leak",
GH_TOKEN: "ghp_leak",
AMICO_LEAK: "1",
NOTTURNO_ISSUE: "2007",
});
expect(r.status, r.out).toBe(0);
expect(r.out).toMatch(/1 passed/);
expect(lastLine(r.stdout)).toMatch(/GREEN/);
});

test("a red suite names its failing test files (first 5) in the RESULT line", () => {
for (const n of ["a", "b", "c", "d", "e", "f", "g"]) {
put(fx, `packages/foo/test/${n}.test.ts`, `import { test, expect } from "vitest";\ntest("${n}", () => { expect(1).toBe(2); });\n`);
}
put(fx, "packages/foo/test/ok.test.ts", `import { test, expect } from "vitest";\ntest("ok", () => { expect(1).toBe(1); });\n`);
put(fx, "packages/bar/test/bad.test.ts", `import { test, expect } from "vitest";\ntest("bad", () => { expect(1).toBe(2); });\n`);
const r = run("./.notturno/gates", [], fx, { NOTTURNO_GATES_STEPS: "vitest" });
expect(r.status, r.out).not.toBe(0);
const summary = lastLine(r.stdout);
expect(summary).toMatch(/RED/);
expect(summary).toContain("vitest:foo [test/a.test.ts, test/b.test.ts, test/c.test.ts, test/d.test.ts, test/e.test.ts, +2 more]");
expect(summary).toContain("vitest:bar [test/bad.test.ts]");
expect(summary).not.toContain("ok.test.ts");
});
});

describe("this repo's committed tree", () => {
test("AGENTS.md's verification-gates section teaches the overlay rule: refresh_manifest, then drift_gate, before commit", () => {
const agents = readFileSync(join(REPO, "AGENTS.md"), "utf8");
Expand Down
Loading