From 880e88ae1460e8b56955ebd256dbd361b4015989 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 21 Jun 2026 18:03:34 -0700 Subject: [PATCH] test(e2e): migrate channels stop/start to Vitest --- .github/workflows/e2e-vitest-scenarios.yaml | 89 +++++ .../live/channels-stop-start.test.ts | 346 ++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 test/e2e-scenario/live/channels-stop-start.test.ts diff --git a/.github/workflows/e2e-vitest-scenarios.yaml b/.github/workflows/e2e-vitest-scenarios.yaml index 844109e4bb..e2178b349c 100644 --- a/.github/workflows/e2e-vitest-scenarios.yaml +++ b/.github/workflows/e2e-vitest-scenarios.yaml @@ -4133,6 +4133,94 @@ jobs: docker logout docker.io || true rm -rf "${DOCKER_CONFIG}" + channels-stop-start-vitest: + needs: generate-matrix + if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',channels-stop-start-vitest,') || contains(format(',{0},', inputs.scenarios), ',channels-stop-start,') }} + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + agent: [openclaw, hermes] + env: + FREE_STANDING_VITEST_JOB: "1" + FREE_STANDING_SCENARIO_ID: "channels-stop-start" + DOCKER_CONFIG: ${{ github.workspace }}/.docker-config-channels-stop-start-${{ matrix.agent }} + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/channels-stop-start/${{ matrix.agent }} + NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js + NEMOCLAW_RUN_E2E_SCENARIOS: "1" + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_AGENT: ${{ matrix.agent }} + NEMOCLAW_CHANNELS_STOP_START_AGENT: ${{ matrix.agent }} + NEMOCLAW_SANDBOX_NAME: e2e-channels-stop-start-${{ matrix.agent }} + OPENSHELL_GATEWAY: "nemoclaw" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Authenticate to Docker Hub + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then + echo "::notice::Docker Hub credentials not configured; continuing with anonymous pulls." + exit 0 + fi + mkdir -p "${DOCKER_CONFIG}" + chmod 700 "${DOCKER_CONFIG}" + echo "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin || echo "::warning::Docker Hub login failed; continuing with anonymous pulls." + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: 22 + cache: npm + + - name: Install root dependencies + run: npm ci --ignore-scripts + + - name: Build CLI + run: npm run build:cli + + - name: Run channels stop/start live test + # Migrated from test/e2e/test-channels-stop-start.sh. Preserves the + # OpenClaw/Hermes messaging channel stop/start, rebuild, provider + # reuse, registry, policy-list, and in-sandbox config contracts. + env: + NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} + TELEGRAM_BOT_TOKEN: test-fake-telegram-token-stop-start-${{ matrix.agent }} + DISCORD_BOT_TOKEN: test-fake-discord-token-stop-start-${{ matrix.agent }} + SLACK_BOT_TOKEN: xoxb-fake-slack-token-stop-start-${{ matrix.agent }} + SLACK_APP_TOKEN: xapp-fake-slack-token-stop-start-${{ matrix.agent }} + WECHAT_BOT_TOKEN: test-fake-wechat-token-stop-start-${{ matrix.agent }} + run: | + set -euo pipefail + npx vitest run --project e2e-scenarios-live \ + test/e2e-scenario/live/channels-stop-start.test.ts \ + --silent=false --reporter=default + + - name: Upload channels stop/start artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-vitest-scenarios-channels-stop-start-${{ matrix.agent }} + path: e2e-artifacts/vitest/channels-stop-start/${{ matrix.agent }}/ + include-hidden-files: false + if-no-files-found: ignore + retention-days: 14 + + - name: Clean up Docker auth + if: always() + run: | + set -euo pipefail + docker logout docker.io || true + rm -rf "${DOCKER_CONFIG}" + issue-2478-crash-loop-recovery-vitest: needs: generate-matrix if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',issue-2478-crash-loop-recovery-vitest,') || contains(format(',{0},', inputs.scenarios), ',issue-2478-crash-loop-recovery,') }} @@ -4304,6 +4392,7 @@ jobs: device-auth-health-vitest, channels-add-remove-vitest, telegram-injection-vitest, + channels-stop-start-vitest, ] if: ${{ always() && github.event_name == 'workflow_dispatch' }} permissions: diff --git a/test/e2e-scenario/live/channels-stop-start.test.ts b/test/e2e-scenario/live/channels-stop-start.test.ts new file mode 100644 index 0000000000..b136cc9dd9 --- /dev/null +++ b/test/e2e-scenario/live/channels-stop-start.test.ts @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Live Vitest replacement for test/e2e/test-channels-stop-start.sh. */ + +import { expect, test } from "../fixtures/e2e-test.ts"; +import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts"; +import { + type AgentKind, + arrayRecords, + cleanupSandbox, + dockerInfo, + expectExitZero, + expectSandboxReady, + installSandbox, + messagingPlan, + phase6Env, + phase6Tokens, + rebuildSandbox, + redactionValues, + resultText, + sandboxSh, + stringArray, +} from "./phase6-messaging-helpers.ts"; + +const AGENT = (process.env.NEMOCLAW_CHANNELS_STOP_START_AGENT ?? + process.env.NEMOCLAW_AGENT ?? + "openclaw") as AgentKind; +if (AGENT !== "openclaw" && AGENT !== "hermes") { + throw new Error(`NEMOCLAW_CHANNELS_STOP_START_AGENT must be openclaw or hermes, got ${AGENT}`); +} +const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? `e2e-channels-stop-start-${AGENT}`; +const CHANNELS = ["telegram", "discord", "wechat", "slack", "whatsapp"] as const; +const PROVIDERS: Record string[]> = { + telegram: (sandbox) => [`${sandbox}-telegram-bridge`], + discord: (sandbox) => [`${sandbox}-discord-bridge`], + wechat: (sandbox) => [`${sandbox}-wechat-bridge`], + slack: (sandbox) => [`${sandbox}-slack-bridge`, `${sandbox}-slack-app`], + whatsapp: () => [], +}; +const LIVE_TIMEOUT_MS = 80 * 60_000; + +type ChannelState = "active" | "disabled"; + +function planChannel(channelId: string) { + return arrayRecords(messagingPlan(SANDBOX_NAME).channels).find( + (channel) => channel.channelId === channelId, + ); +} + +function expectPlanChannelState(channelId: string, expected: ChannelState): void { + const plan = messagingPlan(SANDBOX_NAME); + const channel = planChannel(channelId); + expect(channel, `${channelId} missing from messaging.plan.channels`).toBeTruthy(); + expect(channel?.configured, `${channelId} configured`).toBe(true); + expect(plan.sandboxName, "messaging.plan.sandboxName").toBe(SANDBOX_NAME); + expect(plan.agent, "messaging.plan.agent").toBe(AGENT); + + const disabledChannels = stringArray(plan.disabledChannels); + if (expected === "active") { + expect(channel?.active, `${channelId} active`).toBe(true); + expect(channel?.disabled, `${channelId} disabled unexpectedly`).not.toBe(true); + expect(disabledChannels, `${channelId} unexpectedly disabled`).not.toContain(channelId); + } else { + expect(channel?.disabled, `${channelId} disabled`).toBe(true); + expect(channel?.active, `${channelId} active unexpectedly`).not.toBe(true); + expect(disabledChannels, `${channelId} missing from disabledChannels`).toContain(channelId); + } + + const networkPolicy = + plan.networkPolicy && typeof plan.networkPolicy === "object" + ? (plan.networkPolicy as Record) + : {}; + expect(stringArray(networkPolicy.presets), `${channelId} policy preset`).toContain(channelId); + expect( + arrayRecords(networkPolicy.entries).some((entry) => entry.channelId === channelId), + `${channelId} policy entry`, + ).toBe(true); + const credentialBindings = arrayRecords(plan.credentialBindings); + if (channelId !== "whatsapp") { + expect( + credentialBindings.some((entry) => entry.channelId === channelId), + `${channelId} credential binding`, + ).toBe(true); + } + expect(Object.hasOwn(plan, "agentRender"), "messaging.plan.agentRender should not persist").toBe( + false, + ); +} + +function expectChannelInputs(): void { + const expected: Record> = { + telegram: { + allowedIds: process.env.TELEGRAM_ALLOWED_IDS ?? "123456789,987654321", + requireMention: process.env.TELEGRAM_REQUIRE_MENTION ?? "0", + }, + discord: { + serverId: process.env.DISCORD_SERVER_ID ?? "1491590992753590594", + userId: process.env.DISCORD_USER_ID ?? "1005536447329222676", + requireMention: process.env.DISCORD_REQUIRE_MENTION ?? "0", + }, + slack: { allowedUsers: process.env.SLACK_ALLOWED_USERS ?? "U0123456789,U09ABCDEFGH" }, + wechat: { + allowedIds: + process.env.WECHAT_ALLOWED_IDS ?? process.env.WECHAT_USER_ID ?? "wxid_e2e_operator", + }, + }; + for (const [channelId, inputs] of Object.entries(expected)) { + const channel = planChannel(channelId); + const planInputs = arrayRecords(channel?.inputs); + for (const [inputId, value] of Object.entries(inputs)) { + expect( + planInputs.find((input) => input.inputId === inputId)?.value, + `${channelId}.${inputId}`, + ).toBe(value); + } + } +} + +function openClawChannelKey(channel: string): string { + return channel === "wechat" ? "openclaw-weixin" : channel; +} + +async function agentConfigContains( + sandbox: import("../fixtures/clients/sandbox.ts").SandboxClient, + channel: string, + redactions: string[], +): Promise { + if (AGENT === "openclaw") { + const result = await sandboxSh( + sandbox, + SANDBOX_NAME, + `python3 - <<'PY'\nimport json\nchannel=${JSON.stringify(openClawChannelKey(channel))}\ncfg=json.load(open('/sandbox/.openclaw/openclaw.json'))\nprint('yes' if channel in cfg.get('channels', {}) else 'no')\nPY`, + { artifactName: `config-channel-${AGENT}-${channel}`, redactionValues: redactions }, + ); + expectExitZero(result, `read OpenClaw channel ${channel}`); + return result.stdout.trim() === "yes"; + } + + const probes: Record = { + telegram: + 'grep -Eq "^TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN$" /sandbox/.hermes/.env', + discord: + 'grep -Eq "^DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN$" /sandbox/.hermes/.env', + wechat: + 'grep -Eq "^WEIXIN_TOKEN=openshell:resolve:env:WECHAT_BOT_TOKEN$" /sandbox/.hermes/.env', + slack: + 'grep -Eq "^SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN$" /sandbox/.hermes/.env && grep -Eq "^SLACK_APP_TOKEN=xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN$" /sandbox/.hermes/.env', + whatsapp: + 'grep -Eq "^WHATSAPP_ENABLED=true$" /sandbox/.hermes/.env && grep -Eq "^WHATSAPP_MODE=bot$" /sandbox/.hermes/.env', + }; + const result = await sandboxSh( + sandbox, + SANDBOX_NAME, + `if [ -r /sandbox/.hermes/.env ] && ${probes[channel]}; then echo yes; else echo no; fi`, + { artifactName: `config-channel-${AGENT}-${channel}`, redactionValues: redactions }, + ); + expectExitZero(result, `read Hermes channel ${channel}`); + return result.stdout.trim() === "yes"; +} + +async function expectAgentConfig( + sandbox: import("../fixtures/clients/sandbox.ts").SandboxClient, + expected: "present" | "absent", + redactions: string[], +): Promise { + for (const channel of CHANNELS) { + const present = await agentConfigContains(sandbox, channel, redactions); + expect(present, `${AGENT}/${channel} config ${expected}`).toBe(expected === "present"); + } +} + +async function expectProvidersExist( + host: import("../fixtures/clients/host.ts").HostCliClient, + env: NodeJS.ProcessEnv, + redactions: string[], + context: string, +): Promise { + for (const channel of CHANNELS) { + for (const provider of PROVIDERS[channel](SANDBOX_NAME)) { + const result = await host.command("openshell", ["provider", "get", provider], { + artifactName: `provider-${provider}-${context}`, + env, + redactionValues: redactions, + timeoutMs: 60_000, + }); + expectExitZero(result, `${provider} exists ${context}`); + } + } +} + +async function policyPresetActive( + host: import("../fixtures/clients/host.ts").HostCliClient, + env: NodeJS.ProcessEnv, + redactions: string[], + channel: string, +): Promise { + const result = await host.command( + "node", + [process.env.NEMOCLAW_CLI_BIN ?? "bin/nemoclaw.js", SANDBOX_NAME, "policy-list"], + { + artifactName: `policy-list-${channel}-${AGENT}`, + env, + redactionValues: redactions, + timeoutMs: 60_000, + }, + ); + expectExitZero(result, `policy-list ${channel}`); + return resultText(result).includes(`● ${channel}`); +} + +async function runChannelCommand( + host: import("../fixtures/clients/host.ts").HostCliClient, + env: NodeJS.ProcessEnv, + redactions: string[], + action: "add" | "stop" | "start", + channel: string, +): Promise { + const result = await host.command( + "node", + [process.env.NEMOCLAW_CLI_BIN ?? "bin/nemoclaw.js", SANDBOX_NAME, "channels", action, channel], + { + artifactName: `channels-${action}-${channel}-${AGENT}`, + env, + redactionValues: redactions, + timeoutMs: 10 * 60_000, + }, + ); + expectExitZero(result, `channels ${action} ${channel}`); + const expectedText = + action === "add" + ? `Enabled ${channel} channel` + : `Marked ${channel} ${action === "stop" ? "disabled" : "enabled"}`; + expect(resultText(result)).toContain(expectedText); +} + +test.skipIf(!shouldRunLiveE2EScenarios())( + `${AGENT} channels stop/start preserves credentials and toggles runtime config`, + { timeout: LIVE_TIMEOUT_MS }, + async ({ artifacts, cleanup, host, sandbox, secrets, skip }) => { + const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); + const tokens = phase6Tokens(AGENT); + const env = phase6Env({ sandboxName: SANDBOX_NAME, agent: AGENT, apiKey, tokens }); + const redactions = redactionValues(apiKey, tokens); + + await artifacts.writeJson("scenario.json", { + id: "channels-stop-start", + legacySource: "test/e2e/test-channels-stop-start.sh", + boundary: + "install.sh messaging onboard + channels stop/start CLI + rebuild + sandbox config probes", + agent: AGENT, + sandboxName: SANDBOX_NAME, + channels: CHANNELS, + }); + + cleanup.add(`destroy channels stop/start sandbox ${SANDBOX_NAME}`, () => + cleanupSandbox(host, SANDBOX_NAME, env, redactions, `cleanup-channels-stop-start-${AGENT}`), + ); + await cleanupSandbox( + host, + SANDBOX_NAME, + env, + redactions, + `preclean-channels-stop-start-${AGENT}`, + ); + + const docker = await dockerInfo(host, env); + expect(docker.exitCode, resultText(docker)).toBe(0); + try { + const install = await installSandbox( + host, + env, + redactions, + `install-channels-stop-start-${AGENT}`, + ); + expectExitZero(install, `${AGENT} install.sh`); + } catch (error) { + if (String(error).includes("NVIDIA_ENDPOINT_RATE_LIMIT")) { + skip("NVIDIA endpoint validation was rate-limited before channel lifecycle assertions ran"); + return; + } + throw error; + } + await expectSandboxReady( + host, + SANDBOX_NAME, + env, + redactions, + `sandbox-list-channels-stop-start-${AGENT}`, + ); + + if (!planChannel("whatsapp")) { + await runChannelCommand(host, env, redactions, "add", "whatsapp"); + const rebuild = await rebuildSandbox( + host, + SANDBOX_NAME, + env, + redactions, + `rebuild-add-whatsapp-${AGENT}`, + ); + expectExitZero(rebuild, "rebuild after adding WhatsApp"); + } + + expectChannelInputs(); + for (const channel of CHANNELS) expectPlanChannelState(channel, "active"); + await expectAgentConfig(sandbox, "present", redactions); + await expectProvidersExist(host, env, redactions, "baseline"); + for (const channel of CHANNELS) { + expect( + await policyPresetActive(host, env, redactions, channel), + `${channel} policy active`, + ).toBe(true); + } + + for (const channel of CHANNELS) await runChannelCommand(host, env, redactions, "stop", channel); + expectChannelInputs(); + for (const channel of CHANNELS) expectPlanChannelState(channel, "disabled"); + const stopRebuild = await rebuildSandbox( + host, + SANDBOX_NAME, + env, + redactions, + `rebuild-stop-all-${AGENT}`, + ); + expectExitZero(stopRebuild, "rebuild after stopping all channels"); + await expectAgentConfig(sandbox, "absent", redactions); + await expectProvidersExist(host, env, redactions, "after-stop"); + for (const channel of CHANNELS) expectPlanChannelState(channel, "disabled"); + + for (const channel of CHANNELS) + await runChannelCommand(host, env, redactions, "start", channel); + expectChannelInputs(); + for (const channel of CHANNELS) expectPlanChannelState(channel, "active"); + const startRebuild = await rebuildSandbox( + host, + SANDBOX_NAME, + env, + redactions, + `rebuild-start-all-${AGENT}`, + ); + expectExitZero(startRebuild, "rebuild after starting all channels"); + await expectAgentConfig(sandbox, "present", redactions); + await expectProvidersExist(host, env, redactions, "after-start"); + for (const channel of CHANNELS) expectPlanChannelState(channel, "active"); + }, +);