Files
copilotkit--copilotkit/showcase/scripts/__tests__/create-integration.test.ts
T
2026-07-13 12:58:18 +08:00

1326 lines
45 KiB
TypeScript

// 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<string, string> = {
"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<string, Buffer>();
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<string, Buffer>();
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 `<slugVar>:` 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 `<slugVar>:`, 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`/);
});
});