// create-integration.test.ts — exercises the real generator end-to-end // against FULLY ISOLATED tmpdir-backed packages and workflows directories. // // Previously the test pointed the generator at the real `showcase/integrations/` // and `.github/workflows/` trees and snapshotted the mutated files back to // HEAD in `afterEach`. Under `fileParallelism: true` that collided with // `generate-registry.test.ts` (concurrent `readdirSync` of // `showcase/integrations/` saw partial state → ENOENT) AND with every suite that // healed workflow YAMLs via `git checkout HEAD --` (`.git/index.lock`). // // The generator now honors `CREATE_INTEGRATION_PACKAGES_DIR` and // `CREATE_INTEGRATION_WORKFLOWS_DIR` env overrides. This file creates a // per-suite tmpdir, seeds it with copies of the real workflow YAMLs (so the // generator's regex-based edits still match), and points both env vars // there. No real tracked file is ever mutated — no restorer, no git // invocation, no cross-suite shared state. import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import fs from "fs"; import os from "os"; import path from "path"; import { execFileSync } from "child_process"; import yaml from "yaml"; import Ajv from "ajv"; import addFormats from "ajv-formats"; import { execOptsFor } from "./test-cleanup"; import { SCRIPTS_DIR } from "./paths"; const SCHEMA_PATH = path.resolve( SCRIPTS_DIR, "..", "shared", "manifest.schema.json", ); const FEATURE_REGISTRY_PATH = path.resolve( SCRIPTS_DIR, "..", "shared", "feature-registry.json", ); const TEST_SLUG = "test-integration-tmp"; // Regression test uses its own slug so the generator always has fresh work to // do; the primary slug is already consumed by earlier tests in the file. const REGRESSION_SLUG = "test-integration-tmp-regression-guard"; const TEST_SLUGS: readonly string[] = [TEST_SLUG, REGRESSION_SLUG]; // Inline fixture workflow YAMLs — just enough structural anchors for the // generator's regex-based edits to match. Previously the test seeded from // the real `.github/workflows/*.yml` files, which meant any drift to // those files silently broke the test via a hidden dependency (E4). The // fixtures below are frozen mini-workflows: they carry only the // anchors documented by `showcase/scripts/create-integration/index.ts`: // - `options:` list (workflow_dispatch input) // - `outputs:` map with `${{ ... }}` values // - `filters: |` block with at least one nested key + list item // - `starter:` block sequence for starter-smoke // and nothing else. If the generator learns new anchors, add them here. const WORKFLOW_FIXTURES: Record = { "showcase_deploy.yml": `name: Showcase Deploy on: workflow_dispatch: inputs: service: description: Which service to deploy required: false default: all type: choice options: - all - fixture-a - fixture-b jobs: detect-changes: name: Detect Changes runs-on: ubuntu-latest outputs: fixture_a: \${{ steps.changes.outputs.fixture_a }} fixture_b: \${{ steps.changes.outputs.fixture_b }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 id: changes with: filters: | fixture_a: - 'showcase/integrations/fixture-a/**' fixture_b: - 'showcase/integrations/fixture-b/**' check-lockfile: name: Check Lockfile runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: echo ok `, "test_smoke-starter.yml": `name: Starter Smoke on: { workflow_dispatch: {} } jobs: smoke: strategy: matrix: starter: - fixture-a - fixture-b runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: echo ok `, }; const WORKFLOW_BASENAMES: readonly string[] = Object.keys(WORKFLOW_FIXTURES); // Lazily populated in `beforeAll` — cast via ! below because TypeScript can't // follow that these are always set before any test runs. let TMP_ROOT!: string; let TMP_PACKAGES_DIR!: string; let TMP_WORKFLOWS_DIR!: string; let TEST_DIR!: string; let REGRESSION_DIR!: string; // Per-suite baseline of each workflow's content, captured once after seeding // so `cleanup()` can restore them without re-reading the real files. const WORKFLOW_BASELINES = new Map(); function cleanup() { // Remove any scaffolded package dirs the generator produced. for (const slug of TEST_SLUGS) { const dir = path.join(TMP_PACKAGES_DIR, slug); if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); } } // Restore each seeded workflow to its baseline so the next test starts // with the same anchors the generator's regex edits expect. for (const [p, baseline] of WORKFLOW_BASELINES) { fs.writeFileSync(p, baseline); } } beforeAll(() => { TMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "create-integration-")); TMP_PACKAGES_DIR = path.join(TMP_ROOT, "integrations"); TMP_WORKFLOWS_DIR = path.join(TMP_ROOT, "workflows"); TEST_DIR = path.join(TMP_PACKAGES_DIR, TEST_SLUG); REGRESSION_DIR = path.join(TMP_PACKAGES_DIR, REGRESSION_SLUG); fs.mkdirSync(TMP_PACKAGES_DIR, { recursive: true }); fs.mkdirSync(TMP_WORKFLOWS_DIR, { recursive: true }); // Seed our tmp workflows dir from frozen inline fixtures. Pre-fix (E4) // this copied from `.github/workflows/` — any drift there silently // broke the test because the regex anchors lived in a file outside // this suite's control. The WORKFLOW_FIXTURES constants above carry // the anchors the generator expects; the suite is now hermetic. for (const basename of WORKFLOW_BASENAMES) { const dst = path.join(TMP_WORKFLOWS_DIR, basename); const content = Buffer.from(WORKFLOW_FIXTURES[basename]!, "utf-8"); fs.writeFileSync(dst, content); WORKFLOW_BASELINES.set(dst, content); } }); beforeEach(cleanup); afterAll(() => { fs.rmSync(TMP_ROOT, { recursive: true, force: true }); }); /** Invoke `npx tsx create-integration/index.ts` with argv-style args so none * of the slug / feature strings ever hit a shell parser. Previous revisions * used `execSync(string)` with interpolated slugs — safe today because the * slugs are constants, but a poor hygiene pattern worth stamping out. * * Sets `CREATE_INTEGRATION_{PACKAGES,WORKFLOWS}_DIR` so the generator * writes entirely inside our per-suite tmpdir. */ function runGenerator(args: readonly string[]): { stdout: string } { const baseOpts = execOptsFor(SCRIPTS_DIR); const stdout = execFileSync( "npx", ["tsx", "create-integration/index.ts", ...args], { ...baseOpts, env: { ...process.env, CREATE_INTEGRATION_PACKAGES_DIR: TMP_PACKAGES_DIR, CREATE_INTEGRATION_WORKFLOWS_DIR: TMP_WORKFLOWS_DIR, }, }, ); return { stdout: stdout.toString() }; } describe("Template Generator", () => { it("generates a valid package structure", () => { cleanup(); runGenerator([ "--name", "Test Integration", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat,hitl-in-chat", ]); // Check directory exists expect(fs.existsSync(TEST_DIR)).toBe(true); // Check required files const requiredFiles = [ "manifest.yaml", "package.json", "Dockerfile", "entrypoint.sh", ".env.example", ".gitignore", "next.config.ts", "tsconfig.json", "playwright.config.ts", "requirements.txt", "src/agent_server.py", "src/app/layout.tsx", "src/app/globals.css", "src/app/page.tsx", "src/app/api/copilotkit/route.ts", "src/app/api/health/route.ts", ]; for (const file of requiredFiles) { expect(fs.existsSync(path.join(TEST_DIR, file))).toBe(true); } const gitignore = fs.readFileSync( path.join(TEST_DIR, ".gitignore"), "utf-8", ); expect(gitignore.split(/\r?\n/)).toContain(".copilotkit/"); }); it("generates a manifest that passes schema validation", () => { cleanup(); runGenerator([ "--name", "Test Integration", "--slug", TEST_SLUG, "--category", "provider-sdk", "--language", "typescript", "--features", "agentic-chat,tool-rendering,mcp-apps", ]); const manifest = yaml.parse( fs.readFileSync(path.join(TEST_DIR, "manifest.yaml"), "utf-8"), ); const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, "utf-8")); const ajv = new Ajv({ allErrors: true }); addFormats(ajv); const validate = ajv.compile(schema); const valid = validate(manifest); if (!valid) { console.error("Validation errors:", validate.errors); } expect(valid).toBe(true); // New fields should be present with defaults expect(manifest.generative_ui).toEqual(["constrained-explicit"]); expect(manifest.interaction_modalities).toEqual(["chat"]); }); it("generates correct demo stubs for each feature", () => { cleanup(); runGenerator([ "--name", "Test Integration", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat,hitl-in-chat,subagents", ]); const demoIds = ["agentic-chat", "hitl-in-chat", "subagents"]; for (const demoId of demoIds) { const demoDir = path.join(TEST_DIR, "src", "app", "demos", demoId); expect(fs.existsSync(demoDir)).toBe(true); // Frontend page expect(fs.existsSync(path.join(demoDir, "page.tsx"))).toBe(true); const page = fs.readFileSync(path.join(demoDir, "page.tsx"), "utf-8"); expect(page).toContain("CopilotKit"); expect(page).toContain("CopilotChat"); // Agent file (Python for this test) expect(fs.existsSync(path.join(demoDir, "agent.py"))).toBe(true); // README expect(fs.existsSync(path.join(demoDir, "README.md"))).toBe(true); // E2E test expect( fs.existsSync(path.join(TEST_DIR, "tests", "e2e", `${demoId}.spec.ts`)), ).toBe(true); // QA template expect(fs.existsSync(path.join(TEST_DIR, "qa", `${demoId}.md`))).toBe( true, ); } }); it("generates TypeScript agent files for TS integrations", () => { cleanup(); runGenerator([ "--name", "Test TS", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "typescript", "--features", "agentic-chat", ]); const demoDir = path.join(TEST_DIR, "src", "app", "demos", "agentic-chat"); expect(fs.existsSync(path.join(demoDir, "agent.ts"))).toBe(true); expect(fs.existsSync(path.join(demoDir, "agent.py"))).toBe(false); // No requirements.txt for TS expect(fs.existsSync(path.join(TEST_DIR, "requirements.txt"))).toBe(false); }); it("generates manifest with all declared features and demos", () => { cleanup(); const features = [ "agentic-chat", "hitl-in-chat", "tool-rendering", "mcp-apps", ]; runGenerator([ "--name", "Test", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", features.join(","), ]); const manifest = yaml.parse( fs.readFileSync(path.join(TEST_DIR, "manifest.yaml"), "utf-8"), ); expect(manifest.features).toEqual(features); expect(manifest.demos.length).toBe(features.length); for (const demo of manifest.demos) { expect(features).toContain(demo.id); expect(demo.name).toBeTruthy(); expect(demo.description).toBeTruthy(); expect(demo.route).toMatch(/^\/demos\//); expect(demo.tags.length).toBeGreaterThan(0); } }); it("refuses to create a package if directory already exists", () => { cleanup(); // Create first runGenerator([ "--name", "Test", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat", ]); // Try to create again — the generator should refuse. try { runGenerator([ "--name", "Test", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat", ]); expect.fail("Should have thrown"); } catch (e: any) { const stream = (e.stderr?.toString?.() ?? "") + (e.stdout?.toString?.() ?? ""); expect(stream).toContain("already exists"); } }); it("validates feature IDs against the registry", () => { const featureRegistry = JSON.parse( fs.readFileSync(FEATURE_REGISTRY_PATH, "utf-8"), ); // All features in the registry should have valid IDs for (const feature of featureRegistry.features) { expect(feature.id).toMatch(/^[a-z0-9-]+$/); expect(feature.name).toBeTruthy(); expect(feature.category).toBeTruthy(); expect(feature.description).toBeTruthy(); } // Registry should have all expected categories const categories = new Set( featureRegistry.categories.map((c: any) => c.id), ); expect(categories.has("chat-ui")).toBe(true); expect(categories.has("controlled-generative-ui")).toBe(true); expect(categories.has("declarative-generative-ui")).toBe(true); expect(categories.has("open-generative-ui")).toBe(true); expect(categories.has("operational-generative-ui")).toBe(true); expect(categories.has("agent-state")).toBe(true); expect(categories.has("interactivity")).toBe(true); expect(categories.has("multi-agent")).toBe(true); expect(categories.has("platform")).toBe(true); }); // Regression guard — the test-integration-tmp leak. // // Before the cleanup fix, running the generator scaffolded a package into // showcase/integrations/ AND mutated two CI workflow YAMLs (showcase_deploy.yml // and test_smoke-starter.yml). cleanup() only removed the package dir; the // workflow mutations leaked into the working tree and on Node 20 CI produced // `Timeout calling "onTaskUpdate"` -> ELIFECYCLE on every PR. // // This test asserts `cleanup()` restores every workflow YAML bit-for-bit. // The same `cleanup()` function runs in `beforeEach`, so covering it here // transitively covers the hook. The sentinel append below deliberately // causes transient tracking drift on the workflow YAMLs for the duration // of the test — a developer with a git GUI / file watcher will see flicker // while this test runs; restore() heals it before the test returns. it("cleanup restores workflow YAMLs after generator mutation (regression: test-integration-tmp leak)", () => { cleanup(); // Verify we captured at least one workflow baseline to test against. expect(WORKFLOW_BASELINES.size).toBeGreaterThan(0); // Run the generator with a dedicated slug so it always has fresh work to // do (the generator short-circuits once its slug is registered in any // workflow file). runGenerator([ "--name", "Leak Guard", "--slug", REGRESSION_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat", ]); // Capture pre-sentinel content so we can prove the append was observed // by the filesystem via a content check (stronger than byte-length: // resistant to a hypothetical fs shim that updates stat but not bytes). const preAppendContent = new Map(); for (const p of WORKFLOW_BASELINES.keys()) { preAppendContent.set(p, fs.readFileSync(p)); } // Defense in depth: force every snapshotted file to differ from its // baseline regardless of generator output. Safe because we restore // immediately below via cleanup(). const SENTINEL = "\n# regression-guard-sentinel\n"; const sentinelBuf = Buffer.from(SENTINEL, "utf-8"); for (const p of WORKFLOW_BASELINES.keys()) { fs.appendFileSync(p, SENTINEL); } // Prove the sentinel actually landed on disk — the file must be // pre-append content followed by sentinel bytes, exactly. Replaces the // old tautological `anyMutated` check (which couldn't fail given the // append ran unconditionally directly above it). for (const p of WORKFLOW_BASELINES.keys()) { const before = preAppendContent.get(p)!; const expected = Buffer.concat([before, sentinelBuf]); const actual = fs.readFileSync(p); expect( actual.equals(expected), `sentinel append did not land on ${p}`, ).toBe(true); } // Run cleanup() and assert bit-for-bit restoration against the in-memory // baseline (NOT a local re-read of disk — that would silently agree with // a buggy cleanup()). cleanup(); for (const [p, baseline] of WORKFLOW_BASELINES) { const current = fs.readFileSync(p); expect( current.equals(baseline), `workflow drift not restored: ${p}`, ).toBe(true); } // Both package dirs should be gone. expect(fs.existsSync(TEST_DIR)).toBe(false); expect(fs.existsSync(REGRESSION_DIR)).toBe(false); }); // VT-M2-C regression: on an idempotent re-run against a slug that is // already present in the `starter:` matrix of test_smoke-starter.yml, // the generator must skip the insert quietly instead of throwing // "found 'starter:' block in ... but it has zero entries". Previously // the walker set `lastEntryIndex = -1` on already-present to signal // "skip", which then fell into the same else branch as the degenerate // zero-entries case and surfaced a misleading error for operators // whose workflow was already correctly wired. it("re-running against a slug already in the smoke-matrix is a no-op, not a throw (VT-M2-C)", () => { cleanup(); const args = [ "--name", "Idempotent Rerun", "--slug", REGRESSION_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat", ]; // First run: scaffolds the package, appends the slug to the // `starter:` matrix in test_smoke-starter.yml, AND inserts // options/outputs/filters/build-job entries into showcase_deploy.yml. runGenerator(args); const smokePath = path.join(TMP_WORKFLOWS_DIR, "test_smoke-starter.yml"); const deployPath = path.join(TMP_WORKFLOWS_DIR, "showcase_deploy.yml"); const afterFirstRun = fs.readFileSync(smokePath, "utf-8"); const deployAfterFirstRun = fs.readFileSync(deployPath, "utf-8"); expect(afterFirstRun).toMatch( new RegExp(`^\\s+- ${REGRESSION_SLUG}\\s*$`, "m"), ); // showcase_deploy.yml insertions use the underscored `slugVar` // (hyphens → underscores). This is the form the idempotency // guards must look for on re-run. (VT-M2-F regression anchor.) const regressionSlugVar = REGRESSION_SLUG.replace(/-/g, "_"); expect(deployAfterFirstRun).toMatch( new RegExp(`^\\s+${regressionSlugVar}: \\$\\{\\{`, "m"), ); expect(deployAfterFirstRun).toMatch( new RegExp(`^\\s*build-${regressionSlugVar}:`, "m"), ); // Remove only the scaffolded package dir so the generator's pre-flight // guard lets us run again — the workflow already contains the slug, // which is the state we're exercising. fs.rmSync(path.join(TMP_PACKAGES_DIR, REGRESSION_SLUG), { recursive: true, force: true, }); // Second run must succeed (not throw). Expected output contains the // already-present skip notice; it MUST NOT contain the // "zero entries" degenerate-case error text. const { stdout } = runGenerator(args); expect(stdout).toMatch(/already present in starter matrix/); expect(stdout).not.toMatch(/has zero entries/); // test_smoke-starter.yml must be unchanged bit-for-bit across the // second run (no duplicate insert, no formatting drift). const afterSecondRun = fs.readFileSync(smokePath, "utf-8"); expect(afterSecondRun).toBe(afterFirstRun); // showcase_deploy.yml MUST also be unchanged bit-for-bit across the // second run. This is the VT-M2-F regression: prior to the fix the // outputs/filters idempotency guard compared against the hyphenated // `slug` while the insertion wrote the underscored `slugVar`, so a // re-run against a hyphenated slug like `test-integration-tmp- // regression-guard` would never match the guard and would append // duplicate `:` entries under outputs: and filters: on // every re-run, corrupting the YAML. const deployAfterSecondRun = fs.readFileSync(deployPath, "utf-8"); expect(deployAfterSecondRun).toBe(deployAfterFirstRun); // Belt-and-suspenders: there must be exactly one output entry and // exactly one filter entry keyed by `:`, no duplicates. const outputKeyMatches = deployAfterSecondRun.match( new RegExp(`^\\s+${regressionSlugVar}: \\$\\{\\{`, "gm"), ); expect(outputKeyMatches?.length).toBe(1); const buildJobMatches = deployAfterSecondRun.match( new RegExp(`^\\s*build-${regressionSlugVar}:`, "gm"), ); expect(buildJobMatches?.length).toBe(1); }); // Safety net: every seeded workflow must match its captured baseline // bit-for-bit at the end of the suite. Operates against our tmpdir-backed // copies — the real `.github/workflows/` YAMLs are never touched by this // suite, so there's nothing in the real tree to drift-check. it("leaves every seeded workflow byte-identical to its baseline", () => { expect(WORKFLOW_BASELINES.size).toBeGreaterThan(0); for (const [p, baseline] of WORKFLOW_BASELINES) { const current = fs.readFileSync(p); expect(current.equals(baseline), `workflow drift after suite: ${p}`).toBe( true, ); } }); }); describe("Template Generator — hardening regressions", () => { it("fails fast with a listed-ids error on unknown --features", async () => { cleanup(); try { runGenerator([ "--name", "Bad", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat,not-a-real-feature", ]); expect.fail("Should have rejected unknown feature id"); } catch (e: any) { const combined = (e.stderr || "") + (e.stdout || ""); expect(combined).toMatch(/Unknown feature id/); expect(combined).toContain("not-a-real-feature"); // Error message must enumerate known ids so the user can self-correct. expect(combined).toContain("Known ids:"); expect(combined).toContain("agentic-chat"); } // Partial directory must NOT be left behind on a validation failure. expect(fs.existsSync(TEST_DIR)).toBe(false); }); it("loadFeatureRegistry surfaces structured errors for read/parse/shape failures", async () => { // Replace the vacuous SHOWCASE_FEATURE_REGISTRY env-var test with // direct-import coverage. We spy on fs.readFileSync so we can inject // each failure mode deterministically — ENOENT, invalid JSON, and // shape-invalid (missing 'features' array) — and assert that the // thrown error message names the registry path AND the failure mode // rather than leaking a bare stack. The previous env-var approach // was a no-op: create-integration/index.ts never reads that env var, // and the test's own short-circuit (if /SHOWCASE_FEATURE_REGISTRY/ // matched) meant the asserts never ran. const { loadFeatureRegistry } = await import("../create-integration/index.ts"); const { vi } = await import("vitest"); // Capture the real readFileSync once so each case can delegate // for paths that are NOT the feature registry. An unfiltered // mockImplementation intercepts ALL readFileSync calls — including // the ones vitest/tsx make internally to resolve source maps, // transforms, etc. — and produces flaky failures that have nothing // to do with the code under test. Mirror the pattern used in // validate-parity.test.ts / manifest.test.ts. const realRead = fs.readFileSync; const delegate = ( p: fs.PathOrFileDescriptor, ...rest: unknown[] ): string | Buffer => ( realRead as unknown as ( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => string | Buffer )(p, ...rest); // Case 1: ENOENT (file missing). { const spy = vi.spyOn(fs, "readFileSync").mockImplementation((( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => { if (typeof p === "string" && p.endsWith("feature-registry.json")) { const err = new Error( "ENOENT: no such file or directory", ) as NodeJS.ErrnoException; err.code = "ENOENT"; throw err; } return delegate(p, ...rest); }) as typeof fs.readFileSync); try { expect(() => loadFeatureRegistry()).toThrow( /Failed to read feature registry/i, ); } finally { spy.mockRestore(); } } // Case 2: invalid JSON. { const spy = vi.spyOn(fs, "readFileSync").mockImplementation((( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => { if (typeof p === "string" && p.endsWith("feature-registry.json")) { return "{ this is not valid json }"; } return delegate(p, ...rest); }) as typeof fs.readFileSync); try { expect(() => loadFeatureRegistry()).toThrow(/not valid JSON/i); } finally { spy.mockRestore(); } } // Case 3: shape-invalid — top-level array. { const spy = vi.spyOn(fs, "readFileSync").mockImplementation((( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => { if (typeof p === "string" && p.endsWith("feature-registry.json")) { return JSON.stringify([{ id: "x" }]); } return delegate(p, ...rest); }) as typeof fs.readFileSync); try { expect(() => loadFeatureRegistry()).toThrow( /must be a JSON object with a 'features' array/i, ); } finally { spy.mockRestore(); } } // Case 4: shape-invalid — object without 'features' key. { const spy = vi.spyOn(fs, "readFileSync").mockImplementation((( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => { if (typeof p === "string" && p.endsWith("feature-registry.json")) { return JSON.stringify({ categories: [] }); } return delegate(p, ...rest); }) as typeof fs.readFileSync); try { expect(() => loadFeatureRegistry()).toThrow( /must be a JSON object with a 'features' array/i, ); } finally { spy.mockRestore(); } } }); it("validates feature-registry shape (top-level object with 'features' array)", () => { // The loader rejects registries that drop the { features: [...] } wrapper // so a hand-edited file surfaces as a clear error at load time rather // than as 'features is undefined' further down the call stack. // We assert the shape that the loader depends on is actually present. const raw = fs.readFileSync(FEATURE_REGISTRY_PATH, "utf-8"); const parsed = JSON.parse(raw); expect(parsed).not.toBeNull(); expect(typeof parsed).toBe("object"); expect(Array.isArray(parsed.features)).toBe(true); // And every entry has the shape the loader types against for (const f of parsed.features) { expect(typeof f.id).toBe("string"); expect(f.id.length).toBeGreaterThan(0); } }); it("generated health route has an in-process branch for TypeScript integrations", async () => { cleanup(); runGenerator([ "--name", "TS InProcess", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "typescript", "--features", "agentic-chat", ]); const healthRoute = fs.readFileSync( path.join(TEST_DIR, "src/app/api/health/route.ts"), "utf-8", ); // Must not probe a bogus agent URL for an in-process TS integration; // instead the flag must be hard-wired and the runtime must short-circuit // to the in-process status. expect(healthRoute).toContain("IS_IN_PROCESS = true"); expect(healthRoute).toContain('"in-process"'); // And the happy-path must treat in-process as 200 alongside "ok" expect(healthRoute).toMatch( /agentStatus\s*===\s*"ok"\s*\|\|\s*agentStatus\s*===\s*"in-process"/, ); }); it("generated health route has an out-of-process probe for Python integrations", async () => { cleanup(); runGenerator([ "--name", "Py OutOfProcess", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat", ]); const healthRoute = fs.readFileSync( path.join(TEST_DIR, "src/app/api/health/route.ts"), "utf-8", ); expect(healthRoute).toContain("IS_IN_PROCESS = false"); expect(healthRoute).toContain("AbortSignal.timeout(3000)"); }); it("generated E2E test uses the real assistant-message class, not the phantom data-role selector", async () => { cleanup(); runGenerator([ "--name", "Locator", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat", ]); const testFile = fs.readFileSync( path.join(TEST_DIR, "tests/e2e/agentic-chat.spec.ts"), "utf-8", ); // The old 'data-role="assistant"' attribute does not exist in the // CopilotKit DOM. The real assistant message carries the // copilotKitAssistantMessage class. expect(testFile).not.toContain('data-role="assistant"'); expect(testFile).toContain(".copilotKitAssistantMessage"); }); it("generated layout.tsx contains bare backticks in the inline script, not literal \\`", async () => { cleanup(); runGenerator([ "--name", "Layout", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", "agentic-chat", ]); const layout = fs.readFileSync( path.join(TEST_DIR, "src/app/layout.tsx"), "utf-8", ); // Previously the generator emitted \\\` which produced a literal \` in // the output, breaking the template-literal assignment to __html. expect(layout).not.toContain("\\`"); // The legitimate bare backticks must still be present so the script // body actually parses. expect(layout).toMatch(/__html:\s*`/); }); describe("parseArgs — unit coverage for argv-walking guards", () => { // parseArgs reads process.argv and calls process.exit(1) on any invalid // shape. We stub both so each failure mode is observed via a thrown // "process.exit called" sentinel and the matching error text on stderr. // Each branch below exercises one guard the hardening added so the // argv-walking logic doesn't silently drop flags or accept bad values. it("rejects a positional argument that doesn't start with --", async () => { const { parseArgs } = await import("../create-integration/index.ts"); const { vi } = await import("vitest"); const argv = process.argv; process.argv = ["node", "index.ts", "stray", "--name", "x"]; const exitSpy = vi.spyOn(process, "exit").mockImplementation((( code?: number, ) => { throw new Error(`exit:${code}`); }) as never); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { expect(() => parseArgs()).toThrow(/exit:1/); const combined = errSpy.mock.calls.map((c) => c.join(" ")).join("\n"); expect(combined).toMatch(/Unexpected positional argument 'stray'/); } finally { process.argv = argv; exitSpy.mockRestore(); errSpy.mockRestore(); } }); it("rejects a --flag missing its value (end-of-args)", async () => { const { parseArgs } = await import("../create-integration/index.ts"); const { vi } = await import("vitest"); const argv = process.argv; process.argv = ["node", "index.ts", "--name"]; const exitSpy = vi.spyOn(process, "exit").mockImplementation((( code?: number, ) => { throw new Error(`exit:${code}`); }) as never); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { expect(() => parseArgs()).toThrow(/exit:1/); const combined = errSpy.mock.calls.map((c) => c.join(" ")).join("\n"); expect(combined).toMatch(/Flag --name expects a value/); expect(combined).toMatch(/end-of-args/); } finally { process.argv = argv; exitSpy.mockRestore(); errSpy.mockRestore(); } }); it("rejects a --flag followed by another --flag instead of a value", async () => { const { parseArgs } = await import("../create-integration/index.ts"); const { vi } = await import("vitest"); const argv = process.argv; process.argv = ["node", "index.ts", "--name", "--slug", "x"]; const exitSpy = vi.spyOn(process, "exit").mockImplementation((( code?: number, ) => { throw new Error(`exit:${code}`); }) as never); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { expect(() => parseArgs()).toThrow(/exit:1/); const combined = errSpy.mock.calls.map((c) => c.join(" ")).join("\n"); expect(combined).toMatch(/Flag --name expects a value/); expect(combined).toMatch(/another flag \(--slug\)/); } finally { process.argv = argv; exitSpy.mockRestore(); errSpy.mockRestore(); } }); it("rejects an unknown --category value with the listed allowed set", async () => { const { parseArgs } = await import("../create-integration/index.ts"); const { vi } = await import("vitest"); const argv = process.argv; process.argv = [ "node", "index.ts", "--name", "x", "--slug", "x", "--category", "not-a-category", "--language", "python", "--features", "agentic-chat", ]; const exitSpy = vi.spyOn(process, "exit").mockImplementation((( code?: number, ) => { throw new Error(`exit:${code}`); }) as never); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { expect(() => parseArgs()).toThrow(/exit:1/); const combined = errSpy.mock.calls.map((c) => c.join(" ")).join("\n"); expect(combined).toMatch(/Unknown --category 'not-a-category'/); // The listed allowed set must include the canonical categories // so the user can self-correct from the message alone. expect(combined).toContain("provider-sdk"); } finally { process.argv = argv; exitSpy.mockRestore(); errSpy.mockRestore(); } }); }); describe("updateWorkflows — regex-failure assertions", () => { // updateWorkflows walks two workflow YAML files (showcase_deploy.yml // and test_smoke-starter.yml). Inside showcase_deploy.yml it performs // three regex-anchored block insertions (options:, outputs:, filters:), // each of which is covered by its own test below. If the surrounding // YAML drifts so the regex no longer matches, the hardening requires // it to throw a targeted error that names the file and mode — not to // silently write a no-op. // We drive the failure via a tiny shim that re-exports fs with the // read/write calls intercepted, letting us substitute an empty YAML // body that doesn't contain the expected 'options:' block. it("throws when showcase_deploy.yml lacks the options: block", async () => { const { updateWorkflows } = await import("../create-integration/index.ts"); const { vi } = await import("vitest"); // Production uses probePath (statSync) rather than existsSync, so the // spy must intercept statSync. Make only showcase_deploy.yml "exist" // (return a fake Stats-like object); any other path delegates to the // real statSync so vitest/tsx internals keep working. ENOENT on the // other workflow(s) makes probePath return "missing" and the // corresponding branch is skipped. const realStat = fs.statSync; const fakeStats = { isFile: () => true, isDirectory: () => false, } as unknown as fs.Stats; const statSpy = vi.spyOn(fs, "statSync").mockImplementation((( p: fs.PathLike, ...rest: unknown[] ) => { if (String(p).endsWith("showcase_deploy.yml")) return fakeStats; const pStr = String(p); if (pStr.endsWith("test_smoke-starter.yml")) { const err = new Error( `ENOENT: no such file or directory, stat '${pStr}'`, ) as NodeJS.ErrnoException; err.code = "ENOENT"; throw err; } return ( realStat as unknown as ( p: fs.PathLike, ...rest: unknown[] ) => fs.Stats )(p, ...rest); }) as typeof fs.statSync); const realRead = fs.readFileSync; const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation((( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => { if (typeof p === "string" && p.endsWith("showcase_deploy.yml")) { return "name: deploy\non: push\njobs:\n noop:\n runs-on: ubuntu\n"; } return ( realRead as unknown as ( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => string | Buffer )(p, ...rest); }) as typeof fs.readFileSync); // Prevent the function from actually writing; it shouldn't reach // writeFileSync in the throwing branch, but guard anyway so a // regression doesn't clobber the real workflow file. const writeSpy = vi .spyOn(fs, "writeFileSync") .mockImplementation(() => {}); try { expect(() => updateWorkflows({ name: "X", slug: "x", category: "agent-framework", language: "python", features: ["agentic-chat"], extraDeps: [], }), ).toThrow(/failed to locate the 'options:' block/); } finally { statSpy.mockRestore(); readSpy.mockRestore(); writeSpy.mockRestore(); } }); it("throws when showcase_deploy.yml lacks the outputs: block", async () => { const { updateWorkflows } = await import("../create-integration/index.ts"); const { vi } = await import("vitest"); // YAML that has a well-formed `options:` block (so the first regex // matches and the function proceeds) but no `outputs:` block at // all. The second regex must throw with a message that names the // outputs block — not silently drop the change. const yamlBody = [ "name: deploy", "on:", " workflow_dispatch:", " inputs:", " service:", " type: choice", " options:", " - langgraph-python", " - mastra", "jobs:", " noop:", " runs-on: ubuntu", "", ].join("\n"); const realStat = fs.statSync; const fakeStats = { isFile: () => true, isDirectory: () => false, } as unknown as fs.Stats; const statSpy = vi.spyOn(fs, "statSync").mockImplementation((( p: fs.PathLike, ...rest: unknown[] ) => { if (String(p).endsWith("showcase_deploy.yml")) return fakeStats; const pStr = String(p); if (pStr.endsWith("test_smoke-starter.yml")) { const err = new Error( `ENOENT: no such file or directory, stat '${pStr}'`, ) as NodeJS.ErrnoException; err.code = "ENOENT"; throw err; } return ( realStat as unknown as ( p: fs.PathLike, ...rest: unknown[] ) => fs.Stats )(p, ...rest); }) as typeof fs.statSync); const realRead = fs.readFileSync; const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation((( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => { if (typeof p === "string" && p.endsWith("showcase_deploy.yml")) { return yamlBody; } return ( realRead as unknown as ( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => string | Buffer )(p, ...rest); }) as typeof fs.readFileSync); const writeSpy = vi .spyOn(fs, "writeFileSync") .mockImplementation(() => {}); try { expect(() => updateWorkflows({ name: "X", slug: "new-slug", category: "agent-framework", language: "python", features: ["agentic-chat"], extraDeps: [], }), ).toThrow(/failed to locate the 'outputs:' block/); } finally { statSpy.mockRestore(); readSpy.mockRestore(); writeSpy.mockRestore(); } }); it("throws when showcase_deploy.yml lacks the filters: block", async () => { const { updateWorkflows } = await import("../create-integration/index.ts"); const { vi } = await import("vitest"); // YAML that has both `options:` and `outputs:` blocks so the first // two regexes match, but no `filters: |` block. The third regex // must throw with a message that names the filters block. const yamlBody = [ "name: deploy", "on:", " workflow_dispatch:", " inputs:", " service:", " type: choice", " options:", " - langgraph-python", " - mastra", "jobs:", " detect-changes:", " runs-on: ubuntu", " outputs:", " langgraph_python: ${{ steps.changes.outputs.langgraph_python }}", " mastra: ${{ steps.changes.outputs.mastra }}", " steps:", " - uses: actions/checkout@v4", "", ].join("\n"); const realStat = fs.statSync; const fakeStats = { isFile: () => true, isDirectory: () => false, } as unknown as fs.Stats; const statSpy = vi.spyOn(fs, "statSync").mockImplementation((( p: fs.PathLike, ...rest: unknown[] ) => { if (String(p).endsWith("showcase_deploy.yml")) return fakeStats; const pStr = String(p); if (pStr.endsWith("test_smoke-starter.yml")) { const err = new Error( `ENOENT: no such file or directory, stat '${pStr}'`, ) as NodeJS.ErrnoException; err.code = "ENOENT"; throw err; } return ( realStat as unknown as ( p: fs.PathLike, ...rest: unknown[] ) => fs.Stats )(p, ...rest); }) as typeof fs.statSync); const realRead = fs.readFileSync; const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation((( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => { if (typeof p === "string" && p.endsWith("showcase_deploy.yml")) { return yamlBody; } return ( realRead as unknown as ( p: fs.PathOrFileDescriptor, ...rest: unknown[] ) => string | Buffer )(p, ...rest); }) as typeof fs.readFileSync); const writeSpy = vi .spyOn(fs, "writeFileSync") .mockImplementation(() => {}); try { expect(() => updateWorkflows({ name: "X", slug: "new-slug", category: "agent-framework", language: "python", features: ["agentic-chat"], extraDeps: [], }), ).toThrow(/failed to locate the 'filters:' block/); } finally { statSpy.mockRestore(); readSpy.mockRestore(); writeSpy.mockRestore(); } }); }); it("demo README contains bare backticks for inline code, not literal \\` pairs", async () => { cleanup(); runGenerator([ "--name", "Readme", "--slug", TEST_SLUG, "--category", "agent-framework", "--language", "python", "--features", "tool-rendering", ]); const readme = fs.readFileSync( path.join(TEST_DIR, "src/app/demos/tool-rendering/README.md"), "utf-8", ); // The authored Technical Details section used \\\` which rendered as // literal \` in the generated markdown; flag any remaining residue. expect(readme).not.toContain("\\`"); // Must still contain backticked code references (e.g. \`get_weather\`) expect(readme).toMatch(/`get_weather`/); }); });