Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions tests/failure-reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { SerializedError, TestError } from '@vitest/utils';
import type { Reporter, TestCase, TestModule, TestSuite, Vitest } from 'vitest/node';

const MAX_ERROR_CHARS = 1500;

/**
* The default reporter only prints the failure stack after every file has
* finished. On a long integration run the live footer says a file failed and
* leaves out the name and the reason. This reporter writes both as soon as
* the test or suite fails.
*/
export default class FailureReporter implements Reporter {
private vitest: Vitest | undefined;
private seen = new Set<string>();

onInit(vitest: Vitest): void {
this.vitest = vitest;
}

onTestRunStart(): void {
this.seen.clear();
}

onTestCaseResult(test: TestCase): void {
const result = test.result();
if (result.state !== 'failed') return;
this.print(test.module.relativeModuleId, test.fullName, result.errors);
}

onTestSuiteResult(suite: TestSuite): void {
if (suite.state() !== 'failed') return;
this.print(suite.module.relativeModuleId, suite.fullName, suite.errors());
}

onTestModuleEnd(module: TestModule): void {
if (module.state() !== 'failed') return;
this.print(module.relativeModuleId, module.relativeModuleId, module.errors());
}

private print(
file: string,
name: string,
errors: ReadonlyArray<SerializedError | TestError> | undefined,
): void {
const list = errors?.length ? errors : [];
if (list.length === 0) return;

const blocks = list.map((error) => formatError(error));
const key = `${file}\n${name}\n${blocks.join('\n')}`;
if (this.seen.has(key)) return;
this.seen.add(key);

const lines = ['', `FAIL ${file}`, ` ${name}`];
for (const block of blocks) {
for (const line of block.split('\n')) lines.push(` ${line}`);
}
this.vitest?.logger.error(`${lines.join('\n')}\n`);
}
}

function formatError(error: SerializedError | TestError): string {
const parts = [error.message || error.stack || error.name || 'Unknown failure'];
if ('diff' in error && typeof error.diff === 'string' && error.diff.length > 0) {
parts.push(error.diff);
}
const text = parts.join('\n');
if (text.length <= MAX_ERROR_CHARS) return text;
return `${text.slice(0, MAX_ERROR_CHARS)}…`;
}
9 changes: 9 additions & 0 deletions tests/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { cleanupTestOutput } from "./utils/test-output.js";

export async function setup(): Promise<void> {
await cleanupTestOutput();
}

export async function teardown(): Promise<void> {
await cleanupTestOutput();
}
7 changes: 6 additions & 1 deletion tests/neox-services.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, afterAll } from "vitest";
import fs from "node:fs";
import path from "node:path";
import type { Address } from "viem";
Expand All @@ -21,6 +21,7 @@ import type { AgentProjectConfig, RegistrationState } from "../src/neox/types.js
import { verifyOnChain } from "../src/neox/verify.js";
import type { WizardAnswers } from "../src/wizard.js";
import { generateNeoxAgentConfig } from "../src/templates/neox.js";
import { removeTestProject } from "./utils/test-output.js";

