Repository navigation
test: backfill mockable coverage for live-only behavior + guard against it #6086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
b0ab758
test(start): pin recovery + override regressions as mocked shell-units
prekshivyas 34bc3db
fix(start): make empty-array iteration safe under bash 3.2 set -u
prekshivyas 7976da1
test: backfill mockable coverage for live-only behavior + guard again…
prekshivyas a35e24a
test: backfill medium/low mockable coverage for live-only behavior
prekshivyas b3261de
Merge branch 'main' into test/mock-recovery-reconcile-shell-units
prekshivyas ac1ead9
test: keep backfilled test bodies linear (no added if statements)
prekshivyas f477be2
Merge remote-tracking branch 'upstream/test/mock-recovery-reconcile-s…
prekshivyas c354fd5
Merge remote-tracking branch 'origin/main' into test/mock-recovery-re…
prekshivyas f7f9df5
test: address CodeRabbit review on backfilled coverage
prekshivyas 878991d
test: mirror mock-Anthropic baseline config in the fast e2e-support lane
prekshivyas 9d397ff
test(hermes): define _HERMES_PYTHON in the runtime-env boundary harness
prekshivyas a7ee8b2
test(e2e): drop unused messaging endpoint reply predicate (PRA-5)
prekshivyas be41e69
Merge branch 'main' into test/mock-recovery-reconcile-shell-units
prekshivyas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // | ||
| // Guard: pure unit blocks must not hide inside test/e2e/live/** files. | ||
| // | ||
| // vitest.config.ts only collects test/e2e/live/**/*.test.ts when live E2E is | ||
| // enabled (NEMOCLAW_RUN_LIVE_E2E=1). On PR CI that flag is false, so the entire | ||
| // file is uncollected — including any `describe(...)` unit block embedded in it. | ||
| // Such blocks are dead weight on PR CI: they read like coverage but never run | ||
| // where they could. This is exactly how two mockable regressions stayed | ||
| // unguarded (the skill-agent classifiers and the openclaw TUI-correlation | ||
| // logic, the latter saved only by a lucky root-level duplicate). | ||
| // | ||
| // Convention this guard enforces: inside test/e2e/live/**, the vitest unit | ||
| // primitive `it(` is banned. Live cases are declared with `test` — directly, or | ||
| // (more often) through a gate wrapper assigned from `shouldRunLiveE2E() ? test | ||
| // : test.skip` / `test.skipIf(!shouldRunLiveE2E())`, sometimes grouped under | ||
| // `describe.sequential(...)`. A live case never needs `it(`; when `it(` appears | ||
| // in a live file it is invariably a pure-unit block someone parked there (as | ||
| // happened with the skill-agent and messaging classifier blocks). Such a block | ||
| // is dead on PR CI and belongs in an importable module + a PR-collected test | ||
| // (root test/**, a co-located src/**/*.test.ts, or test/e2e/support/**). | ||
| // | ||
| // We deliberately do NOT try to flag bare `test(` unit cases: a live test that | ||
| // uses module-level helpers legitimately reads as `test("...", async () => …)` | ||
| // with no fixture, and is syntactically indistinguishable from a unit case. The | ||
| // `it(` ban is the reliable, zero-false-positive line. | ||
|
|
||
| import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); | ||
| const LIVE_DIR = path.join(REPO_ROOT, "test", "e2e", "live"); | ||
| const TEST_FILE_PATTERN = /\.(?:test|spec)\.(?:[cm]?[jt]s)$/; | ||
| // Match the vitest unit primitive `it(` — including `it.each(`, `it.only(`, | ||
| // `it.skip(`, etc. — as a call at a statement boundary. The leading boundary | ||
| // (line start or whitespace) prevents matching inside a custom identifier, and | ||
| // requiring a call paren after the optional member keeps non-call references | ||
| // from matching. | ||
| const IT_PRIMITIVE_PATTERN = | ||
| /(?:^|[\s;{(])it(?:\.(?:each|only|skip|todo|fails|concurrent|sequential))?\s*\(/; | ||
|
|
||
| export type LiveUnitBlockViolation = { | ||
| readonly file: string; | ||
| readonly line: number; | ||
| readonly text: string; | ||
| }; | ||
|
|
||
| function toRepoPath(absPath: string): string { | ||
| return path.relative(REPO_ROOT, absPath).split(path.sep).join("/"); | ||
| } | ||
|
|
||
| function* walkFiles(dir: string): Generator<string> { | ||
| if (!existsSync(dir)) return; | ||
| for (const entry of readdirSync(dir)) { | ||
| const absPath = path.join(dir, entry); | ||
| const stats = statSync(absPath); | ||
| if (stats.isDirectory()) { | ||
| yield* walkFiles(absPath); | ||
| } else if (stats.isFile() && TEST_FILE_PATTERN.test(entry)) { | ||
| yield absPath; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export function findLiveUnitBlocks(source: string, file: string): LiveUnitBlockViolation[] { | ||
| const violations: LiveUnitBlockViolation[] = []; | ||
| const lines = source.split(/\r\n|\r|\n/); | ||
| for (let i = 0; i < lines.length; i += 1) { | ||
| const text = lines[i] ?? ""; | ||
| const trimmed = text.trimStart(); | ||
| // Skip import lines (`import { it, test } from "vitest"`) and comments. | ||
| if (trimmed.startsWith("import ") || trimmed.startsWith("//") || trimmed.startsWith("*")) { | ||
| continue; | ||
| } | ||
| if (IT_PRIMITIVE_PATTERN.test(text)) { | ||
| violations.push({ file, line: i + 1, text: trimmed }); | ||
| } | ||
| } | ||
| return violations; | ||
| } | ||
|
|
||
| export function collectLiveUnitBlocks(dir = LIVE_DIR): LiveUnitBlockViolation[] { | ||
| return [...walkFiles(dir)] | ||
| .flatMap((absPath) => findLiveUnitBlocks(readFileSync(absPath, "utf-8"), toRepoPath(absPath))) | ||
| .sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); | ||
| } | ||
|
|
||
| export function formatViolations(violations: readonly LiveUnitBlockViolation[]): string { | ||
| const out = [ | ||
| "Live E2E unit-block guard failed.", | ||
| "", | ||
| "These test/e2e/live/** files use the vitest unit primitive it(...). That glob", | ||
| "is only collected when NEMOCLAW_RUN_LIVE_E2E=1, so an it(...) block never runs", | ||
| "on PR CI — it looks like coverage but guards nothing. Live cases use test(...)", | ||
| "(directly or via a gate wrapper); it(...) in a live file is always a pure-unit", | ||
| "block parked in the wrong place.", | ||
| "", | ||
| "Fix: extract the helper under test into an importable module (src/** or", | ||
| "test/e2e/support/**) and move the it(...) block to a PR-collected project", | ||
| "(root test/**/*.test.ts, a co-located src/**/*.test.ts, or test/e2e/support/**).", | ||
| "Keep the live test importing the shared helper.", | ||
| "", | ||
| ]; | ||
| for (const v of violations) { | ||
| out.push(`- ${v.file}:${v.line} ${v.text}`); | ||
| } | ||
| return out.join("\n"); | ||
| } | ||
|
|
||
| function main(): void { | ||
| const violations = collectLiveUnitBlocks(); | ||
| if (violations.length > 0) { | ||
| console.error(formatViolations(violations)); | ||
| process.exitCode = 1; | ||
| return; | ||
| } | ||
| console.log("Live E2E unit-block guard passed: no it(...) blocks in test/e2e/live/**."); | ||
| } | ||
|
|
||
| if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? "")) { | ||
| main(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
169 changes: 169 additions & 0 deletions
169
src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| // Unit coverage for the WhatsApp compact-QR preload's pure shape-detect and | ||
| // patch helpers (NemoClaw#4522 wrong-package-patch regression class). The live | ||
| // whatsapp-qr-compact E2E only asserts terminal row counts against the real | ||
| // upstream renderer; these tests pin the load-hook contract hermetically with | ||
| // fake module objects so no real qrcode / qrcode-terminal dependency is needed. | ||
|
|
||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { | ||
| isQrcodePackage, | ||
| isQrcodeTerminalPackage, | ||
| patchQrcode, | ||
| patchQrcodeTerminal, | ||
| } from "./whatsapp-qr-compact"; | ||
|
|
||
| // A fake of the `qrcode` package main: has its OWN toString + create(). | ||
| function makeQrcodeFake() { | ||
| const calls: Array<{ text: unknown; opts: unknown; cb: unknown }> = []; | ||
| const mod = { | ||
| calls, | ||
| create() { | ||
| return {}; | ||
| }, | ||
| toString(text: unknown, opts?: unknown, cb?: unknown) { | ||
| calls.push({ text, opts, cb }); | ||
| return "QR"; | ||
| }, | ||
| }; | ||
| return mod; | ||
| } | ||
|
|
||
| // A fake of the `qrcode-terminal` package: has generate(), no create(). | ||
| function makeQrcodeTerminalFake() { | ||
| const calls: Array<{ text: unknown; opts: unknown; cb: unknown }> = []; | ||
| const mod = { | ||
| calls, | ||
| generate(text: unknown, opts?: unknown, cb?: unknown) { | ||
| calls.push({ text, opts, cb }); | ||
| }, | ||
| }; | ||
| return mod; | ||
| } | ||
|
|
||
| describe("isQrcodePackage (#4522)", () => { | ||
| it("detects the qrcode package main by own toString + create", () => { | ||
| expect(isQrcodePackage(makeQrcodeFake())).toBe(true); | ||
| }); | ||
|
|
||
| it("does not match a lookalike submodule that only has create()", () => { | ||
| // qrcode's internal lib/core/qrcode.js exposes create() but only the | ||
| // inherited Object.prototype.toString — it must NOT be patched. | ||
| const submodule = { | ||
| create() { | ||
| return {}; | ||
| }, | ||
| }; | ||
| expect(isQrcodePackage(submodule)).toBe(false); | ||
| }); | ||
|
|
||
| it("does not match qrcode-terminal (has generate, no create)", () => { | ||
| expect(isQrcodePackage(makeQrcodeTerminalFake())).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("isQrcodeTerminalPackage (#4522)", () => { | ||
| it("detects qrcode-terminal by own generate and absent create", () => { | ||
| expect(isQrcodeTerminalPackage(makeQrcodeTerminalFake())).toBe(true); | ||
| }); | ||
|
|
||
| it("does not match the qrcode package (has create)", () => { | ||
| expect(isQrcodeTerminalPackage(makeQrcodeFake())).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("patchQrcode (#4522)", () => { | ||
| it("forces small:true only for terminal renders", () => { | ||
| const mod = makeQrcodeFake(); | ||
| patchQrcode(mod); | ||
| mod.toString("payload", { type: "terminal" }); | ||
| expect(mod.calls[0].opts).toEqual({ type: "terminal", small: true }); | ||
| }); | ||
|
|
||
| it.each(["svg", "png", "utf8"])("leaves type=%s options untouched", (type) => { | ||
| const mod = makeQrcodeFake(); | ||
| patchQrcode(mod); | ||
| mod.toString("payload", { type }); | ||
| expect(mod.calls[0].opts).toEqual({ type }); | ||
| expect((mod.calls[0].opts as Record<string, unknown>).small).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("does not mutate the caller-supplied options object", () => { | ||
| const mod = makeQrcodeFake(); | ||
| patchQrcode(mod); | ||
| const opts = { type: "terminal" }; | ||
| mod.toString("payload", opts); | ||
| expect(opts).toEqual({ type: "terminal" }); | ||
| }); | ||
|
|
||
| it("preserves the toString(text, cb) signature", () => { | ||
| const mod = makeQrcodeFake(); | ||
| patchQrcode(mod); | ||
| const cb = vi.fn(); | ||
| mod.toString("payload", cb); | ||
| expect(mod.calls[0].cb).toBe(cb); | ||
| // No opts object was supplied, so nothing is forced. | ||
| expect(mod.calls[0].opts).toEqual({}); | ||
| }); | ||
|
|
||
| it("is idempotent: double-patch does not re-wrap", () => { | ||
| const mod = makeQrcodeFake(); | ||
| patchQrcode(mod); | ||
| const wrappedOnce = mod.toString; | ||
| patchQrcode(mod); | ||
| expect(mod.toString).toBe(wrappedOnce); | ||
| // And forcing still works exactly once. | ||
| mod.toString("payload", { type: "terminal" }); | ||
| expect(mod.calls[0].opts).toEqual({ type: "terminal", small: true }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("patchQrcodeTerminal (#4522)", () => { | ||
| it("forces small:true on generate", () => { | ||
| const mod = makeQrcodeTerminalFake(); | ||
| patchQrcodeTerminal(mod); | ||
| mod.generate("payload", {}); | ||
| expect(mod.calls[0].opts).toEqual({ small: true }); | ||
| }); | ||
|
|
||
| it("is idempotent: double-patch does not re-wrap", () => { | ||
| const mod = makeQrcodeTerminalFake(); | ||
| patchQrcodeTerminal(mod); | ||
| const wrappedOnce = mod.generate; | ||
| patchQrcodeTerminal(mod); | ||
| expect(mod.generate).toBe(wrappedOnce); | ||
| }); | ||
| }); | ||
|
|
||
| describe("Module._load hook path-segment matching (#4522)", () => { | ||
| it('patches import("qrcode")\'s resolved absolute path', async () => { | ||
| // Simulate the real load hook: install a Module._load wrapper identical to | ||
| // the runtime's, then require by an ABSOLUTE resolved path (as import() | ||
| // bottoms out at) and confirm the returned module got the compact patch. | ||
| const Module = (await import("node:module")).default as unknown as { | ||
| _load: (...args: unknown[]) => unknown; | ||
| }; | ||
| const qrcodeFake = makeQrcodeFake(); | ||
| const absolutePath = "/tmp/app/node_modules/qrcode/lib/index.js"; | ||
| const origLoad = Module._load; | ||
| Module._load = function (request: unknown, ..._rest: unknown[]) { | ||
| const loaded = request === absolutePath ? qrcodeFake : {}; | ||
| if (typeof request === "string" && request.indexOf("qrcode") !== -1) { | ||
| if (isQrcodePackage(loaded)) return patchQrcode(loaded); | ||
| if (isQrcodeTerminalPackage(loaded)) return patchQrcodeTerminal(loaded); | ||
| } | ||
| return loaded; | ||
| }; | ||
| try { | ||
| const loaded = Module._load(absolutePath) as ReturnType<typeof makeQrcodeFake>; | ||
| expect(loaded).toBe(qrcodeFake); | ||
| loaded.toString("payload", { type: "terminal" }); | ||
| expect(loaded.calls[0].opts).toEqual({ type: "terminal", small: true }); | ||
| } finally { | ||
| Module._load = origLoad; | ||
| } | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.