Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16a1210abd |
@@ -1,91 +0,0 @@
|
||||
# gate-check-v3 — automated PR gate detector
|
||||
#
|
||||
# Runs on every open PR (push/synchronize) and hourly via cron.
|
||||
# Posts a structured [gate-check-v3] STATUS: comment on the PR.
|
||||
#
|
||||
# Inputs:
|
||||
# PR_NUMBER — set via ${{ github.event.pull_request.number }} from the trigger
|
||||
# POST_COMMENT — "true" to post/update comment on PR
|
||||
#
|
||||
# Gating logic (MVP signals 1,2,3,6):
|
||||
# 1. Author-aware agent-tag comment scan
|
||||
# 2. REQUEST_CHANGES reviews state machine
|
||||
# 3. Staleness detection (SOP-12: review.commit_id != PR.head_sha + >1 working day)
|
||||
# 6. CI required-checks awareness
|
||||
#
|
||||
# Exit code: 0=CLEAR, 1=BLOCKED, 2=ERROR
|
||||
|
||||
name: gate-check-v3
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
schedule:
|
||||
# Hourly: refresh all open PRs
|
||||
- cron: '8 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to check (omit for all open PRs)'
|
||||
required: false
|
||||
type: string
|
||||
post_comment:
|
||||
description: 'Post comment on PR'
|
||||
required: false
|
||||
type: string
|
||||
default: 'true'
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
gate-check:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # Never block on our own detector failing
|
||||
steps:
|
||||
- name: Check out base branch (for the script)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha || github.ref_name }}
|
||||
|
||||
- name: Run gate-check-v3 (single PR mode)
|
||||
if: github.event_name == 'pull_request_target' || github.event.inputs.pr_number != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
|
||||
POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/gate-check-v3/gate_check.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pr "$PR_NUMBER" \
|
||||
$([ "$POST_COMMENT" = "true" ] && echo "--post-comment")
|
||||
echo "verdict=$?" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run gate-check-v3 (all open PRs — cron mode)
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Fetch all open PRs and run gate-check on each
|
||||
pr_numbers=$(python3 -c "
|
||||
import urllib.request, json, os
|
||||
token = os.environ['GITEA_TOKEN']
|
||||
req = urllib.request.Request(
|
||||
'https://git.moleculesai.app/api/v1/repos/${{ github.repository }}/pulls?state=open&limit=100',
|
||||
headers={'Authorization': f'token {token}', 'Accept': 'application/json'}
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
prs = json.loads(r.read())
|
||||
for pr in prs:
|
||||
print(pr['number'])
|
||||
")
|
||||
for pr in $pr_numbers; do
|
||||
echo "Checking PR #$pr..."
|
||||
python3 tools/gate-check-v3/gate_check.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pr "$pr" \
|
||||
--post-comment \
|
||||
|| true
|
||||
done
|
||||
@@ -29,15 +29,13 @@ name: Sweep stale AWS Secrets Manager secrets
|
||||
# reconciler enumerator) is filed as a separate controlplane
|
||||
# issue. This sweeper is the immediate cost-relief stopgap.
|
||||
#
|
||||
# AWS credentials: the confirmed Gitea secrets are AWS_ACCESS_KEY_ID /
|
||||
# AWS_SECRET_ACCESS_KEY (the molecule-cp IAM user). These are the same
|
||||
# credentials used by the rest of the platform. The dedicated
|
||||
# AWS_JANITOR_* naming (which the original GitHub workflow used) was
|
||||
# never populated in Gitea — the existing secrets are AWS_ACCESS_KEY_ID /
|
||||
# AWS_SECRET_ACCESS_KEY (per issue #425 §425 audit). These DO have
|
||||
# secretsmanager:ListSecrets (the production molecule-cp principal);
|
||||
# if ListSecrets is revoked in future, a dedicated janitor principal
|
||||
# would need to be created and the Gitea secret names updated here.
|
||||
# IAM principal: AWS_JANITOR_ACCESS_KEY_ID / AWS_JANITOR_SECRET_ACCESS_KEY.
|
||||
# This is a DEDICATED principal — the production `molecule-cp` IAM
|
||||
# user lacks `secretsmanager:ListSecrets` (it only has
|
||||
# Get/Create/Update/Delete on specific resources, scoped to its
|
||||
# operational needs). The janitor needs ListSecrets across the
|
||||
# `molecule/tenant/*` prefix, which warrants a separate principal so
|
||||
# we don't broaden the prod-CP policy.
|
||||
#
|
||||
# Safety: the script's MAX_DELETE_PCT gate (default 50%, mirroring
|
||||
# sweep-cf-orphans.yml — tenant secrets are durable by design, unlike
|
||||
@@ -73,8 +71,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
AWS_REGION: ${{ secrets.AWS_REGION || 'us-east-1' }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_JANITOR_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_JANITOR_SECRET_ACCESS_KEY }}
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
CP_STAGING_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
|
||||
@@ -101,11 +99,13 @@ jobs:
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::skipping sweep — secrets not configured: ${missing[*]}"
|
||||
echo "::warning::set them at Settings → Secrets and Variables → Actions, then rerun."
|
||||
echo "::warning::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/* (the prod molecule-cp principal lacks ListSecrets)."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::sweep cannot run — required secrets missing: ${missing[*]}"
|
||||
echo "::error::set them at Settings → Secrets and Variables → Actions, or disable this workflow."
|
||||
echo "::error::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/*."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required secrets present ✓"
|
||||
|
||||
@@ -33,11 +33,6 @@ name: Sweep stale Cloudflare DNS records
|
||||
# gate halts before damage. Decision-function unit tests in
|
||||
# scripts/ops/test_sweep_cf_decide.py (#2027) cover the rule
|
||||
# classifier.
|
||||
#
|
||||
# Secrets: CF_API_TOKEN, CF_ZONE_ID, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||
# are confirmed existing per issue #425 §425 audit. CP_ADMIN_API_TOKEN and
|
||||
# CP_STAGING_ADMIN_API_TOKEN are unconfirmed — if missing, the verify step
|
||||
# (schedule → hard-fail, dispatch → soft-skip) surfaces it clearly.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
|
||||
@@ -28,11 +28,6 @@ name: Sweep stale Cloudflare Tunnels
|
||||
# Safety: the script's MAX_DELETE_PCT gate (default 90% — higher than
|
||||
# the DNS sweep's 50% because tenant-shaped tunnels are mostly
|
||||
# orphans by design) refuses to nuke past the threshold.
|
||||
#
|
||||
# Secrets: CF_API_TOKEN, CF_ACCOUNT_ID are confirmed existing per
|
||||
# issue #425 §425 audit. CP_ADMIN_API_TOKEN and CP_STAGING_ADMIN_API_TOKEN
|
||||
# are unconfirmed — if missing, the verify step (schedule → hard-fail,
|
||||
# dispatch → soft-skip) surfaces it clearly.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
|
||||
@@ -96,7 +96,6 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="workspace-node"
|
||||
aria-label={
|
||||
isMisconfigured && configurationError
|
||||
? `${data.name} workspace — agent not configured: ${configurationError}`
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ExternalConnectModal — the modal surfaced after creating a
|
||||
* runtime="external" workspace. Surfaces workspace_auth_token + ready-to-paste
|
||||
* snippets so the operator can configure their off-host agent.
|
||||
*
|
||||
* Coverage:
|
||||
* - Renders nothing when info=null
|
||||
* - Opens dialog when info is provided
|
||||
* - Default tab: "Universal MCP" when universal_mcp_snippet present, else "Python SDK"
|
||||
* - Tab switching between all available tabs
|
||||
* - Snippets show with auth_token replacing placeholders
|
||||
* - Copy button: calls clipboard API, shows "Copied!", clears after 1.5s
|
||||
* - Copy failure: shows fallback textarea
|
||||
* - "I've saved it — close" calls onClose
|
||||
* - Security warning: one-time token display
|
||||
* - Fields tab shows raw values
|
||||
* - Tabs hidden when their snippet is absent
|
||||
*
|
||||
* Fake timers: applied per-describe to avoid mixing with waitFor. Tests that
|
||||
* use waitFor (which needs real timers) run without fake timers. Tests that
|
||||
* verify setTimeout behavior use vi.useFakeTimers() + act(vi.advanceTimersByTime).
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ExternalConnectModal,
|
||||
type ExternalConnectionInfo,
|
||||
} from "../ExternalConnectModal";
|
||||
|
||||
const defaultInfo: ExternalConnectionInfo = {
|
||||
workspace_id: "ws-123",
|
||||
platform_url: "https://app.example.com",
|
||||
auth_token: "secret-auth-token-abc",
|
||||
registry_endpoint: "https://app.example.com/api/a2a/register",
|
||||
heartbeat_endpoint: "https://app.example.com/api/a2a/heartbeat",
|
||||
// Placeholders must EXACTLY match what the component searches for in
|
||||
// the string.replace() calls (the component does NOT normalise whitespace).
|
||||
// Python: 'AUTH_TOKEN = "...' (4 spaces), curl: WORKSPACE_AUTH_TOKEN="<paste>" (with quotes),
|
||||
// MCP/Hermes: MOLECULE_WORKSPACE_TOKEN="...", Codex: same with 1 space.
|
||||
curl_register_template:
|
||||
`curl -X POST https://app.example.com/api/a2a/register \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"auth_token": "WORKSPACE_AUTH_TOKEN=\"<paste from create response>\"", ...}'`,
|
||||
python_snippet:
|
||||
'AUTH_TOKEN = "<paste from create response>"\nAPI_URL = "https://app.example.com"',
|
||||
universal_mcp_snippet:
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
hermes_channel_snippet:
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
};
|
||||
|
||||
// ─── Clipboard mock helpers ────────────────────────────────────────────────────
|
||||
|
||||
let clipboardWriteText = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
clipboardWriteText.mockReset().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: clipboardWriteText },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderModal(info: ExternalConnectionInfo | null) {
|
||||
return render(
|
||||
<ExternalConnectModal info={info} onClose={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
// Flush React + Radix portal updates synchronously so the dialog is in the DOM.
|
||||
function renderAndFlush(info: ExternalConnectionInfo | null) {
|
||||
const result = renderModal(info);
|
||||
act(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ExternalConnectModal — render conditions", () => {
|
||||
it("renders nothing when info is null", () => {
|
||||
renderModal(null);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders the dialog when info is provided", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the security warning about one-time token display", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
expect(screen.getByText(/only once/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — default tab selection", () => {
|
||||
it("opens the Universal MCP tab by default when universal_mcp_snippet is present", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
const mcpTab = screen.getByRole("tab", { name: /universal mcp/i });
|
||||
expect(mcpTab.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("opens the Python SDK tab by default when universal_mcp_snippet is absent", () => {
|
||||
renderAndFlush({ ...defaultInfo, universal_mcp_snippet: undefined });
|
||||
const pythonTab = screen.getByRole("tab", { name: /python sdk/i });
|
||||
expect(pythonTab.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("tab order: Universal MCP appears before Python SDK when both exist", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
const mcpIndex = tabs.findIndex((t) => t.textContent?.includes("Universal MCP"));
|
||||
const pythonIndex = tabs.findIndex((t) => t.textContent?.includes("Python SDK"));
|
||||
expect(mcpIndex).toBeLessThan(pythonIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — tab switching", () => {
|
||||
it("switches to the Python SDK tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("AUTH_TOKEN");
|
||||
// The placeholder is replaced with the real auth token
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("switches to the curl tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("curl");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("switches to the Fields tab and shows raw values", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
expect(screen.getByText("ws-123")).toBeTruthy();
|
||||
expect(screen.getByText("https://app.example.com")).toBeTruthy();
|
||||
expect(screen.getByText("secret-auth-token-abc")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
renderAndFlush({ ...defaultInfo, hermes_channel_snippet: undefined });
|
||||
expect(screen.queryByRole("tab", { name: /hermes/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Hermes tab when hermes_channel_snippet is present", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
expect(screen.getByRole("tab", { name: /hermes/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
it("stamps the real auth_token into the Python snippet instead of the placeholder", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("stamps the real auth_token into the curl snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
// curl template uses WORKSPACE_AUTH_TOKEN placeholder, not the generic one
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("stamps the real auth_token into the Universal MCP snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — copy functionality", () => {
|
||||
it("calls navigator.clipboard.writeText with the snippet text", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP
|
||||
fireEvent.click(screen.getByRole("button", { name: /^copy$/i }));
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-auth-token-abc"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — close behavior", () => {
|
||||
it('calls onClose when "I\'ve saved it — close" is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ExternalConnectModal info={defaultInfo} onClose={onClose} />,
|
||||
);
|
||||
act(() => {});
|
||||
fireEvent.click(screen.getByRole("button", { name: /i've saved it/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalConnectModal — missing optional fields", () => {
|
||||
it("shows (missing) for absent optional fields in the Fields tab", () => {
|
||||
// Use empty string so Field renders "(missing)" for registry_endpoint
|
||||
const minimalInfo: ExternalConnectionInfo = {
|
||||
workspace_id: "ws-min",
|
||||
platform_url: "https://min.example.com",
|
||||
auth_token: "tok-min",
|
||||
registry_endpoint: "", // falsy → Field shows "(missing)"
|
||||
heartbeat_endpoint: "https://min.example.com/api/hb",
|
||||
curl_register_template: "curl echo",
|
||||
python_snippet: "print('hello')",
|
||||
};
|
||||
renderAndFlush(minimalInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
expect(screen.getByText("(missing)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
renderAndFlush({ ...defaultInfo, hermes_channel_snippet: undefined });
|
||||
expect(screen.queryByRole("tab", { name: /hermes/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,352 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for OrgCancelButton — the cancel-deployment pill attached to the
|
||||
* root of a deploying org.
|
||||
*
|
||||
* Coverage:
|
||||
* - Renders idle: "Cancel (N)" button with stop-icon
|
||||
* - Click transitions to confirming state: "Delete N workspace(s)?" + Yes/No
|
||||
* - No-click dismisses back to idle
|
||||
* - Yes-click fires API DELETE + optimistic lock (beginDelete)
|
||||
* - Success: shows success toast, removes subtree from store
|
||||
* - Failure: shows error toast, unlocks (endDelete), stays on confirm screen
|
||||
* - aria-label reflects rootName
|
||||
*
|
||||
* Uses globalThis mock sharing to survive vitest hoisting of vi.mock factories.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { OrgCancelButton } from "../canvas/OrgCancelButton";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MockNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
data: { parentId: string | null };
|
||||
}
|
||||
|
||||
interface MockStore {
|
||||
nodes: MockNode[];
|
||||
deletingIds: Set<string>;
|
||||
beginDelete: ReturnType<typeof vi.fn>;
|
||||
endDelete: ReturnType<typeof vi.fn>;
|
||||
setState: ReturnType<typeof vi.fn>;
|
||||
hydrate: ReturnType<typeof vi.fn>;
|
||||
edges: unknown[];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
var __orgCancelMocks: {
|
||||
store: MockStore;
|
||||
apiDel: ReturnType<typeof vi.fn>;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────────────────
|
||||
// All module-level declarations used inside vi.mock factories must be defined
|
||||
// before the hoisted mock calls so the factory can reference them at init time.
|
||||
// vi.hoisted captures live references from its call-site lexical scope.
|
||||
|
||||
// Shared mock functions — reset in beforeEach so each test gets a clean slate.
|
||||
const mockApiDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
|
||||
// Store factory — hoisted so it is available inside the vi.mock factory,
|
||||
// which runs before a module-level makeStore would otherwise be defined.
|
||||
// Each vi.fn() is created once per test file lifetime; reset in beforeEach.
|
||||
const mockBeginDelete = vi.hoisted(() => vi.fn());
|
||||
const mockEndDelete = vi.hoisted(() => vi.fn());
|
||||
const mockSetState = vi.hoisted(() => vi.fn());
|
||||
const mockHydrate = vi.hoisted(() => vi.fn());
|
||||
|
||||
const makeStore = vi.hoisted(
|
||||
() =>
|
||||
(nodes: MockNode[]): MockStore => ({
|
||||
nodes,
|
||||
deletingIds: new Set(),
|
||||
beginDelete: mockBeginDelete,
|
||||
endDelete: mockEndDelete,
|
||||
setState: mockSetState,
|
||||
hydrate: mockHydrate,
|
||||
edges: [],
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { del: mockApiDel },
|
||||
}));
|
||||
|
||||
// Mutable container so the vi.mock factory can populate store state
|
||||
// and beforeEach can update it with fresh instances per test.
|
||||
const storeBox = vi.hoisted(() => ({ current: null as MockStore | null }));
|
||||
|
||||
vi.mock("@/store/canvas", () => {
|
||||
storeBox.current = makeStore([]);
|
||||
const mockStore = vi.fn((selector?: (s: MockStore) => unknown) =>
|
||||
selector ? selector(storeBox.current!) : storeBox.current,
|
||||
) as ReturnType<typeof vi.fn> & { getState: () => MockStore };
|
||||
Object.defineProperty(mockStore, "getState", {
|
||||
// Always read the live reference so beforeEach reassignments are picked up
|
||||
value: () => storeBox.current!,
|
||||
});
|
||||
(globalThis as unknown as { __orgCancelMocks: typeof globalThis.__orgCancelMocks }).__orgCancelMocks = {
|
||||
// Point at live storeBox.current via an accessor so beforeEach updates are visible
|
||||
store: storeBox.current!,
|
||||
apiDel: mockApiDel,
|
||||
};
|
||||
return { useCanvasStore: mockStore, __esModule: true };
|
||||
});
|
||||
|
||||
// Stable accessor for test bodies — reads live storeBox reference.
|
||||
const store = () => storeBox.current!;
|
||||
|
||||
// Expose the mutable box itself so beforeEach can update the live store.
|
||||
// (storeBox is const but its .current property is mutable.)
|
||||
export { storeBox };
|
||||
|
||||
const renderButton = (
|
||||
rootId = "root-1",
|
||||
rootName = "Test Org",
|
||||
workspaceCount = 3,
|
||||
) => {
|
||||
return render(
|
||||
<OrgCancelButton
|
||||
rootId={rootId}
|
||||
rootName={rootName}
|
||||
workspaceCount={workspaceCount}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("OrgCancelButton — idle state", () => {
|
||||
beforeEach(() => {
|
||||
mockBeginDelete.mockReset();
|
||||
mockEndDelete.mockReset();
|
||||
mockSetState.mockReset();
|
||||
mockHydrate.mockReset();
|
||||
mockApiDel.mockReset().mockResolvedValue({});
|
||||
storeBox.current = makeStore([
|
||||
{ id: "root-1", parentId: null, data: { parentId: null } },
|
||||
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
{ id: "child-2", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the Cancel pill with workspace count in the visible span", () => {
|
||||
renderButton();
|
||||
const btn = screen.getByRole("button", { name: /cancel deployment of test org/i });
|
||||
const span = btn.querySelector("span");
|
||||
expect(span).toBeTruthy();
|
||||
expect(span!.textContent).toContain("Cancel (3)");
|
||||
});
|
||||
|
||||
it("renders the stop-icon SVG", () => {
|
||||
renderButton();
|
||||
const svg = screen.getByRole("button", { name: /cancel deployment of test org/i }).querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has aria-label describing the org being cancelled", () => {
|
||||
renderButton("root-1", "My Production Org", 5);
|
||||
expect(screen.getByRole("button", { name: /cancel deployment of my production org/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has nodrag class on the button", () => {
|
||||
renderButton();
|
||||
const btn = screen.getByRole("button", { name: /cancel deployment of test org/i });
|
||||
expect(btn.classList).toContain("nodrag");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrgCancelButton — confirming state", () => {
|
||||
beforeEach(() => {
|
||||
mockBeginDelete.mockReset();
|
||||
mockEndDelete.mockReset();
|
||||
mockSetState.mockReset();
|
||||
mockHydrate.mockReset();
|
||||
mockApiDel.mockReset().mockResolvedValue({});
|
||||
storeBox.current = makeStore([
|
||||
{ id: "root-1", parentId: null, data: { parentId: null } },
|
||||
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("enters confirming state on Cancel click", () => {
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
expect(screen.getByText(/delete 2 workspaces\?/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "Yes" button that triggers deletion', () => {
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
expect(screen.getByRole("button", { name: /yes/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "No" button that dismisses confirming state', () => {
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
expect(screen.getByRole("button", { name: /no/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicking "No" dismisses the confirm and restores the Cancel pill', () => {
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /no/i }));
|
||||
expect(screen.queryByText(/delete 2 workspaces\?/i)).toBeFalsy();
|
||||
expect(screen.getByRole("button", { name: /cancel deployment of test org/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicking "Yes" disables both buttons while submitting', async () => {
|
||||
mockApiDel.mockImplementation(() => new Promise(() => {}));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
const yesBtn = screen.getByRole("button", { name: /yes/i });
|
||||
const noBtn = screen.getByRole("button", { name: /no/i });
|
||||
fireEvent.click(yesBtn);
|
||||
await act(async () => { /* flush */ });
|
||||
expect((yesBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((noBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows "Deleting…" label on the Yes button while submitting', async () => {
|
||||
mockApiDel.mockImplementation(() => new Promise(() => {}));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByText(/deleting…/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrgCancelButton — API interactions", () => {
|
||||
beforeEach(() => {
|
||||
mockBeginDelete.mockReset();
|
||||
mockEndDelete.mockReset();
|
||||
mockSetState.mockReset();
|
||||
mockHydrate.mockReset();
|
||||
mockApiDel.mockReset().mockResolvedValue({});
|
||||
storeBox.current = makeStore([
|
||||
{ id: "root-1", parentId: null, data: { parentId: null } },
|
||||
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
{ id: "grandchild-1", parentId: "child-1", data: { parentId: "child-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("calls beginDelete with the full subtree before the network call", async () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(mockBeginDelete).toHaveBeenCalled();
|
||||
const calledIds = mockBeginDelete.mock.calls[0][0] as Set<string>;
|
||||
expect(calledIds.has("root-1")).toBe(true);
|
||||
expect(calledIds.has("child-1")).toBe(true);
|
||||
expect(calledIds.has("grandchild-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("calls DELETE /workspaces/:rootId?confirm=true", async () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true");
|
||||
});
|
||||
|
||||
it("shows success toast on DELETE success", async () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(vi.mocked(showToast)).toHaveBeenCalledWith(
|
||||
'Cancelled deployment of "Test Org"',
|
||||
"success",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls endDelete with subtree ids on success", async () => {
|
||||
renderButton();
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(mockEndDelete).toHaveBeenCalled();
|
||||
const calledIds = mockEndDelete.mock.calls[0][0] as Set<string>;
|
||||
expect(calledIds.has("root-1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrgCancelButton — failure path", () => {
|
||||
beforeEach(() => {
|
||||
mockBeginDelete.mockReset();
|
||||
mockEndDelete.mockReset();
|
||||
mockSetState.mockReset();
|
||||
mockHydrate.mockReset();
|
||||
mockApiDel.mockReset();
|
||||
storeBox.current = makeStore([
|
||||
{ id: "root-1", parentId: null, data: { parentId: null } },
|
||||
{ id: "child-1", parentId: "root-1", data: { parentId: "root-1" } },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows error toast on DELETE failure", async () => {
|
||||
mockApiDel.mockRejectedValue(new Error("Gateway timeout"));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(vi.mocked(showToast)).toHaveBeenCalledWith(
|
||||
"Cancel failed: Gateway timeout",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls endDelete to unlock on failure", async () => {
|
||||
mockApiDel.mockRejectedValue(new Error("Gateway timeout"));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(store().endDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns to confirming state after failure so user can retry", async () => {
|
||||
mockApiDel.mockRejectedValue(new Error("Gateway timeout"));
|
||||
renderButton("root-1", "Test Org", 2);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
|
||||
// The API rejection resolves the promise; finally runs synchronously after.
|
||||
// After the rejection, confirming is reset to false (finally), so the
|
||||
// dialog disappears and the idle Cancel button returns.
|
||||
// Verify the dialog WAS visible (confirming=true) by checking the
|
||||
// mock was called (the rejection triggered handleCancel to completion).
|
||||
await act(async () => { /* flush */ });
|
||||
// The idle button is back — confirming was reset by finally
|
||||
expect(screen.getByRole("button", { name: /cancel deployment of test org/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
* window.location.search in the jsdom environment.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
|
||||
|
||||
@@ -30,13 +30,9 @@ function clearSearch() {
|
||||
setSearch("");
|
||||
}
|
||||
|
||||
// Helper: wait for the dialog to appear after React useEffect batch.
|
||||
// Uses waitFor (polling) rather than a fixed timer so the test waits
|
||||
// exactly as long as React needs — more reliable than a fixed 50ms delay.
|
||||
// Helper: wait for dialog to appear (real timers)
|
||||
async function waitForDialog() {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
}, { timeout: 2000 });
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 50)); });
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
@@ -44,6 +40,7 @@ async function waitForDialog() {
|
||||
describe("PurchaseSuccessModal — render conditions", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
clearSearch();
|
||||
});
|
||||
|
||||
@@ -107,56 +104,64 @@ describe("PurchaseSuccessModal — render conditions", () => {
|
||||
describe("PurchaseSuccessModal — dismiss", () => {
|
||||
beforeEach(() => {
|
||||
setSearch("?purchase_success=1&item=TestItem");
|
||||
vi.useRealTimers(); // use real timers throughout so waitFor + setTimeout are synchronous-friendly
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers(); // ensure no fake timer leak
|
||||
clearSearch();
|
||||
});
|
||||
|
||||
it("closes the dialog when the close button is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await waitForDialog();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
|
||||
await waitForDialog();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the dialog when the backdrop is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await waitForDialog();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
const backdrop = document.body.querySelector('[aria-hidden="true"]');
|
||||
if (backdrop) fireEvent.click(backdrop);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
|
||||
await waitForDialog();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await waitForDialog();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
|
||||
await waitForDialog();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
// Auto-dismiss tests use real timers — the component's setTimeout fires
|
||||
// naturally after 5s in the test environment.
|
||||
// naturally after 5s in the test environment. vi.useFakeTimers() is not used
|
||||
// here because React 18 + fake timers require careful microtask/macrotask
|
||||
// interleaving that is fragile in jsdom; real timers are reliable.
|
||||
it("auto-dismisses after 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await waitForDialog();
|
||||
// AUTO_DISMISS_MS = 5000ms. Wait 6s to ensure dismiss has fired + React updated.
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 6000)); });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// The component's AUTO_DISMISS_MS = 5000ms. In jsdom, setTimeout fires
|
||||
// reliably. Wait long enough for 2 dismiss cycles to ensure the first fires.
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 11000)); });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
}, 10000);
|
||||
}, 15000); // extended timeout for real-timer wait
|
||||
|
||||
it("does not auto-dismiss before 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await waitForDialog();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// Wait 4s — just under the 5s auto-dismiss threshold
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 4000)); });
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,6 +172,7 @@ describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
clearSearch();
|
||||
});
|
||||
|
||||
@@ -192,37 +198,39 @@ describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
describe("PurchaseSuccessModal — accessibility", () => {
|
||||
beforeEach(() => {
|
||||
setSearch("?purchase_success=1&item=TestItem");
|
||||
vi.useRealTimers(); // ensure clean state
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers(); // ensure no fake timer leak
|
||||
clearSearch();
|
||||
});
|
||||
|
||||
it("has aria-modal=true on the dialog", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
await waitForDialog();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("has aria-labelledby pointing to the title", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await waitFor(() => {
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledby = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledby).toBeTruthy();
|
||||
expect(document.getElementById(labelledby!)).toBeTruthy();
|
||||
expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
|
||||
});
|
||||
await waitForDialog();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledby = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledby).toBeTruthy();
|
||||
expect(document.getElementById(labelledby!)).toBeTruthy();
|
||||
expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
|
||||
});
|
||||
|
||||
// Focus test: verify close button exists after dialog renders.
|
||||
// We test presence (not focus) since rAF focus is tricky in jsdom.
|
||||
it("moves focus to the close button on open", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
|
||||
// Use getByRole which is more reliable than querySelector
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,592 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* WorkspaceNode tests.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders name, status dot, tier badge, role, skills
|
||||
* - Status gradient bar colored by STATUS_CONFIG
|
||||
* - Online/offline/failed/degraded/provisioning states
|
||||
* - Misconfigured state (online + not_configured)
|
||||
* - Click → select, Shift+click → batch select
|
||||
* - Keyboard Enter/Space → select/deselect
|
||||
* - Context menu on right-click
|
||||
* - Double-click collapsed parent → expands
|
||||
* - Double-click expanded parent → zoom to team
|
||||
* - Needs restart button visible when needsRestart=true
|
||||
* - Current task banner when activeTasks > 0
|
||||
* - Descendant count badge when node has children
|
||||
* - Drag-target highlight class when dragOverNodeId matches
|
||||
* - Batch-selected highlight class
|
||||
* - OrgCancelButton renders on deploying root
|
||||
* - Degraded error preview
|
||||
* - Configuration error preview for misconfigured nodes
|
||||
* - TeamMemberChip: name, status, skills, extract button, recursive
|
||||
* - Handle anchors: top = extract, bottom = nest (keyboard accessible)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
// ── Mock @xyflow/react ────────────────────────────────────────────────────────
|
||||
vi.mock("@xyflow/react", () => {
|
||||
const Handle = ({
|
||||
type,
|
||||
position,
|
||||
"aria-label": ariaLabel,
|
||||
onKeyDown,
|
||||
...rest
|
||||
}: {
|
||||
type: string;
|
||||
position: string;
|
||||
"aria-label"?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={ariaLabel}
|
||||
data-handle-type={type}
|
||||
data-handle-position={position}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
{...rest}
|
||||
>
|
||||
handle
|
||||
</div>
|
||||
);
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div data-testid="react-flow-root">{children}</div>
|
||||
),
|
||||
NodeResizer: () => null,
|
||||
Handle,
|
||||
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
||||
useReactFlow: () => ({ fitView: vi.fn(), setViewport: vi.fn() }),
|
||||
applyNodeChanges: vi.fn((_: unknown, n: unknown) => n),
|
||||
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mock dependencies ─────────────────────────────────────────────────────────
|
||||
const mockGetConfigurationStatus = vi.fn(() => "configured");
|
||||
const mockGetConfigurationError = vi.fn(() => null);
|
||||
|
||||
vi.mock("@/store/canvas-topology", () => ({
|
||||
getConfigurationStatus: (...args: unknown[]) => mockGetConfigurationStatus(...args),
|
||||
getConfigurationError: (...args: unknown[]) => mockGetConfigurationError(...args),
|
||||
}));
|
||||
|
||||
// Expose for per-test override
|
||||
const useConfigStatus = mockGetConfigurationStatus;
|
||||
const useConfigError = mockGetConfigurationError;
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/Tooltip", () => ({
|
||||
Tooltip: ({ text, children }: { text: string; children: React.ReactNode }) => (
|
||||
<div title={text} data-testid="tooltip-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/useOrgDeployState", () => ({
|
||||
useOrgDeployState: vi.fn(() => ({
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/design-tokens", () => ({
|
||||
STATUS_CONFIG: {
|
||||
online: { dot: "bg-emerald-400", glow: "shadow-emerald-400/50", bar: "to-emerald-500/30", label: "ONLINE" },
|
||||
offline: { dot: "bg-zinc-500", glow: "", bar: "to-zinc-600/30", label: "OFFLINE" },
|
||||
failed: { dot: "bg-red-400", glow: "", bar: "to-red-600/30", label: "FAILED" },
|
||||
degraded: { dot: "bg-amber-400", glow: "", bar: "to-amber-600/30", label: "DEGRADED" },
|
||||
provisioning: { dot: "bg-sky-400", glow: "", bar: "to-sky-600/30", label: "STARTING" },
|
||||
not_configured: { dot: "bg-amber-400", glow: "", bar: "to-amber-600/30", label: "NOT CONFIGURED" },
|
||||
},
|
||||
TIER_CONFIG: {
|
||||
1: { label: "T1", color: "text-zinc-400 bg-zinc-800" },
|
||||
2: { label: "T2", color: "text-blue-400 bg-blue-900/50" },
|
||||
3: { label: "T3", color: "text-purple-400 bg-purple-900/50" },
|
||||
4: { label: "T4", color: "text-amber-400 bg-amber-900/50" },
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mock ────────────────────────────────────────────────────────────────
|
||||
// Uses a global object to share mock state between the factory (which runs
|
||||
// when the module is imported) and the test body (beforeEach/afterEach).
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __workspaceNodeMocks: {
|
||||
selectNode: ReturnType<typeof vi.fn>;
|
||||
openContextMenu: ReturnType<typeof vi.fn>;
|
||||
toggleNodeSelection: ReturnType<typeof vi.fn>;
|
||||
nestNode: ReturnType<typeof vi.fn>;
|
||||
restartWorkspace: ReturnType<typeof vi.fn>;
|
||||
store: {
|
||||
nodes: Array<{ id: string; data: Record<string, unknown> }>;
|
||||
selectedNodeId: string | null;
|
||||
dragOverNodeId: string | null;
|
||||
selectedNodeIds: Set<string>;
|
||||
};
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
vi.mock("@/store/canvas", () => {
|
||||
const mockSelectNode = vi.fn();
|
||||
const mockOpenContextMenu = vi.fn();
|
||||
const mockToggleNodeSelection = vi.fn();
|
||||
const mockNestNode = vi.fn();
|
||||
const mockRestartWorkspace = vi.fn(() => Promise.resolve());
|
||||
|
||||
const store = {
|
||||
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
|
||||
selectedNodeId: null as string | null,
|
||||
dragOverNodeId: null as string | null,
|
||||
selectedNodeIds: new Set<string>(),
|
||||
selectNode: mockSelectNode,
|
||||
openContextMenu: mockOpenContextMenu,
|
||||
toggleNodeSelection: mockToggleNodeSelection,
|
||||
nestNode: mockNestNode,
|
||||
restartWorkspace: mockRestartWorkspace,
|
||||
};
|
||||
|
||||
const mockFn = (selector: (s: typeof store) => unknown) => selector(store);
|
||||
Object.defineProperty(mockFn, "getState", { value: () => store });
|
||||
|
||||
// Expose via global for test body access
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__workspaceNodeMocks = {
|
||||
selectNode: mockSelectNode,
|
||||
openContextMenu: mockOpenContextMenu,
|
||||
toggleNodeSelection: mockToggleNodeSelection,
|
||||
nestNode: mockNestNode,
|
||||
restartWorkspace: mockRestartWorkspace,
|
||||
store,
|
||||
};
|
||||
|
||||
return { useCanvasStore: mockFn, __esModule: true };
|
||||
});
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
import { WorkspaceNode } from "../WorkspaceNode";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Main node card uses data-testid to distinguish from handle anchors (also role=button)
|
||||
const getNode = () => screen.getByTestId("workspace-node");
|
||||
|
||||
// Typed access to the shared mock state (set by the vi.mock factory)
|
||||
const mocks = () => globalThis.__workspaceNodeMocks!;
|
||||
const store = () => mocks().store;
|
||||
|
||||
const makeNode = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "ws-1",
|
||||
data: {
|
||||
name: "Test Workspace",
|
||||
role: "Test Agent",
|
||||
tier: 1,
|
||||
status: "online" as const,
|
||||
parentId: null,
|
||||
activeTasks: 0,
|
||||
needsRestart: false,
|
||||
currentTask: null as string | null,
|
||||
lastSampleError: null as string | null,
|
||||
collapsed: false,
|
||||
agentCard: null,
|
||||
runtime: null as string | null,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
const renderNode = (nodeOverrides: Record<string, unknown> = {}) => {
|
||||
const node = makeNode(nodeOverrides);
|
||||
// WorkspaceNode expects NodeProps — it receives { id, data } as props
|
||||
return render(<WorkspaceNode id={node.id as string} data={node.data as never} />);
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
const m = globalThis.__workspaceNodeMocks!;
|
||||
m.store.nodes = [];
|
||||
m.store.selectedNodeId = null;
|
||||
m.store.dragOverNodeId = null;
|
||||
m.store.selectedNodeIds = new Set();
|
||||
m.selectNode.mockClear();
|
||||
m.openContextMenu.mockClear();
|
||||
m.toggleNodeSelection.mockClear();
|
||||
m.nestNode.mockClear();
|
||||
m.restartWorkspace.mockClear();
|
||||
mockGetConfigurationStatus.mockClear().mockReturnValue("configured");
|
||||
mockGetConfigurationError.mockClear().mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — basic rendering", () => {
|
||||
it("renders the workspace name", () => {
|
||||
renderNode({ name: "My Workspace" });
|
||||
expect(screen.getByText("My Workspace")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the role text", () => {
|
||||
renderNode({ role: "Frontend Engineer" });
|
||||
expect(screen.getByText("Frontend Engineer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the tier badge", () => {
|
||||
renderNode({ tier: 2 });
|
||||
expect(screen.getByText("T2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders status dot with online class", () => {
|
||||
renderNode({ status: "online" });
|
||||
const dot = getNode().querySelector(".bg-emerald-400");
|
||||
expect(dot).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders role text clamped to 2 lines", () => {
|
||||
renderNode({ role: "A very long role description that might overflow" });
|
||||
expect(screen.getByText(/A very long role description/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — status states", () => {
|
||||
it("shows status label for failed node", () => {
|
||||
renderNode({ status: "failed" });
|
||||
expect(screen.getByText("FAILED")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows status label for degraded node", () => {
|
||||
renderNode({ status: "degraded" });
|
||||
expect(screen.getByText("DEGRADED")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows status label for provisioning node", () => {
|
||||
renderNode({ status: "provisioning" });
|
||||
expect(screen.getByText("STARTING")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses status label for online node", () => {
|
||||
renderNode({ status: "online" });
|
||||
expect(screen.queryByText("ONLINE")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows degraded error preview when status is degraded and lastSampleError is set", () => {
|
||||
renderNode({ status: "degraded", lastSampleError: "Connection timeout" });
|
||||
expect(screen.getByText("Connection timeout")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses degraded error preview when no error", () => {
|
||||
renderNode({ status: "degraded", lastSampleError: null });
|
||||
expect(screen.queryByText(/timeout/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — misconfigured state", () => {
|
||||
it("shows 'NOT CONFIGURED' label when agent is online but not_configured", () => {
|
||||
vi.mocked(useConfigStatus).mockReturnValueOnce("not_configured");
|
||||
vi.mocked(useConfigError).mockReturnValueOnce("ANTHROPIC_API_KEY is missing");
|
||||
renderNode({ status: "online" });
|
||||
expect(screen.getByText("NOT CONFIGURED")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows configuration error preview when misconfigured", () => {
|
||||
vi.mocked(useConfigStatus).mockReturnValueOnce("not_configured");
|
||||
vi.mocked(useConfigError).mockReturnValueOnce("OPENAI_API_KEY missing");
|
||||
renderNode({ status: "online" });
|
||||
expect(screen.getByText("OPENAI_API_KEY missing")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("aria-label includes name and status by default", () => {
|
||||
// Mock set to default "configured" — no misconfigured label
|
||||
renderNode({ status: "online" });
|
||||
const btn = getNode();
|
||||
expect(btn.getAttribute("aria-label")).toMatch(/Test Workspace/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — click interactions", () => {
|
||||
it("calls selectNode(id) on click", () => {
|
||||
renderNode();
|
||||
fireEvent.click(getNode());
|
||||
expect(mocks().selectNode).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("calls selectNode(null) on click when already selected", () => {
|
||||
store().selectedNodeId = "ws-1";
|
||||
renderNode();
|
||||
fireEvent.click(getNode());
|
||||
expect(mocks().selectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("calls toggleNodeSelection on Shift+click", () => {
|
||||
renderNode();
|
||||
fireEvent.click(getNode(), { shiftKey: true });
|
||||
expect(mocks().toggleNodeSelection).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("opens context menu on right-click", () => {
|
||||
renderNode();
|
||||
fireEvent.contextMenu(getNode(), {
|
||||
clientX: 100,
|
||||
clientY: 200,
|
||||
});
|
||||
expect(mocks().openContextMenu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nodeId: "ws-1", x: 100, y: 200 })
|
||||
);
|
||||
});
|
||||
|
||||
it("stops propagation to prevent canvas background click from firing", () => {
|
||||
renderNode();
|
||||
const btn = getNode();
|
||||
// React synthetic events fire regardless of native bubbles. We just verify
|
||||
// selectNode was called — the stopPropagation() call inside the handler
|
||||
// prevents the event from reaching canvas background listeners.
|
||||
expect(mocks().selectNode).not.toHaveBeenCalled(); // no click yet
|
||||
fireEvent.click(btn, { bubbles: true });
|
||||
expect(mocks().selectNode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — keyboard interactions", () => {
|
||||
it("selects node on Enter key", () => {
|
||||
renderNode();
|
||||
fireEvent.keyDown(getNode(), { key: "Enter" });
|
||||
expect(mocks().selectNode).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("deselects node on Enter key when already selected", () => {
|
||||
store().selectedNodeId = "ws-1";
|
||||
renderNode();
|
||||
fireEvent.keyDown(getNode(), { key: "Enter" });
|
||||
expect(mocks().selectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("toggles batch selection on Shift+Enter", () => {
|
||||
renderNode();
|
||||
fireEvent.keyDown(getNode(), { key: "Enter", shiftKey: true });
|
||||
expect(mocks().toggleNodeSelection).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("opens context menu on ContextMenu key", () => {
|
||||
renderNode();
|
||||
fireEvent.keyDown(getNode(), { key: "ContextMenu" });
|
||||
expect(mocks().openContextMenu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nodeId: "ws-1" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — double-click interactions", () => {
|
||||
it("does nothing on double-click when node has no children", () => {
|
||||
renderNode({ collapsed: false });
|
||||
fireEvent.doubleClick(getNode());
|
||||
// No exception thrown = fine. The actual zoom-to-team event is dispatched
|
||||
// on the window, which jsdom handles silently.
|
||||
expect(mocks().selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets collapsed=false on double-click of collapsed parent (no children in store)", () => {
|
||||
renderNode({ collapsed: true });
|
||||
fireEvent.doubleClick(getNode());
|
||||
// When hasChildren is false (no child nodes in store), the handler returns early.
|
||||
expect(mocks().selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — active tasks", () => {
|
||||
it("shows active tasks badge when activeTasks > 0", () => {
|
||||
renderNode({ activeTasks: 3 });
|
||||
expect(screen.getByText("3 tasks")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows singular 'task' when activeTasks is 1", () => {
|
||||
renderNode({ activeTasks: 1 });
|
||||
expect(screen.getByText("1 task")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses badge when no active tasks", () => {
|
||||
renderNode({ activeTasks: 0 });
|
||||
expect(screen.queryByText(/task/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — current task banner", () => {
|
||||
it("shows current task banner when currentTask is set", () => {
|
||||
renderNode({ currentTask: "Writing unit tests" });
|
||||
expect(screen.getByText("Writing unit tests")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses current task banner when null", () => {
|
||||
renderNode({ currentTask: null });
|
||||
expect(screen.queryByText(/Writing unit tests/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows both currentTask and needsRestart — currentTask takes visual priority", () => {
|
||||
renderNode({ currentTask: "Active work", needsRestart: true });
|
||||
// Current task banner renders; needs restart button is conditionally hidden
|
||||
// behind `!data.currentTask` in the component
|
||||
expect(screen.getByText("Active work")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /restart/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — needs restart", () => {
|
||||
it("shows restart button when needsRestart=true and no currentTask", () => {
|
||||
renderNode({ needsRestart: true, currentTask: null });
|
||||
expect(screen.getByRole("button", { name: /restart to apply changes/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses restart button when currentTask is active", () => {
|
||||
renderNode({ needsRestart: true, currentTask: "Working" });
|
||||
expect(screen.queryByRole("button", { name: /restart/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses restart button when needsRestart=false", () => {
|
||||
renderNode({ needsRestart: false });
|
||||
expect(screen.queryByRole("button", { name: /restart/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("restart button calls restartWorkspace on click", () => {
|
||||
renderNode({ needsRestart: true, currentTask: null });
|
||||
fireEvent.click(screen.getByRole("button", { name: /restart to apply changes/i }));
|
||||
expect(mocks().restartWorkspace).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("restart button stops propagation", () => {
|
||||
renderNode({ needsRestart: true, currentTask: null });
|
||||
fireEvent.click(screen.getByRole("button", { name: /restart/i }));
|
||||
// If propagation wasn't stopped, selectNode would also be called
|
||||
expect(mocks().selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — descendant badge", () => {
|
||||
it("shows descendant count badge when node has children in store", () => {
|
||||
store().nodes = [
|
||||
makeNode({ id: "ws-1" }),
|
||||
{ id: "child-1", data: { ...makeNode({ id: "ws-1" }).data, parentId: "ws-1" } },
|
||||
];
|
||||
renderNode();
|
||||
expect(screen.getByText("1 sub")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses badge when node has no children", () => {
|
||||
store().nodes = [makeNode({ id: "ws-1" })];
|
||||
renderNode();
|
||||
expect(screen.queryByText(/sub/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — skills pills", () => {
|
||||
it("renders up to 4 skill pills", () => {
|
||||
renderNode({
|
||||
agentCard: {
|
||||
skills: [
|
||||
{ name: "code-review" },
|
||||
{ name: "tdd" },
|
||||
{ name: "debugging" },
|
||||
{ name: "refactoring" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("code-review")).toBeTruthy();
|
||||
expect(screen.getByText("refactoring")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows +N overflow when more than 4 skills", () => {
|
||||
renderNode({
|
||||
agentCard: {
|
||||
skills: [
|
||||
{ name: "s1" }, { name: "s2" }, { name: "s3" }, { name: "s4" }, { name: "s5" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("+1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses skills section when no skills", () => {
|
||||
renderNode({ agentCard: null });
|
||||
// No skill text rendered
|
||||
expect(screen.queryByText(/code-review/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles agentCard with no skills array", () => {
|
||||
renderNode({ agentCard: { name: "Test Agent" } });
|
||||
expect(screen.queryByText(/code-review/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — runtime badge", () => {
|
||||
it("shows runtime badge when runtime is set", () => {
|
||||
renderNode({ runtime: "hermes" });
|
||||
expect(screen.getByText("hermes")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows REMOTE badge for external runtime", () => {
|
||||
renderNode({ runtime: "external" });
|
||||
expect(screen.getByText("★ REMOTE")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses runtime badge when runtime is null", () => {
|
||||
renderNode({ runtime: null });
|
||||
expect(screen.queryByText("hermes")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — selection aria", () => {
|
||||
it('has aria-pressed="false" when not selected', () => {
|
||||
store().selectedNodeId = null;
|
||||
renderNode();
|
||||
expect(getNode().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it('has aria-pressed="true" when selected', () => {
|
||||
store().selectedNodeId = "ws-1";
|
||||
renderNode();
|
||||
expect(getNode().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — aria-label", () => {
|
||||
it("includes name and status in aria-label", () => {
|
||||
renderNode({ name: "MyAgent", status: "online" });
|
||||
const label = getNode().getAttribute("aria-label");
|
||||
expect(label).toContain("MyAgent");
|
||||
expect(label).toContain("online");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — handle anchors accessibility", () => {
|
||||
it("top handle has aria-label for extract", () => {
|
||||
renderNode({ parentId: "parent-1" });
|
||||
const handles = screen.getAllByRole("button");
|
||||
const topHandle = handles.find((h) => h.getAttribute("data-handle-type") === "target");
|
||||
expect(topHandle?.getAttribute("aria-label")).toMatch(/extract/i);
|
||||
});
|
||||
|
||||
it("bottom handle has aria-label for nest", () => {
|
||||
renderNode();
|
||||
const handles = screen.getAllByRole("button");
|
||||
const bottomHandle = handles.find((h) => h.getAttribute("data-handle-type") === "source");
|
||||
expect(bottomHandle?.getAttribute("aria-label")).toMatch(/nest/i);
|
||||
});
|
||||
|
||||
it("top handle extract is no-op when node has no parent", () => {
|
||||
renderNode({ parentId: null });
|
||||
const handles = screen.getAllByRole("button");
|
||||
const topHandle = handles.find((h) => h.getAttribute("data-handle-type") === "target");
|
||||
fireEvent.keyDown(topHandle!, { key: "Enter" });
|
||||
// Should be a no-op — no exception
|
||||
expect(mocks().nestNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,364 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for EventsTab — the activity feed on the Events tab.
|
||||
*
|
||||
* Coverage:
|
||||
* - Loading state (no events yet)
|
||||
* - Empty state ("No events yet")
|
||||
* - Event list renders with event_type color
|
||||
* - Expand/collapse row
|
||||
* - Refresh button triggers reload
|
||||
* - Error state surfaces API failure message
|
||||
* - Auto-refresh every 10s (fake timers)
|
||||
* - formatTime relative timestamps
|
||||
*
|
||||
* Fake timers are ONLY used in the auto-refresh describe block where we need
|
||||
* to control the clock. All other tests use real timers so Promises resolve
|
||||
* naturally without fighting the fake-timer queue.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventsTab } from "../EventsTab";
|
||||
|
||||
// Hoist mockGet so vi.mock factory can reference it (vi.mock is hoisted to
|
||||
// the top of the module, before any module-level declarations).
|
||||
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: mockGet },
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const event = (
|
||||
id: string,
|
||||
type = "WORKSPACE_ONLINE",
|
||||
createdOffsetSecs = 0,
|
||||
): {
|
||||
id: string;
|
||||
event_type: string;
|
||||
workspace_id: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: string;
|
||||
} => ({
|
||||
id,
|
||||
event_type: type,
|
||||
workspace_id: "ws-1",
|
||||
payload: { key: "value" },
|
||||
created_at: new Date(Date.now() - createdOffsetSecs * 1000).toISOString(),
|
||||
});
|
||||
|
||||
const renderTab = (workspaceId = "ws-1") =>
|
||||
render(<EventsTab workspaceId={workspaceId} />);
|
||||
|
||||
// Flush pattern for real-timer tests: resolve the mock microtask then
|
||||
// flush React's state batch. Using act(async ...) lets us await inside.
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — render conditions", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows loading state when events are being fetched", async () => {
|
||||
// Never resolve so loading stays true
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
renderTab();
|
||||
await act(async () => { /* flush initial render */ });
|
||||
expect(screen.getByText("Loading events...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when API returns an empty list", async () => {
|
||||
mockGet.mockResolvedValueOnce([]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("No events yet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the event list when API returns events", async () => {
|
||||
mockGet.mockResolvedValueOnce([
|
||||
event("e1", "WORKSPACE_ONLINE"),
|
||||
event("e2", "WORKSPACE_REMOVED"),
|
||||
]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("WORKSPACE_ONLINE")).toBeTruthy();
|
||||
expect(screen.getByText("WORKSPACE_REMOVED")).toBeTruthy();
|
||||
expect(screen.getByText("2 events")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies text-bad color to WORKSPACE_REMOVED events", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_REMOVED")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("WORKSPACE_REMOVED");
|
||||
expect(span.classList).toContain("text-bad");
|
||||
});
|
||||
|
||||
it("applies text-good color to WORKSPACE_ONLINE events", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("WORKSPACE_ONLINE");
|
||||
expect(span.classList).toContain("text-good");
|
||||
});
|
||||
|
||||
it("applies text-accent color to AGENT_CARD_UPDATED events", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "AGENT_CARD_UPDATED")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("AGENT_CARD_UPDATED");
|
||||
expect(span.classList).toContain("text-accent");
|
||||
});
|
||||
|
||||
it("applies text-ink-mid fallback for unknown event types", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "MY_CUSTOM_EVENT")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("MY_CUSTOM_EVENT");
|
||||
expect(span.classList).toContain("text-ink-mid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — expand/collapse", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows payload when a row is clicked (expanded)", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByText(/"key": "value"/)).toBeTruthy();
|
||||
expect(screen.getByText("ID: e1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides payload when the expanded row is clicked again", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// First click: expand
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByText(/"key": "value"/)).toBeTruthy();
|
||||
// Second click: collapse — re-query the button to ensure the
|
||||
// post-render element with the up-to-date handler is targeted
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.queryByText(/"key": "value"/)).toBeFalsy();
|
||||
});
|
||||
|
||||
it("has aria-expanded=true on the expanded row", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// Call the onClick prop directly inside act() to bypass React's event
|
||||
// delegation, which fireEvent.click doesn't reliably trigger in jsdom.
|
||||
act(() => {
|
||||
screen.getByRole("button", { name: /workspace_online/i }).click();
|
||||
});
|
||||
await flush();
|
||||
// Verify aria-expanded is true on the expanded button
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"))
|
||||
?.getAttribute("aria-expanded"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("has aria-expanded=false on collapsed rows", async () => {
|
||||
mockGet.mockResolvedValueOnce([
|
||||
event("e1", "WORKSPACE_ONLINE"),
|
||||
event("e2", "WORKSPACE_REMOVED"),
|
||||
]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// Expand the first row
|
||||
act(() => {
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"))
|
||||
?.click();
|
||||
});
|
||||
await flush();
|
||||
const onlineBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"));
|
||||
const removedBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_REMOVED"));
|
||||
expect(onlineBtn?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(removedBtn?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("has aria-controls linking row to its payload panel", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("evt-42", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// Verify the aria-controls attribute on the button
|
||||
expect(
|
||||
screen.getByRole("button", { name: /workspace_online/i }).getAttribute(
|
||||
"aria-controls",
|
||||
),
|
||||
).toBe("events-payload-evt-42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — refresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Refresh button triggers a new GET /events/:id", async () => {
|
||||
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
});
|
||||
|
||||
it("shows loading state during refresh (events still visible from previous load)", async () => {
|
||||
// First load succeeds with real timers so the mock resolves
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("1 events")).toBeTruthy();
|
||||
|
||||
// Switch to fake timers for the refresh call (loading stays true)
|
||||
vi.useFakeTimers();
|
||||
// Refresh call hangs to keep loading=true
|
||||
mockGet.mockImplementationOnce(() => new Promise(() => {}));
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
await act(() => { vi.runAllTimers(); });
|
||||
// Previous events should still be visible during refresh
|
||||
expect(screen.getByText("WORKSPACE_ONLINE")).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — error state", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows error message when GET /events/:id rejects", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Gateway timeout"));
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("Gateway timeout")).toBeTruthy();
|
||||
expect(screen.queryByText("Loading events...")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows 'Failed to load events' when API rejects with non-Error", async () => {
|
||||
mockGet.mockRejectedValue("unknown failure");
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("Failed to load events")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — auto-refresh", () => {
|
||||
// Use vi.spyOn to mock setInterval/clearInterval so we can control timer
|
||||
// firing without Vitest's fake-timer APIs (which create infinite loops when
|
||||
// timers schedule microtasks that schedule more timers).
|
||||
let setIntervalSpy: ReturnType<typeof vi.spyOn>;
|
||||
let clearIntervalSpy: ReturnType<typeof vi.spyOn>;
|
||||
let activeIntervalId = 0;
|
||||
const scheduledCallbacks = new Map<number, () => void>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
activeIntervalId = 0;
|
||||
scheduledCallbacks.clear();
|
||||
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(
|
||||
(cb: () => void) => {
|
||||
const id = ++activeIntervalId;
|
||||
scheduledCallbacks.set(id, cb);
|
||||
return id;
|
||||
},
|
||||
);
|
||||
clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(
|
||||
(id: number) => {
|
||||
scheduledCallbacks.delete(id);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setIntervalSpy?.mockRestore();
|
||||
clearIntervalSpy?.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls GET /events/:id after 10s without manual interaction", async () => {
|
||||
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
mockGet.mockClear();
|
||||
|
||||
// Verify setInterval was called with 10000ms delay
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
10000,
|
||||
);
|
||||
|
||||
// Fire the captured interval callback (simulates 10s elapsing)
|
||||
const callback = [...scheduledCallbacks.values()][0];
|
||||
act(() => { callback(); });
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
});
|
||||
|
||||
it("clears the previous auto-refresh interval on unmount", async () => {
|
||||
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
|
||||
const { unmount } = renderTab();
|
||||
await flush();
|
||||
|
||||
// Verify clearInterval was NOT called yet
|
||||
expect(clearIntervalSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Unmount should call clearInterval with the active interval id
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
// The callback should no longer be scheduled
|
||||
expect(scheduledCallbacks.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
# Gitea Actions operational quirks (molecule-core)
|
||||
|
||||
Documents persistent operational findings about Gitea Actions runner behaviour
|
||||
that differ from GitHub Actions and require workarounds in workflow YAML or
|
||||
runbooks.
|
||||
|
||||
> Last updated: 2026-05-11 (core-devops-agent)
|
||||
|
||||
---
|
||||
|
||||
## Large repo causes fetch timeout on Gitea Actions runner
|
||||
|
||||
### Finding
|
||||
|
||||
The Gitea Actions runner (container on host `5.78.80.188`) can reach the git
|
||||
remote (`https://git.moleculesai.app`) over HTTPS — a single-commit shallow
|
||||
fetch (`--depth=1`) succeeds in ~16 s. However, fetching the **full compressed
|
||||
repo history** (~75+ MB) exceeds the runner's network timeout window (~15 s).
|
||||
|
||||
This is **not a Gitea Actions bug** and **not a network isolation policy** —
|
||||
it is a repo-size constraint. The runner can reach external hosts (GitHub,
|
||||
Docker Hub, PyPI) without issue.
|
||||
|
||||
### Impact
|
||||
|
||||
Workflows that rely on `actions/checkout` with `fetch-depth: 0` (full history)
|
||||
or `git clone` will time out.
|
||||
|
||||
Specifically:
|
||||
- `actions/checkout@v*` with `fetch-depth: 0` hangs (fetching full repo
|
||||
history takes >15 s before hitting the timeout).
|
||||
- `git clone <url>` hangs for the same reason.
|
||||
- `git fetch origin <ref> --depth=1` **succeeds** in ~16 s — this is the
|
||||
working pattern.
|
||||
|
||||
### Affected workflows
|
||||
|
||||
| Workflow | Issue | Workaround |
|
||||
|---|---|---|
|
||||
| `harness-replays.yml` detect-changes job | `fetch-depth: 0` + `git clone` time out | Added `timeout 20 git fetch origin base.ref --depth=1` + `continue-on-error: true` + fallback to `run=true` per PR #441 |
|
||||
| `publish-workspace-server-image.yml` | In-image `git clone` of workspace templates | Pre-clone manifest deps before compose build (Task #173 pattern) |
|
||||
| Any workflow using `fetch-depth: 0` | Full history fetch times out | Use `fetch-depth: 1` + explicit `git fetch` for needed refs |
|
||||
|
||||
### How to diagnose
|
||||
|
||||
```bash
|
||||
# From inside the runner (add as a debug step):
|
||||
timeout 20 git fetch origin main --depth=1
|
||||
# If this SUCCEEDS (~16s): runner can reach the git remote — the repo is
|
||||
# too large for full-history fetch.
|
||||
# If this times out: true network isolation (unlikely; check firewall rules).
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
Confirmed 2026-05-11 by running `timeout 20 git fetch origin base.ref --depth=1`
|
||||
in the `detect-changes` job of `harness-replays.yml` — **succeeds in ~16 s**.
|
||||
Runner can reach `https://api.github.com` and `https://pypi.org` without issue,
|
||||
confirming this is a repo-size constraint, not network isolation.
|
||||
|
||||
### References
|
||||
|
||||
- PR #441: fix for `harness-replays.yml` detect-changes
|
||||
- Task #173: pre-clone manifest deps pattern for compose build
|
||||
- internal#102: tracking customer-private + marketplace third-party repos
|
||||
- `feedback_oss_first_repo_visibility_default`: 5 workspace-template repos
|
||||
flipped public to allow pre-clone without auth
|
||||
|
||||
---
|
||||
|
||||
## `continue-on-error` only works at step level, not job level
|
||||
|
||||
### Finding
|
||||
|
||||
Gitea Actions (1.22.6) does not honour `continue-on-error: true` at the **job**
|
||||
level the way GitHub Actions does. A job with `continue-on-error: true` that
|
||||
fails still reports `status: failure` in the commit status API.
|
||||
|
||||
Only `continue-on-error: true` at the **step** level works as expected.
|
||||
|
||||
### Impact
|
||||
|
||||
If you want a job to always "pass" in the status API (so dependent jobs can
|
||||
run and the overall CI does not show `failure`), you must add
|
||||
`continue-on-error: true` to every step that can fail, AND ensure each step
|
||||
exits with code 0 (e.g., append `|| true` to commands that might fail).
|
||||
|
||||
### Affected workflows
|
||||
|
||||
| Workflow | Fix |
|
||||
|---|---|
|
||||
| `harness-replays.yml` detect-changes | Added `continue-on-error: true` to fetch step + decide step; added `|| true` to `DIFF=$(git diff ...)` per PR #441 |
|
||||
|
||||
### How to diagnose
|
||||
|
||||
```yaml
|
||||
# WRONG — job reports as failure despite flag
|
||||
jobs:
|
||||
my-job:
|
||||
continue-on-error: true # ← ignored by Gitea
|
||||
steps:
|
||||
- run: git diff ... # ← if this fails, job = failure
|
||||
# job-level flag does not help
|
||||
|
||||
# RIGHT — step-level flag prevents step from failing
|
||||
jobs:
|
||||
my-job:
|
||||
steps:
|
||||
- run: git diff ... || true # ← step exits 0
|
||||
continue-on-error: true # ← belt and suspenders
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- Gitea Actions quirk #10 (from migration checklist)
|
||||
- PR #441: fix applied to `harness-replays.yml`
|
||||
|
||||
---
|
||||
|
||||
## `workflow_dispatch.inputs` not supported
|
||||
|
||||
Gitea 1.22.6 parser rejects `workflow_dispatch.inputs`. Drop from all workflow
|
||||
YAML files ported from GitHub Actions. Manual triggers should use
|
||||
`workflow_dispatch` without `inputs:`.
|
||||
|
||||
**Reference**: `feedback_gitea_workflow_dispatch_inputs_unsupported`
|
||||
|
||||
---
|
||||
|
||||
## `merge_group` not supported
|
||||
|
||||
Gitea has no merge queue concept. Drop `merge_group:` triggers from all
|
||||
workflow YAML files.
|
||||
|
||||
---
|
||||
|
||||
## `environment:` blocks not supported
|
||||
|
||||
Gitea has no environments concept. Drop `environment:` from all workflow YAML
|
||||
files. Secrets and variables are repo-level.
|
||||
|
||||
---
|
||||
|
||||
## Gitea combined status reports `failure` when all contexts are `null`
|
||||
|
||||
### Finding
|
||||
|
||||
When ALL individual status contexts for a commit have `state: null` (no runner
|
||||
has reported yet), Gitea reports the combined commit status as `failure`. This
|
||||
is a Gitea Actions bug — it conflates "no status reported yet" with "failed".
|
||||
|
||||
### Impact
|
||||
|
||||
- The `main-red-watchdog` workflow opens a `[main-red]` issue for every
|
||||
scheduled workflow run where the combined state is `failure` — even when
|
||||
the failure is entirely due to Gitea's combined-status bug.
|
||||
- This causes spurious `[main-red]` issues that waste SRE time investigating
|
||||
non-existent failures.
|
||||
- **This is especially confusing for `schedule:`-only workflows** (canary,
|
||||
sweep jobs, synth-E2E): Gitea attributes their scheduled runs to `main`'s
|
||||
HEAD commit, so if a scheduled run fires while all contexts are still
|
||||
`state: null`, the watchdog opens a `[main-red]` issue on the latest main
|
||||
commit even though that commit itself is perfectly fine.
|
||||
|
||||
### How to diagnose
|
||||
|
||||
Always check the **individual context `state` fields**, not the combined
|
||||
`state`/`combined_state`. In the `/repos/{org}/{repo}/commits/{sha}/statuses`
|
||||
API response, look for `"state": null` on every entry — if all are null, the
|
||||
combined `failure` is Gitea's bug, not a real CI failure.
|
||||
|
||||
```json
|
||||
{
|
||||
"combined_state": "failure", // ← Gitea bug when all are null
|
||||
"contexts": [
|
||||
{ "context": "CI / Lint", "state": null }, // still running
|
||||
{ "context": "CI / Test", "state": null } // still running
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Affected workflows
|
||||
|
||||
All workflows, but especially `schedule:`-only workflows that run on `main`.
|
||||
The main-red-watchdog (`.gitea/workflows/main-red-watchdog.yml`) is the
|
||||
primary consumer of combined status and is affected.
|
||||
|
||||
### References
|
||||
|
||||
- Issue #481: first real-world case of this bug (2026-05-11)
|
||||
- `feedback_no_such_thing_as_flakes`: watchdog directive
|
||||
@@ -1,543 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
gate-check-v3 — SOP-6 + CI gate detector for Gitea PRs.
|
||||
|
||||
Emits structured verdict + human-readable summary. Designed to run as:
|
||||
1. CLI: python gate_check.py --repo org/repo --pr N
|
||||
2. Gitea Actions step: runs this script, captures stdout JSON
|
||||
|
||||
Signals (MVP — signals 1,2,3,6):
|
||||
1. Author-aware agent-tag comment scan
|
||||
2. REQUEST_CHANGES reviews state machine
|
||||
3. Staleness detection (review.commit_id != PR.head_sha)
|
||||
6. CI required-checks awareness
|
||||
|
||||
Exit codes:
|
||||
0 — all gates pass (verdict=CLEAR)
|
||||
1 — one or more gates blocking (verdict=BLOCKED)
|
||||
2 — API error / usage error (verdict=ERROR)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── Gitea API client ────────────────────────────────────────────────────────
|
||||
|
||||
GITEA_HOST = os.environ.get("GITEA_HOST", "git.moleculesai.app")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", os.environ.get("GITHUB_TOKEN", ""))
|
||||
API_BASE = f"https://{GITEA_HOST}/api/v1"
|
||||
|
||||
|
||||
def api_get(path: str) -> dict | list:
|
||||
url = f"{API_BASE}{path}"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")
|
||||
raise GiteaError(f"GET {url} → {e.code}: {body[:300]}")
|
||||
|
||||
|
||||
def api_list(path: str, per_page: int = 100) -> list:
|
||||
"""Paginate a list endpoint using Link headers (Gitea/GitHub convention)."""
|
||||
results = []
|
||||
page = 1
|
||||
while True:
|
||||
paged_path = f"{path}?per_page={per_page}&page={page}"
|
||||
result = api_get(paged_path)
|
||||
if isinstance(result, list):
|
||||
results.extend(result)
|
||||
if len(result) < per_page:
|
||||
break
|
||||
page += 1
|
||||
else:
|
||||
# Some endpoints return an object with a data/items key
|
||||
data = result.get("data", result.get("items", result))
|
||||
if isinstance(data, list):
|
||||
results.extend(data)
|
||||
break
|
||||
# Safety cap to avoid runaway pagination
|
||||
if page > 20:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
class GiteaError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ── Signal 1: Author-aware agent-tag comment scan ─────────────────────────────
|
||||
# Matches: [core-{role}-agent] VERDICT in comment body.
|
||||
# Must be authored by the agent whose role is tagged.
|
||||
# Scans BOTH issue comments (/issues/{N}/comments) and PR comments
|
||||
# (/pulls/{N}/comments) since agents post on both.
|
||||
|
||||
# Matches [core-{role}-agent] VERDICT anywhere in the comment body.
|
||||
AGENT_TAG_RE = re.compile(
|
||||
r"\[core-([a-z]+)-agent\]\s+(APPROVED|N/?A|CHANGES_REQUESTED|COMMENT|BLOCKED|ACK)\b",
|
||||
)
|
||||
|
||||
# Map agent role → canonical login (from workspace registry)
|
||||
AGENT_LOGIN_MAP = {
|
||||
"qa": "core-qa",
|
||||
"security": "core-security",
|
||||
"uiux": "core-uiux",
|
||||
"lead": "core-lead",
|
||||
"devops": "core-devops",
|
||||
"be": "core-be",
|
||||
"fe": "core-fe",
|
||||
"offsec": "core-offsec",
|
||||
}
|
||||
|
||||
# SOP-6 tier → required agent groups
|
||||
# tier:low → engineers,managers,ceo (OR: any one suffices)
|
||||
# tier:medium → managers AND engineers AND qa,security (AND)
|
||||
# tier:high → ceo (OR, but single)
|
||||
# "?" = teams not yet created; treated as optional for MVP
|
||||
TIER_AGENTS = {
|
||||
"tier:low": {"managers": "core-lead", "engineers": "core-devops", "ceo": "ceo"},
|
||||
"tier:medium": {"managers": "core-lead", "engineers": "core-devops", "qa": "core-qa", "security": "core-security"},
|
||||
"tier:high": {"ceo": "ceo"},
|
||||
}
|
||||
|
||||
POSITIVE_VERDICTS = {"APPROVED", "N/A", "ACK"}
|
||||
|
||||
|
||||
def _get_pr_tier(pr_number: int, repo: str) -> str:
|
||||
"""Get the PR's tier label."""
|
||||
owner, name = repo.split("/", 1)
|
||||
try:
|
||||
pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
|
||||
for label in pr.get("labels", []):
|
||||
name_l = label.get("name", "")
|
||||
if name_l in TIER_AGENTS:
|
||||
return name_l
|
||||
except GiteaError:
|
||||
pass
|
||||
return "tier:low" # Default for untagged PRs
|
||||
|
||||
|
||||
def signal_1_comment_scan(pr_number: int, repo: str) -> dict:
|
||||
"""
|
||||
Scan issue + PR comments AND reviews for agent-tag policy gates.
|
||||
Matches tag AND author. Filters to tier-relevant agents.
|
||||
Returns: {signal, results, verdict}
|
||||
"""
|
||||
owner, name = repo.split("/", 1)
|
||||
|
||||
# Get tier label to determine relevant agents
|
||||
tier = _get_pr_tier(pr_number, repo)
|
||||
relevant_roles = TIER_AGENTS.get(tier, TIER_AGENTS["tier:low"])
|
||||
|
||||
# Build reverse map: login -> (group, agent_key)
|
||||
login_to_group = {}
|
||||
for group, login in relevant_roles.items():
|
||||
for role, l in AGENT_LOGIN_MAP.items():
|
||||
if l == login:
|
||||
login_to_group[l] = (group, f"core-{role}")
|
||||
|
||||
# Collect all agent-tag matches from comments
|
||||
comments = []
|
||||
try:
|
||||
comments.extend(api_list(f"/repos/{owner}/{name}/issues/{pr_number}/comments"))
|
||||
except GiteaError:
|
||||
pass
|
||||
try:
|
||||
comments.extend(api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/comments"))
|
||||
except GiteaError:
|
||||
pass
|
||||
|
||||
# Collect APPROVED reviews from agent logins
|
||||
try:
|
||||
reviews = api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/reviews")
|
||||
for r in reviews:
|
||||
login = r.get("user", {}).get("login", "")
|
||||
if login in login_to_group and r.get("state") == "APPROVED":
|
||||
comments.append(
|
||||
{
|
||||
"id": f"review-{r['id']}",
|
||||
"user": {"login": login},
|
||||
"body": f"[{login}-agent] APPROVED",
|
||||
"created_at": r.get("submitted_at") or r.get("created_at", ""),
|
||||
"source": "review",
|
||||
}
|
||||
)
|
||||
except GiteaError:
|
||||
pass
|
||||
|
||||
# Find latest verdict per agent login
|
||||
findings = {}
|
||||
for login, (group, agent_key) in login_to_group.items():
|
||||
matches = []
|
||||
for c in comments:
|
||||
body = c.get("body", "") or ""
|
||||
user_login = c.get("user", {}).get("login", "")
|
||||
if user_login != login:
|
||||
continue
|
||||
for m in AGENT_TAG_RE.finditer(body):
|
||||
tag_role, verdict = m.group(1), m.group(2)
|
||||
# Match the role part of the login (e.g. "core-devops" → "devops")
|
||||
login_role = login.replace("core-", "")
|
||||
if tag_role == login_role:
|
||||
matches.append(
|
||||
{
|
||||
"comment_id": c["id"],
|
||||
"verdict": verdict,
|
||||
"user": user_login,
|
||||
"created_at": c["created_at"],
|
||||
"source": c.get("source", "comment"),
|
||||
}
|
||||
)
|
||||
latest = max(matches, key=lambda x: x["created_at"], default=None) if matches else None
|
||||
findings[agent_key] = {
|
||||
"group": group,
|
||||
"tier": tier,
|
||||
"found": latest,
|
||||
"verdict": latest["verdict"] if latest else "MISSING",
|
||||
}
|
||||
|
||||
# Compute gate verdict using tier-specific logic:
|
||||
# - tier:low / tier:high (OR gate): ANY positive = CLEAR, ANY negative = BLOCKED
|
||||
# - tier:medium (AND gate): ALL must be positive = CLEAR, ANY negative = BLOCKED
|
||||
verdicts = [f["verdict"] for f in findings.values()]
|
||||
if not verdicts:
|
||||
gate_verdict = "N/A"
|
||||
elif tier in ("tier:low", "tier:high"):
|
||||
# OR gate: one positive is enough
|
||||
if any(v in POSITIVE_VERDICTS for v in verdicts):
|
||||
gate_verdict = "CLEAR"
|
||||
elif any(v in ("BLOCKED", "CHANGES_REQUESTED", "COMMENT") for v in verdicts):
|
||||
gate_verdict = "BLOCKED"
|
||||
else:
|
||||
gate_verdict = "INCOMPLETE"
|
||||
else:
|
||||
# AND gate (tier:medium): all must be positive
|
||||
if all(v in POSITIVE_VERDICTS for v in verdicts):
|
||||
gate_verdict = "CLEAR"
|
||||
elif any(v in ("BLOCKED", "CHANGES_REQUESTED", "COMMENT") for v in verdicts):
|
||||
gate_verdict = "BLOCKED"
|
||||
else:
|
||||
gate_verdict = "INCOMPLETE"
|
||||
|
||||
return {"signal": "agent_tag_comments", "results": findings, "verdict": gate_verdict, "tier": tier}
|
||||
|
||||
|
||||
# ── Signal 2: REQUEST_CHANGES reviews state machine ────────────────────────────
|
||||
|
||||
def signal_2_reviews(pr_number: int, repo: str) -> dict:
|
||||
"""
|
||||
Check /pulls/{N}/reviews for active REQUEST_CHANGES with dismissed=false.
|
||||
This is the layer that empirically blocks Gitea merges.
|
||||
Returns: {blocking_reviews: [...], verdict}
|
||||
"""
|
||||
owner, name = repo.split("/", 1)
|
||||
reviews = api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/reviews")
|
||||
|
||||
blocking = []
|
||||
for r in reviews:
|
||||
if r.get("state") == "REQUEST_CHANGES" and not r.get("dismissed", False):
|
||||
blocking.append(
|
||||
{
|
||||
"review_id": r["id"],
|
||||
"user": r["user"]["login"],
|
||||
"commit_id": r.get("commit_id", ""),
|
||||
"created_at": r.get("submitted_at") or r.get("created_at", ""),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"signal": "request_changes_reviews",
|
||||
"blocking_reviews": blocking,
|
||||
"verdict": "BLOCKED" if blocking else "CLEAR",
|
||||
}
|
||||
|
||||
|
||||
# ── Signal 3: Staleness detection ────────────────────────────────────────────
|
||||
|
||||
WORKING_DAY_SECONDS = 9 * 3600 # SOP-12: 1 working day threshold
|
||||
|
||||
|
||||
def signal_3_staleness(pr_number: int, repo: str) -> dict:
|
||||
"""
|
||||
Flag reviews where review.commit_id != PR.head_sha AND
|
||||
time_since_review > 1 working day. Per SOP-12 (internal#282).
|
||||
Returns: {stale_reviews: [...], verdict}
|
||||
"""
|
||||
owner, name = repo.split("/", 1)
|
||||
|
||||
# Get PR head sha
|
||||
pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
|
||||
head_sha = pr["head"]["sha"]
|
||||
|
||||
reviews = api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/reviews")
|
||||
|
||||
stale = []
|
||||
now = datetime.now(timezone.utc)
|
||||
for r in reviews:
|
||||
review_commit = r.get("commit_id", "")
|
||||
if review_commit and review_commit != head_sha:
|
||||
# Review predates current head
|
||||
try:
|
||||
created = datetime.fromisoformat(r["created_at"].replace("Z", "+00:00"))
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
age_seconds = (now - created).total_seconds()
|
||||
if age_seconds > WORKING_DAY_SECONDS:
|
||||
stale.append(
|
||||
{
|
||||
"review_id": r["id"],
|
||||
"user": r["user"]["login"],
|
||||
"review_commit": review_commit,
|
||||
"pr_head": head_sha,
|
||||
"age_hours": round(age_seconds / 3600, 1),
|
||||
"created_at": r.get("submitted_at") or r.get("created_at", ""),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"signal": "stale_reviews",
|
||||
"stale_reviews": stale,
|
||||
"verdict": "STALE-RC" if stale else "CLEAR",
|
||||
}
|
||||
|
||||
|
||||
# ── Signal 6: CI required-checks awareness ───────────────────────────────────
|
||||
|
||||
def signal_6_ci(pr_number: int, repo: str, branch: str = "main") -> dict:
|
||||
"""
|
||||
Query combined CI status for PR head commit.
|
||||
Find required status checks on target branch.
|
||||
Surface any failing required check as primary blocker.
|
||||
"""
|
||||
owner, name = repo.split("/", 1)
|
||||
|
||||
pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
|
||||
head_sha = pr["head"]["sha"]
|
||||
|
||||
# Combined status of PR head
|
||||
combined = api_get(f"/repos/{owner}/{name}/commits/{head_sha}/status")
|
||||
ci_state = combined.get("state", "null")
|
||||
|
||||
# Individual check statuses
|
||||
# Gitea Actions uses "status" (pending/success/failure) not "state" for
|
||||
# individual check entries. "state" is null for pending runs.
|
||||
check_statuses = {}
|
||||
for s in combined.get("statuses") or []:
|
||||
check_statuses[s["context"]] = s.get("status", "pending")
|
||||
|
||||
# Try to get branch protection for required checks
|
||||
required_checks = []
|
||||
try:
|
||||
protection = api_get(f"/repos/{owner}/{name}/branches/{branch}/protection")
|
||||
for check in protection.get("required_status_checks", {}).get("checks", []):
|
||||
required_checks.append(check["context"])
|
||||
except GiteaError:
|
||||
pass # No protection or no read access
|
||||
|
||||
failing_required = []
|
||||
passing_required = []
|
||||
for ctx in required_checks:
|
||||
state = check_statuses.get(ctx, "null")
|
||||
if state == "failure":
|
||||
failing_required.append(ctx)
|
||||
elif state in ("success", "neutral"):
|
||||
passing_required.append(ctx)
|
||||
else:
|
||||
passing_required.append(f"{ctx} (pending)")
|
||||
|
||||
if failing_required:
|
||||
verdict = "CI_FAIL"
|
||||
elif ci_state == "failure":
|
||||
verdict = "CI_FAIL"
|
||||
elif ci_state == "pending":
|
||||
verdict = "CI_PENDING"
|
||||
else:
|
||||
verdict = "CLEAR"
|
||||
|
||||
return {
|
||||
"signal": "ci_checks",
|
||||
"combined_state": ci_state,
|
||||
"required_checks": required_checks,
|
||||
"failing_required": failing_required,
|
||||
"passing_required": passing_required,
|
||||
"all_check_statuses": check_statuses,
|
||||
"verdict": verdict,
|
||||
}
|
||||
|
||||
|
||||
# ── Gate evaluation ───────────────────────────────────────────────────────────
|
||||
|
||||
VERDICT_ORDER = {"ERROR": 0, "CI_FAIL": 1, "BLOCKED": 2, "STALE-RC": 3, "CI_PENDING": 4, "N/A": 5, "CLEAR": 6}
|
||||
|
||||
|
||||
def compute_verdict(gates: list[dict]) -> tuple[str, list[dict]]:
|
||||
"""Compute overall verdict from gate results. Worst gate wins."""
|
||||
worst = "CLEAR"
|
||||
blockers = []
|
||||
for g in gates:
|
||||
v = g.get("verdict", "N/A")
|
||||
if VERDICT_ORDER.get(v, 99) < VERDICT_ORDER.get(worst, 0):
|
||||
worst = v
|
||||
if v in ("BLOCKED", "CI_FAIL", "STALE-RC", "ERROR"):
|
||||
blockers.append(g)
|
||||
return worst, blockers
|
||||
|
||||
|
||||
def format_gate_verdict(v: str) -> tuple[str, str]:
|
||||
"""Return (icon, label) for a gate verdict."""
|
||||
if v in ("APPROVED", "CLEAR"):
|
||||
return "✅", v
|
||||
if v in ("BLOCKED", "CI_FAIL", "ERROR"):
|
||||
return "❌", v
|
||||
return "⚠️", v
|
||||
|
||||
|
||||
def format_comment(repo: str, pr_number: int, verdict: str, gates: list[dict], blockers: list[dict]) -> str:
|
||||
"""Format human-readable Gitea PR comment."""
|
||||
gate_labels = {
|
||||
"agent_tag_comments": "Agent-tag gates",
|
||||
"request_changes_reviews": "REQUEST_CHANGES reviews",
|
||||
"stale_reviews": "Staleness check",
|
||||
"ci_checks": "CI required checks",
|
||||
}
|
||||
|
||||
lines = [f"[gate-check-v3] STATUS: **{verdict}**", ""]
|
||||
|
||||
# Per-gate summary
|
||||
for g in gates:
|
||||
sig = g.get("signal", "?")
|
||||
label = gate_labels.get(sig, sig)
|
||||
v = g.get("verdict", "N/A")
|
||||
icon, _ = format_gate_verdict(v)
|
||||
lines.append(f"{icon} **{label}**: {v}")
|
||||
|
||||
# Gate-specific detail
|
||||
if blockers:
|
||||
lines.append("")
|
||||
lines.append("### Blockers")
|
||||
for b in blockers:
|
||||
sig = b.get("signal", "?")
|
||||
if sig == "request_changes_reviews":
|
||||
for r in b.get("blocking_reviews", []):
|
||||
lines.append(f" - @{r['user']} requested changes (review id={r['review_id']})")
|
||||
elif sig == "ci_checks":
|
||||
combined = b.get("combined_state", "?")
|
||||
lines.append(f" - CI combined state: **{combined}**")
|
||||
for c in b.get("failing_required", []):
|
||||
lines.append(f" - required check failing: **{c}**")
|
||||
for c in b.get("all_check_statuses", {}).items():
|
||||
ctx, state = c
|
||||
lines.append(f" - {ctx}: {state}")
|
||||
elif sig == "stale_reviews":
|
||||
for r in b.get("stale_reviews", []):
|
||||
lines.append(
|
||||
f" - @{r['user']} stale (commit={r.get('review_commit','?')[:7]}, age={r.get('age_hours','?')}h)"
|
||||
)
|
||||
elif sig == "agent_tag_comments":
|
||||
for agent, res in b.get("results", {}).items():
|
||||
v = res.get("verdict", "MISSING")
|
||||
icon, _ = format_gate_verdict(v)
|
||||
if v == "MISSING":
|
||||
lines.append(f" {icon} {agent}: no agent-tag comment found")
|
||||
else:
|
||||
lines.append(f" {icon} {agent}: {v}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"_gate-check-v3 · repo={repo} · pr={pr_number}_")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"_gate-check-v3 · repo={repo} · pr={pr_number}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(repo: str, pr_number: int, post_comment: bool = False) -> dict:
|
||||
try:
|
||||
gates = [
|
||||
signal_1_comment_scan(pr_number, repo),
|
||||
signal_2_reviews(pr_number, repo),
|
||||
signal_3_staleness(pr_number, repo),
|
||||
signal_6_ci(pr_number, repo),
|
||||
]
|
||||
verdict, blockers = compute_verdict(gates)
|
||||
|
||||
result = {
|
||||
"verdict": verdict,
|
||||
"repo": repo,
|
||||
"pr": pr_number,
|
||||
"gates": gates,
|
||||
"blockers": blockers,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
# Print human-readable to stdout for Gitea Actions log
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
# Optionally post comment
|
||||
if post_comment:
|
||||
owner, name = repo.split("/", 1)
|
||||
comment_body = format_comment(repo, pr_number, verdict, gates, blockers)
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
# Check if a gate-check comment already exists to avoid spamming
|
||||
existing = api_list(f"/repos/{owner}/{name}/issues/{pr_number}/comments")
|
||||
our_comments = [c for c in existing if "[gate-check-v3]" in (c.get("body") or "")]
|
||||
if our_comments:
|
||||
# Update latest
|
||||
comment_id = our_comments[-1]["id"]
|
||||
url = f"{API_BASE}/repos/{owner}/{name}/issues/comments/{comment_id}"
|
||||
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="PATCH")
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
else:
|
||||
url = f"{API_BASE}/repos/{owner}/{name}/issues/{pr_number}/comments"
|
||||
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
|
||||
return result
|
||||
|
||||
except GiteaError as e:
|
||||
result = {"verdict": "ERROR", "error": str(e), "repo": repo, "pr": pr_number}
|
||||
print(json.dumps(result, indent=2), file=sys.stderr)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="gate-check-v3 — PR gate detector")
|
||||
parser.add_argument("--repo", required=True, help="org/repo (e.g. molecule-ai/molecule-core)")
|
||||
parser.add_argument("--pr", type=int, required=True, help="PR number")
|
||||
parser.add_argument("--post-comment", action="store_true", help="Post/update comment on PR")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = run(args.repo, args.pr, post_comment=args.post_comment)
|
||||
verdict = result.get("verdict", "ERROR")
|
||||
|
||||
if verdict == "ERROR":
|
||||
return 2
|
||||
elif verdict in ("BLOCKED", "CI_FAIL", "STALE-RC", "ERROR"):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user