1326 lines
45 KiB
TypeScript
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`/);
|
|
});
|
|
});
|