diff --git a/.github/workflows/regression-e2e.yaml b/.github/workflows/regression-e2e.yaml index cd1dcf10628..3658a130449 100644 --- a/.github/workflows/regression-e2e.yaml +++ b/.github/workflows/regression-e2e.yaml @@ -323,14 +323,13 @@ jobs: with: node-version: "22" - - name: Run WhatsApp compact-QR reporter-workflow E2E test - run: bash test/e2e/test-whatsapp-qr-compact-e2e.sh + - name: Install root dependencies + run: npm ci --ignore-scripts - - name: Upload WhatsApp compact-QR E2E logs on failure - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: whatsapp-qr-compact-e2e-logs - path: | - /tmp/nemoclaw-e2e-whatsapp-qr-install.log - if-no-files-found: ignore + - name: Run WhatsApp compact-QR reporter-workflow Vitest test + env: + NEMOCLAW_RUN_E2E_SCENARIOS: "1" + run: | + npx vitest run --project e2e-scenarios-live \ + test/e2e-scenario/live/whatsapp-qr-compact.test.ts \ + --silent=false --reporter=default diff --git a/test/e2e-scenario/live/whatsapp-qr-compact.test.ts b/test/e2e-scenario/live/whatsapp-qr-compact.test.ts new file mode 100644 index 00000000000..09445602370 --- /dev/null +++ b/test/e2e-scenario/live/whatsapp-qr-compact.test.ts @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { expect, test } from "vitest"; + +import { testTimeoutOptions } from "../../helpers/timeouts"; + +// Migrated from test/e2e/test-whatsapp-qr-compact-e2e.sh. This hermetic +// reporter-workflow coverage guard for #4522 installs the exact OpenClaw / +// @openclaw/whatsapp versions bundled by Dockerfile.base and measures the real +// upstream terminal QR renderer with and without the NemoClaw compact preload. +// It intentionally does not require a WhatsApp account, phone scan, sandbox, +// Docker, or NVIDIA_API_KEY: the legacy contract is the renderer boundary. + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); +const DOCKERFILE_BASE = path.join(REPO_ROOT, "Dockerfile.base"); +const PRELOAD = path.join(REPO_ROOT, "nemoclaw-blueprint", "scripts", "whatsapp-qr-compact.js"); +const INSTALL_TIMEOUT_MS = 180_000; +const PROBE_TIMEOUT_MS = 30_000; +const COMPACT_MAX_ROWS = Number.parseInt(process.env.WHATSAPP_QR_COMPACT_MAX_ROWS ?? "40", 10); +const OVERSIZE_MIN_ROWS = Number.parseInt(process.env.WHATSAPP_QR_OVERSIZE_MIN_ROWS ?? "50", 10); + +const PROBE_SOURCE = `import { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime"; +const strip = (s) => s.replace(/\\x1b\\[[0-9;]*m/g, ""); +// ref,noiseKey,signedIdentityKey,advSecret — the four comma-joined fields a +// baileys WhatsApp Web QR carries; long and dense like the real payload. +const qr = + "2@" + "ABcd12".repeat(8) + "," + "a8K3".repeat(11) + "=," + + "Xy90".repeat(11) + "=," + "Qr5T".repeat(9) + "="; +// Call exactly as the plugin does at session login: renderQrTerminal(qr), with +// no { small }, so this exercises the real default rather than a contrived opt-in. +const out = strip(await renderQrTerminal(qr)); +const lines = out.split("\\n"); +process.stdout.write(JSON.stringify({ + rows: lines.length, + cols: Math.max(...lines.map((l) => [...l].length)), +})); +`; + +type CommandResult = { + status: number | null; + stdout: string; + stderr: string; +}; + +type Dimensions = { + rows: number; + cols: number; +}; + +function runCommand( + command: string, + args: string[], + options: { cwd: string; env?: Record; timeoutMs: number }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...(options.env ?? {}) }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 1_000).unref(); + }, options.timeoutMs); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (status) => { + clearTimeout(timeout); + resolve({ status, stdout, stderr }); + }); + }); +} + +async function readBundledOpenClawVersion(): Promise { + const dockerfile = await fs.readFile(DOCKERFILE_BASE, "utf8"); + const match = dockerfile.match(/^ARG OPENCLAW_VERSION=(\S+)\s*$/m); + if (!match?.[1]) { + throw new Error("could not parse OPENCLAW_VERSION from Dockerfile.base"); + } + return match[1]; +} + +async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +async function fileContains(root: string, needle: string): Promise { + const entries = await fs.readdir(root, { withFileTypes: true }); + for (const entry of entries) { + const target = path.join(root, entry.name); + if (entry.isDirectory()) { + if (await fileContains(target, needle)) return true; + continue; + } + if (!entry.isFile()) continue; + const text = await fs.readFile(target, "utf8"); + if (text.includes(needle)) return true; + } + return false; +} + +function parseDimensions(stdout: string, label: string): Dimensions { + const parsed = JSON.parse(stdout.trim()) as Partial; + expect(typeof parsed.rows, `${label} rows must be numeric`).toBe("number"); + expect(typeof parsed.cols, `${label} cols must be numeric`).toBe("number"); + return parsed as Dimensions; +} + +test( + "WhatsApp pairing QR renders compact with the NemoClaw preload", + testTimeoutOptions(INSTALL_TIMEOUT_MS + PROBE_TIMEOUT_MS * 2), + async () => { + expect(await pathExists(PRELOAD), `compact-QR preload missing: ${PRELOAD}`).toBe(true); + const openclawVersion = await readBundledOpenClawVersion(); + + const workdir = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-wa-qr-e2e-")); + try { + await fs.writeFile( + path.join(workdir, "package.json"), + `${JSON.stringify({ name: "wa-qr-e2e", version: "1.0.0", private: true })}\n`, + ); + + const install = await runCommand( + "npm", + [ + "install", + "--no-audit", + "--no-fund", + `openclaw@${openclawVersion}`, + `@openclaw/whatsapp@${openclawVersion}`, + ], + { cwd: workdir, timeoutMs: INSTALL_TIMEOUT_MS }, + ); + expect(install.status, `npm install failed\n${install.stderr}`).toBe(0); + + const whatsappDist = path.join(workdir, "node_modules", "@openclaw", "whatsapp", "dist"); + expect( + await fileContains(whatsappDist, "renderQrTerminal"), + "plugin channel-login must render through renderQrTerminal", + ).toBe(true); + + await fs.writeFile(path.join(workdir, "probe.mjs"), PROBE_SOURCE); + + const baseline = await runCommand("node", ["probe.mjs"], { + cwd: workdir, + timeoutMs: PROBE_TIMEOUT_MS, + }); + expect(baseline.status, `baseline probe failed\n${baseline.stderr}`).toBe(0); + const baselineDimensions = parseDimensions(baseline.stdout, "baseline"); + expect( + baselineDimensions.rows, + `baseline QR should reproduce the oversized form (${baselineDimensions.rows} rows)`, + ).toBeGreaterThanOrEqual(OVERSIZE_MIN_ROWS); + + const patched = await runCommand("node", ["probe.mjs"], { + cwd: workdir, + env: { NODE_OPTIONS: `--require ${PRELOAD}` }, + timeoutMs: PROBE_TIMEOUT_MS, + }); + expect(patched.status, `patched probe failed\n${patched.stderr}`).toBe(0); + const patchedDimensions = parseDimensions(patched.stdout, "patched"); + expect( + patchedDimensions.rows, + `compact QR should fit the scan frame (${patchedDimensions.rows} rows)`, + ).toBeLessThanOrEqual(COMPACT_MAX_ROWS); + expect(patchedDimensions.rows).toBeLessThan(baselineDimensions.rows); + } finally { + await fs.rm(workdir, { recursive: true, force: true }); + } + }, +); diff --git a/test/e2e-script-workflow.test.ts b/test/e2e-script-workflow.test.ts index da3de1c2263..a4c4cdc384b 100644 --- a/test/e2e-script-workflow.test.ts +++ b/test/e2e-script-workflow.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; @@ -80,7 +80,6 @@ const LEGACY_E2E_SHELL_ALLOWLIST = [ "test/e2e/test-tunnel-lifecycle.sh", "test/e2e/test-upgrade-stale-sandbox.sh", "test/e2e/test-vm-driver-privileged-exec-routing.sh", - "test/e2e/test-whatsapp-qr-compact-e2e.sh", ]; // Scheduled nightly wiring is frozen separately: retiring a nightly-wired legacy diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index da94723ffb4..ce5d39ee7c4 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -992,7 +992,8 @@ fi # renderer cannot be resolved/executed at all (an infra/resolution issue, not a # size regression) the sub-check SKIPs rather than failing the suite — an actual # oversized render still yields a number above the ceiling and fails. The -# hard-gated, version-pinned size proof lives in test-whatsapp-qr-compact-e2e.sh. +# hard-gated, version-pinned size proof lives in +# test/e2e-scenario/live/whatsapp-qr-compact.test.ts. WHATSAPP_QR_RENDER_PROBE=$( cat <<'PROBE' import { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime"; diff --git a/test/e2e/test-whatsapp-qr-compact-e2e.sh b/test/e2e/test-whatsapp-qr-compact-e2e.sh deleted file mode 100755 index 950898a3456..00000000000 --- a/test/e2e/test-whatsapp-qr-compact-e2e.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Reporter-workflow coverage guard for NemoClaw#4522 — the in-sandbox WhatsApp -# pairing QR (`openclaw channels login --channel whatsapp`) must render compact -# enough to scan with a phone. -# -# WHY THIS SHAPE: a full live pairing cannot be automated — it needs a real -# WhatsApp account and a phone to scan the code. But the bug is purely in QR -# *rendering*, which happens in the plugin's `onQr` callback BEFORE any phone -# interaction. That callback renders through `renderQrTerminal()` in -# `openclaw/plugin-sdk/media-runtime`, which calls the `qrcode` package's -# `toString(text, { type: "terminal", small })`. This test installs the EXACT -# `@openclaw/whatsapp` + `openclaw` versions the sandbox bundles (pinned to the -# OPENCLAW_VERSION ARG in Dockerfile.base) and drives that real renderer with a -# representative WhatsApp pairing payload, measuring the rendered dimensions -# with and without the NemoClaw compact-QR preload. -# -# This proves rendered QR *size* (not merely that the preload file exists), -# through the same upstream symbol the reporter workflow invokes. It is fully -# hermetic: it needs only npm (to fetch the pinned plugin) and node — no Docker, -# no GPU, no NVIDIA_API_KEY, no sandbox. -# -# Ref: https://github.com/NVIDIA/NemoClaw/issues/4522 - -set -uo pipefail - -PASS=0 -FAIL=0 - -pass() { - ((PASS++)) - echo " OK: $1" -} -fail() { - ((FAIL++)) - echo " ERROR: $1" -} -section() { - echo "" - printf '\033[1;36m=== %s ===\033[0m\n' "$1" -} -info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "${SCRIPT_DIR}/../.." && pwd)" -PRELOAD="${REPO}/nemoclaw-blueprint/scripts/whatsapp-qr-compact.js" - -# Scan-friendly ceiling: a half-block WhatsApp QR is ~29 rows. 40 leaves head -# room for QR-version drift while staying well under the ~56-row full-size form -# the reporter saw. The oversize floor (50) guards that we are still measuring -# the real, un-compacted render in the baseline. -COMPACT_MAX_ROWS="${WHATSAPP_QR_COMPACT_MAX_ROWS:-40}" -OVERSIZE_MIN_ROWS="${WHATSAPP_QR_OVERSIZE_MIN_ROWS:-50}" - -WORKDIR="$(mktemp -d /tmp/nemoclaw-wa-qr-e2e.XXXXXX)" -# shellcheck disable=SC2329 # invoked via the EXIT trap below -cleanup() { rm -rf "$WORKDIR" 2>/dev/null || true; } -trap cleanup EXIT - -section "Prerequisites" -if command -v node >/dev/null 2>&1; then - pass "node is available: $(node --version)" -else - fail "node is required" - exit 1 -fi -if command -v npm >/dev/null 2>&1; then - pass "npm is available: $(npm --version)" -else - fail "npm is required" - exit 1 -fi -if [ -f "$PRELOAD" ]; then - pass "compact-QR preload present: $PRELOAD" -else - fail "compact-QR preload missing: $PRELOAD" - exit 1 -fi - -section "Resolve bundled OpenClaw / WhatsApp plugin version" -# Single source of truth: the OPENCLAW_VERSION ARG default in Dockerfile.base. -# The sandbox installs @openclaw/whatsapp pinned to this same version -# (scripts/openclaw-build-messaging-plugins.py), so the rendered QR we measure -# matches what a real sandbox would show. -OC_VERSION="$(grep -m1 -E '^ARG OPENCLAW_VERSION=' "${REPO}/Dockerfile.base" | cut -d= -f2 | tr -d '[:space:]')" -if [ -n "$OC_VERSION" ]; then - pass "bundled OpenClaw version resolved: ${OC_VERSION}" -else - fail "could not parse OPENCLAW_VERSION from Dockerfile.base" - exit 1 -fi - -section "Install pinned @openclaw/whatsapp + openclaw" -(cd "$WORKDIR" && printf '{ "name": "wa-qr-e2e", "version": "1.0.0", "private": true }\n' >package.json) -# Keep the install log outside WORKDIR (which the EXIT trap removes) so CI can -# upload it as a failure artifact for debugging. -install_log="${E2E_WHATSAPP_QR_INSTALL_LOG:-/tmp/nemoclaw-e2e-whatsapp-qr-install.log}" -if (cd "$WORKDIR" && npm install --no-audit --no-fund \ - "openclaw@${OC_VERSION}" "@openclaw/whatsapp@${OC_VERSION}" >"$install_log" 2>&1); then - pass "installed openclaw@${OC_VERSION} and @openclaw/whatsapp@${OC_VERSION}" -else - fail "npm install failed; see ${install_log}" - tail -20 "$install_log" || true - exit 1 -fi - -section "Plugin renders the pairing QR via renderQrTerminal (real path)" -# Confirm the precondition the bug depends on: the channel-login QR path uses -# renderQrTerminal from the openclaw media-runtime SDK. If a future plugin -# version stops using it, this guard should be revisited. -if grep -rqs "renderQrTerminal" "${WORKDIR}/node_modules/@openclaw/whatsapp/dist/"; then - pass "plugin channel-login renders through renderQrTerminal" -else - fail "plugin no longer references renderQrTerminal — revisit this guard" - exit 1 -fi - -# Probe program: import the EXACT symbol the plugin's onQr callback calls -# (renderQrTerminal from openclaw/plugin-sdk/media-runtime), render a -# representative WhatsApp Web Linked-Devices pairing payload, and print the -# visible (ANSI-stripped) terminal dimensions as JSON. -cat >"${WORKDIR}/probe.mjs" <<'PROBE' -import { renderQrTerminal } from "openclaw/plugin-sdk/media-runtime"; -const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ""); -// ref,noiseKey,signedIdentityKey,advSecret — the four comma-joined fields a -// baileys WhatsApp Web QR carries; long and dense like the real payload. -const qr = - "2@" + "ABcd12".repeat(8) + "," + "a8K3".repeat(11) + "=," + - "Xy90".repeat(11) + "=," + "Qr5T".repeat(9) + "="; -// Call EXACTLY as the plugin does at session login: renderQrTerminal(qr), -// with no { small } — so we exercise the real default, not a contrived opt-in. -const out = strip(await renderQrTerminal(qr)); -const lines = out.split("\n"); -process.stdout.write(JSON.stringify({ - rows: lines.length, - cols: Math.max(...lines.map((l) => [...l].length)), -})); -PROBE - -run_probe() { - # $1: "with" | "without" preload - if [ "$1" = "with" ]; then - (cd "$WORKDIR" && NODE_OPTIONS="--require ${PRELOAD}" node probe.mjs) - else - (cd "$WORKDIR" && node probe.mjs) - fi -} - -section "Baseline (no preload) reproduces the oversized QR" -baseline_json="$(run_probe without)" || { - fail "baseline probe failed" - exit 1 -} -baseline_rows="$(printf '%s' "$baseline_json" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).rows))')" -info "baseline rendered dimensions: ${baseline_json}" -if [ "$baseline_rows" -ge "$OVERSIZE_MIN_ROWS" ]; then - pass "baseline QR is oversized (${baseline_rows} rows >= ${OVERSIZE_MIN_ROWS}) — reproduces NemoClaw#4522" -else - fail "baseline QR was only ${baseline_rows} rows; expected >= ${OVERSIZE_MIN_ROWS} (precondition for the bug)" - exit 1 -fi - -section "With NemoClaw compact-QR preload, the QR is scan-friendly" -patched_json="$(run_probe with)" || { - fail "patched probe failed" - exit 1 -} -patched_rows="$(printf '%s' "$patched_json" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).rows))')" -info "compact rendered dimensions: ${patched_json}" -if [ "$patched_rows" -le "$COMPACT_MAX_ROWS" ]; then - pass "compact QR fits a scan frame (${patched_rows} rows <= ${COMPACT_MAX_ROWS})" -else - fail "compact QR was ${patched_rows} rows; expected <= ${COMPACT_MAX_ROWS}" -fi -if [ "$patched_rows" -lt "$baseline_rows" ]; then - pass "preload strictly shrinks the QR (${baseline_rows} -> ${patched_rows} rows)" -else - fail "preload did not shrink the QR (${baseline_rows} -> ${patched_rows} rows)" -fi - -section "Summary" -echo " PASS=${PASS} FAIL=${FAIL}" -if [ "$FAIL" -eq 0 ]; then - echo " WhatsApp compact-QR reporter-workflow E2E passed" - exit 0 -fi -exit 1 diff --git a/test/regression-e2e-workflow.test.ts b/test/regression-e2e-workflow.test.ts index fa3da7fdea3..4963ddd4995 100644 --- a/test/regression-e2e-workflow.test.ts +++ b/test/regression-e2e-workflow.test.ts @@ -50,4 +50,13 @@ describe("Regression E2E workflow contract", () => { expect(selectorScript).not.toContain("strict-tool-call-probe-e2e"); expect(selectorScript).not.toContain("strict_tool_call_probe"); }); + + it("runs WhatsApp compact QR through Vitest instead of the retired shell script", () => { + const job = workflow.jobs?.["whatsapp-qr-compact-e2e"]; + const runText = (job?.steps ?? []).map((step) => step.run ?? "").join("\n"); + + expect(runText).toContain("test/e2e-scenario/live/whatsapp-qr-compact.test.ts"); + expect(runText).toContain("npx vitest run --project e2e-scenarios-live"); + expect(runText).not.toContain("test/e2e/test-whatsapp-qr-compact-e2e.sh"); + }); }); diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index b3d39311ff3..be91ced7145 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -2,11 +2,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; const REPO_ROOT = path.join(import.meta.dirname, ".."); const START_SCRIPT = path.join(REPO_ROOT, "scripts", "nemoclaw-start.sh"); @@ -23,8 +23,8 @@ const PRELOAD_SOURCE = path.join( // the bundled @openclaw/whatsapp passes NO `small`, so it defaults to full // size. These tests prove the preload patches that real package shape. End-to- // end proof that this shrinks a *real* rendered QR lives in -// test/e2e/test-whatsapp-qr-compact-e2e.sh, which drives the actual upstream -// renderer at the version bundled in Dockerfile.base. Ref: NemoClaw#4522. +// test/e2e-scenario/live/whatsapp-qr-compact.test.ts, which drives the actual +// upstream renderer at the version bundled in Dockerfile.base. Ref: NemoClaw#4522. // A fake `qrcode` package (toString + create — the shape the preload keys on) // and a fake `qrcode-terminal` (generate). Each records the options it was @@ -200,8 +200,9 @@ describe("WhatsApp compact-QR preload (qrcode package)", () => { // is exercised behaviorally rather than by asserting on source text: the guard // describe-block below executes the extracted openclaw() function and checks the // --require injection, and the end-to-end renderer E2E -// (test/e2e/test-whatsapp-qr-compact-e2e.sh) plus the in-sandbox M-WA6d check in -// test-messaging-providers.sh prove the wired preload actually shrinks the QR. +// (test/e2e-scenario/live/whatsapp-qr-compact.test.ts) plus the in-sandbox +// M-WA6d check in test-messaging-providers.sh prove the wired preload actually +// shrinks the QR. // Extract the sandbox-side `openclaw()` guard function from the single-quoted // heredoc so we can exercise the WhatsApp login branch without a live sandbox.