const REGISTRY = NEOX_T4_IDENTITY_REGISTRY;
const OWNER = "0x1111111111111111111111111111111111111111" as Address;
Expand Down Expand Up @@ -227,6 +228,10 @@ describe("Neo X registration services model", () => {
describe("Neo X generator service declarations", () => {
const outputRoot = path.join(process.cwd(), "test-output", "neox-services-generator");

afterAll(async () => {
await removeTestProject(outputRoot);
});

it("emits no A2A/MCP services without features", async () => {
const configSource = generateNeoxAgentConfig(baseAnswers());
expect(configSource).not.toContain('"name": "A2A"');
Expand Down
7 changes: 6 additions & 1 deletion tests/neox-t4.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
import { execFile } from "node:child_process";
Expand All @@ -9,6 +9,7 @@ import { isNeoxChain, NEOX_T4_CHAIN_ID, NEOX_T4_IDENTITY_REGISTRY } from "../src
import { IDENTITY_REGISTRY_ABI } from "../src/neox/abi.js";
import type { WizardAnswers } from "../src/wizard.js";
import { wizardAnswersFromConfig } from "../src/generate-from-config.js";
import { removeTestProject } from "./utils/test-output.js";

const execFileAsync = promisify(execFile);
const OUTPUT_DIR = path.join(process.cwd(), "test-output", "neox-t4-generator");
Expand Down Expand Up @@ -140,6 +141,10 @@ describe("Neo X T4 generator routing", () => {
}));
});

afterAll(async () => {
await removeTestProject(OUTPUT_DIR);
}, 120000);

it("generates a viem registration path instead of agent0-sdk", async () => {
const projectDir = path.join(OUTPUT_DIR, "identity-fixture");
const register = await fs.readFile(path.join(projectDir, "src/register.ts"), "utf8");
Expand Down
14 changes: 5 additions & 9 deletions tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
* - TEST_PAYER_PRIVATE_KEY: (Optional) Private key for x402 paid request tests
* Only needed for testnet x402 payment verification.
* The wallet needs testnet USDC on the respective chains.
* - KEEP_TEST_OUTPUT=1: Leave test-output/ on disk after the run.
*/

import 'dotenv/config';
import { beforeAll, afterAll } from 'vitest';
import { cleanupTestOutput, resetPorts, TEST_BASE_DIR } from './utils/test-helpers.js';
import { beforeAll } from 'vitest';
import { resetPorts, TEST_BASE_DIR } from './utils/test-helpers.js';
import fs from 'fs/promises';

// Ensure test output directory exists at start
// Ensure test output directory exists at start. global-setup.ts removes it
// before and after the run; suites remove their own projects when they finish.
beforeAll(async () => {
await fs.mkdir(TEST_BASE_DIR, { recursive: true });
resetPorts();
Expand All @@ -26,9 +28,3 @@ beforeAll(async () => {
console.log('⚠️ TEST_PAYER_PRIVATE_KEY not set - x402 paid request tests will be skipped');
}
});

// Cleanup after all tests
afterAll(async () => {
// Uncomment to cleanup after tests:
// await cleanupTestOutput();
});
44 changes: 44 additions & 0 deletions tests/test-output-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import fs from "fs/promises";
import path from "path";
import { describe, expect, it } from "vitest";
import { cleanupTestOutput, removeTestProject, TEST_BASE_DIR } from "./utils/test-output.js";

const keepOutput = process.env.KEEP_TEST_OUTPUT === "1";

describe("test output cleanup", () => {
it.skipIf(keepOutput)("removes one generated project and leaves its siblings", async () => {
const keepDir = path.join(TEST_BASE_DIR, "keep-me");
const dropDir = path.join(TEST_BASE_DIR, "drop-me", "node_modules", "pkg");
await fs.mkdir(dropDir, { recursive: true });
await fs.mkdir(keepDir, { recursive: true });
await fs.writeFile(path.join(dropDir, "index.js"), "module.exports = 1;\n");

await removeTestProject(path.join(TEST_BASE_DIR, "drop-me"));

await expect(fs.stat(path.join(TEST_BASE_DIR, "drop-me"))).rejects.toThrow();
await expect(fs.stat(keepDir)).resolves.toBeTruthy();
await removeTestProject(keepDir);
});

it("refuses to remove the output root or a path outside it", async () => {
const packageJson = path.join(process.cwd(), "package.json");
await expect(removeTestProject(TEST_BASE_DIR)).rejects.toThrow(/outside/);
await expect(removeTestProject(path.join(TEST_BASE_DIR, "..", "package.json"))).rejects.toThrow(/outside/);
await expect(fs.stat(packageJson)).resolves.toBeTruthy();
});

it("ignores an unset project directory", async () => {
await removeTestProject(undefined);
});

it.skipIf(keepOutput)("clears the whole test-output directory", async () => {
const projectDir = path.join(TEST_BASE_DIR, "wipe-probe");
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(path.join(projectDir, "marker.txt"), "x");

await cleanupTestOutput();

await expect(fs.stat(TEST_BASE_DIR)).rejects.toThrow();
await expect(fs.stat(path.join(process.cwd(), "package.json"))).resolves.toBeTruthy();
});
});
Loading
Loading