From d994abee5d598fa647771ac4346109d9af254ce7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 29 May 2026 14:17:32 +0900 Subject: [PATCH] test(omo-claude): add real-install cache QA harness + publish no-auto-trigger invariant qa-real-install.mjs builds the plugin, copies it to a node_modules-free cache, and exercises every hook + boots both MCP servers (F3 gate). publish-claude-invariant test pins the no-accidental-publish guardrails (workflow_dispatch-only, identity guard, missing-secret hard-fail) and is wired into test:claude. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- .../plugin/scripts/qa-real-install.mjs | 119 ++++++++++++++++++ script/publish-claude-invariant.test.ts | 35 ++++++ 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 packages/omo-claude/plugin/scripts/qa-real-install.mjs create mode 100644 script/publish-claude-invariant.test.ts diff --git a/package.json b/package.json index d191088c0..dda407cf8 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "typecheck:script": "tsgo --noEmit -p script/tsconfig.json", "test": "bun test", "test:codex": "bun test src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-agent-links.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs", - "test:claude": "bun test packages/omo-claude/src/**/*.test.ts packages/omo-claude/plugin/components/ultragoal/test/*.test.ts script/sync-lazyclaudecode-marketplace.test.ts && node --test packages/omo-claude/plugin/scripts/sync-components.test.mjs && node --test packages/omo-claude/plugin/test/aggregate.test.mjs packages/omo-claude/plugin/scripts/sync-mcp.test.mjs packages/omo-claude/scripts/sync-telemetry-component.test.mjs", + "test:claude": "bun test packages/omo-claude/src/**/*.test.ts packages/omo-claude/plugin/components/ultragoal/test/*.test.ts script/sync-lazyclaudecode-marketplace.test.ts script/publish-claude-invariant.test.ts && node --test packages/omo-claude/plugin/scripts/sync-components.test.mjs && node --test packages/omo-claude/plugin/test/aggregate.test.mjs packages/omo-claude/plugin/scripts/sync-mcp.test.mjs packages/omo-claude/scripts/sync-telemetry-component.test.mjs", "test:windows-codex": "bun run test:codex", "build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build" }, diff --git a/packages/omo-claude/plugin/scripts/qa-real-install.mjs b/packages/omo-claude/plugin/scripts/qa-real-install.mjs new file mode 100644 index 000000000..cc6213d53 --- /dev/null +++ b/packages/omo-claude/plugin/scripts/qa-real-install.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// F3 real-install QA: prove the built plugin works from a COPIED cache tree +// (no node_modules, no `../` traversal) — the failure mode CI cannot catch, +// because CI runs from the monorepo where relative paths resolve. +// +// Steps: build the plugin -> copy plugin/ to a temp dir (the "cache") -> with +// CLAUDE_PLUGIN_ROOT pointed at the copy, fire each hook and boot both MCP +// servers, asserting each succeeds. Evidence is written under .omo/evidence/. +// +// node scripts/qa-real-install.mjs build, copy, exercise +// node scripts/qa-real-install.mjs --no-build use the already-built plugin + +import { spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const PACKAGE_ROOT = dirname(PLUGIN_ROOT); +const REPO_ROOT = dirname(dirname(PACKAGE_ROOT)); +const EVIDENCE = join(REPO_ROOT, ".omo", "evidence", "task-30-install-qa"); + +const failures = []; +function check(name, ok, detail = "") { + console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); + if (!ok) failures.push(name); +} + +function hook(cli, sub, payload, env = {}) { + const res = spawnSync("node", [cli, "hook", sub], { + input: JSON.stringify(payload), + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return { status: res.status, out: (res.stdout ?? "") + (res.stderr ?? "") }; +} + +function mcpInitialize(cli, env) { + const req = `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "qa", version: "0" } }, + })}\n`; + const res = spawnSync("node", [cli, "mcp"], { input: req, encoding: "utf8", timeout: 15000, env: { ...process.env, ...env } }); + return (res.stdout ?? "") + (res.stderr ?? ""); +} + +if (!process.argv.includes("--no-build")) { + const build = spawnSync("npm", ["run", "build"], { cwd: PLUGIN_ROOT, stdio: "inherit" }); + if (build.status !== 0) { + console.error("plugin build failed"); + process.exit(1); + } +} + +mkdirSync(EVIDENCE, { recursive: true }); +const cache = join(mkdtempSync(join(tmpdir(), "omo-cache-")), "omo"); +cpSync(PLUGIN_ROOT, cache, { recursive: true }); +const work = mkdtempSync(join(tmpdir(), "omo-work-")); +writeFileSync(join(work, "CLAUDE.md"), "QA-RULE-MARKER project guidance\n"); +writeFileSync(join(work, "x.ts"), "export const a = 1;\n"); +const env = { CLAUDE_PLUGIN_ROOT: cache, PLUGIN_DATA: join(work, "pd") }; +const C = (name) => join(cache, "components", name, "dist", "cli.js"); + +const ups = hook(C("rules"), "user-prompt-submit", { + hook_event_name: "UserPromptSubmit", + session_id: "qa", + transcript_path: null, + cwd: work, + permission_mode: "default", + prompt: "hello", +}, env); +check("rules injects from cache (no turn_id/model)", ups.status === 0 && ups.out.includes("hookSpecificOutput")); + +const ulw = hook(C("ultrawork"), "user-prompt-submit", { + hook_event_name: "UserPromptSubmit", + session_id: "qa", + transcript_path: null, + cwd: work, + prompt: "ulw: go", +}, env); +check("ultrawork directive injects on `ulw:`", /ULTRAWORK/i.test(ulw.out)); + +for (const comp of ["comment-checker", "lsp", "rules"]) { + const ptu = hook(C(comp), "post-tool-use", { + hook_event_name: "PostToolUse", + session_id: "qa", + transcript_path: null, + cwd: work, + permission_mode: "default", + tool_name: "Write", + tool_use_id: "t", + tool_input: { file_path: join(work, "x.ts"), content: "export const a = 1;\n" }, + tool_response: "ok", + }, env); + check(`${comp} PostToolUse runs from cache`, ptu.status === 0); +} + +const stop = hook(C("start-work-continuation"), "stop", { + hook_event_name: "Stop", + session_id: "qa", + transcript_path: null, + cwd: work, + permission_mode: "default", + stop_hook_active: false, +}, env); +check("start-work-continuation Stop no-ops without boulder", stop.status === 0); + +for (const server of ["ast-grep", "lsp"]) { + const handshake = mcpInitialize(join(cache, "mcp", server, "cli.js"), env); + writeFileSync(join(EVIDENCE, `mcp-${server}-handshake.txt`), handshake); + check(`${server} MCP server boots from cache`, handshake.includes('"serverInfo"')); +} + +writeFileSync(join(EVIDENCE, "summary.txt"), `failures: ${failures.length ? failures.join(", ") : "none"}\ncache: ${cache}\n`); +console.log(failures.length === 0 ? "\nF3 real-install QA: ALL PASS" : `\nF3 real-install QA: ${failures.length} FAILURE(S)`); +process.exit(failures.length === 0 ? 0 : 1); diff --git a/script/publish-claude-invariant.test.ts b/script/publish-claude-invariant.test.ts new file mode 100644 index 000000000..232754b2f --- /dev/null +++ b/script/publish-claude-invariant.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +// Guardrail: the Claude Code marketplace publish workflow must NEVER deploy +// without an explicit, gated, human-triggered action. This test pins the +// "no accidental publish" invariants so a future edit can't silently re-arm it. +const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url))) +const WORKFLOW = join(REPO_ROOT, ".github", "workflows", "publish-claude.yml") +const yaml = readFileSync(WORKFLOW, "utf8") + +// Isolate the `on:` trigger block (everything before the first top-level `jobs:`). +const triggerBlock = yaml.slice(0, yaml.search(/^jobs:/m) === -1 ? yaml.length : yaml.search(/^jobs:/m)) + +describe("publish-claude.yml is off by default", () => { + test("the only trigger is workflow_dispatch (no push/pull_request/schedule/tags)", () => { + expect(triggerBlock).toMatch(/workflow_dispatch:/) + expect(triggerBlock).not.toMatch(/^\s*push:/m) + expect(triggerBlock).not.toMatch(/^\s*pull_request:/m) + expect(triggerBlock).not.toMatch(/^\s*schedule:/m) + expect(triggerBlock).not.toMatch(/^\s*tags:/m) + }) + + test("every job is guarded to the canonical repository identity", () => { + expect(yaml).toMatch(/github\.repository == 'code-yeongyu\/oh-my-openagent'/) + }) + + test("a missing LAZYCLAUDECODE_SYNC_TOKEN hard-fails the publish", () => { + expect(yaml).toContain("LAZYCLAUDECODE_SYNC_TOKEN") + // The gate must combine an empty-token condition with a non-zero exit. + expect(yaml).toMatch(/LAZYCLAUDECODE_SYNC_TOKEN == ''/) + expect(yaml).toMatch(/exit 1/) + }) +})