Files
apophis-fastify/src/test/cli/packaging.test.ts
T

318 lines
13 KiB
TypeScript
Raw Normal View History

// src/test/cli/packaging.test.ts — packaging and entrypoint hardening tests
// Ensures exactly one canonical invocation path and no broken alternatives.
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const ROOT = new URL('../../../', import.meta.url).pathname;
const DIST_CLI = join(ROOT, 'dist/cli/index.js');
const PACKAGE_JSON = join(ROOT, 'package.json');
function run(args: string[], cwd?: string) {
const result = spawnSync('node', [DIST_CLI, ...args], {
encoding: 'utf8',
cwd: cwd || ROOT,
timeout: 30000,
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
status: result.status,
signal: result.signal,
};
}
describe('packaging', () => {
it('dist/cli/index.js exists after build', () => {
assert(existsSync(DIST_CLI), `Missing ${DIST_CLI} — run npm run build first`);
});
it('--help exits 0 and prints expected help text', () => {
const { stdout, status } = run(['--help']);
assert.strictEqual(status, 0, `Expected exit 0, got ${status}. stderr: ${run(['--help']).stderr}`);
assert(stdout.includes('apophis'), 'Help should mention apophis');
assert(stdout.includes('Commands:'), 'Help should list commands');
assert(stdout.includes('init'), 'Help should mention init');
assert(stdout.includes('verify'), 'Help should mention verify');
});
it('--version exits 0 and prints version', () => {
const { stdout, status } = run(['--version']);
assert.strictEqual(status, 0, `Expected exit 0, got ${status}`);
assert.match(stdout, /2\.7\.0/, `Version should include 2.7.0, got: ${stdout}`);
});
it('init --help exits 0 and prints init help', () => {
const { stdout, status } = run(['init', '--help']);
assert.strictEqual(status, 0);
assert(stdout.includes('apophis init'), 'Should show init help header');
assert(stdout.includes('--preset'), 'Should mention --preset');
});
it('verify --help exits 0 and prints verify help', () => {
const { stdout, status } = run(['verify', '--help']);
assert.strictEqual(status, 0);
assert(stdout.includes('apophis verify'), 'Should show verify help header');
assert(stdout.includes('--routes'), 'Should mention --routes');
});
it('frobnicate exits 2 with "Unknown command"', () => {
const { stdout, stderr, status } = run(['frobnicate']);
assert.strictEqual(status, 2, `Expected exit 2, got ${status}`);
const combined = stdout + stderr;
assert(combined.includes('Unknown command'), `Should report unknown command. Got: ${combined}`);
});
it('verify --unknown-flag exits 2 with "Unknown flag"', () => {
const { stdout, stderr, status } = run(['verify', '--unknown-flag']);
assert.strictEqual(status, 2, `Expected exit 2, got ${status}`);
const combined = stdout + stderr;
assert(combined.includes('Unknown flag'), `Should report unknown flag. Got: ${combined}`);
});
it('doctor --mode verify does not reject --mode as unknown', () => {
const { stdout, stderr, status } = run(['doctor', '--mode', 'verify', '--cwd', 'src/cli/__fixtures__/tiny-fastify']);
const combined = stdout + stderr;
assert.notStrictEqual(status, 3, `Should not crash. Output: ${combined}`);
assert(!combined.includes('Unknown flag: --mode'), `Should accept --mode flag. Output: ${combined}`);
});
// For each of the 7 commands, verify they do NOT print "Not yet implemented"
const commands = ['init', 'verify', 'qualify', 'replay', 'doctor', 'migrate'];
for (const cmd of commands) {
it(`${cmd} does not print "Not yet implemented"`, () => {
// Some commands may fail for config reasons; we just assert they don't say "Not yet implemented"
const { stdout, stderr } = run([cmd]);
const combined = stdout + stderr;
assert(
!combined.includes('Not yet implemented'),
`Command ${cmd} appears to be a placeholder. Output: ${combined}`
);
});
}
it('npx apophis --help works via temp package.json bin reference', () => {
const tmpDir = join(tmpdir(), `apophis-packaging-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
const pkg = {
name: 'test-consumer',
version: '1.0.0',
dependencies: {
'@apophis/fastify': `file:${ROOT}`,
},
};
writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(pkg, null, 2));
// We don't actually npm install; instead we verify the bin path resolves correctly
// by checking the package.json bin field points to dist/cli/index.js
const rootPkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8'));
assert.strictEqual(rootPkg.bin.apophis, 'dist/cli/index.js', 'package.json bin must point to dist/cli/index.js');
assert.strictEqual(rootPkg.main, 'dist/index.js', 'package.json main must point to dist/index.js');
// Verify the file exists at the resolved path
const resolvedBin = join(ROOT, rootPkg.bin.apophis);
assert(existsSync(resolvedBin), `Resolved bin path does not exist: ${resolvedBin}`);
// Clean up temp dir
rmSync(tmpDir, { recursive: true, force: true });
});
it('npm pack produces a tarball with the bin entry', () => {
const result = spawnSync('npm', ['pack', '--dry-run', '--json'], {
cwd: ROOT,
encoding: 'utf8',
timeout: 30000,
});
assert.strictEqual(result.status, 0, `npm pack --dry-run failed: ${result.stderr}`);
const packOutput = JSON.parse(result.stdout);
const files = packOutput[0]?.files?.map((f: { path: string }) => f.path) || [];
assert(files.includes('dist/cli/index.js'), 'Tarball must include dist/cli/index.js');
});
it('npm pack does not contain dist/test files', () => {
const result = spawnSync('npm', ['pack', '--dry-run', '--json'], {
cwd: ROOT, encoding: 'utf8', timeout: 30000,
});
assert.strictEqual(result.status, 0, `npm pack failed: ${result.stderr}`);
const packOutput = JSON.parse(result.stdout);
const files = packOutput[0]?.files?.map((f: { path: string }) => f.path) || [];
const testFiles = files.filter((f: string) => f.includes('dist/test/'));
assert.strictEqual(testFiles.length, 0, `Package must not contain dist/test/ files. Found: ${testFiles.join(', ')}`);
});
it('real consumer can import the package after install', () => {
const tmpDir = join(tmpdir(), `apophis-consumer-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
try {
const pkg = {
name: 'test-consumer-import',
version: '1.0.0',
type: 'module',
dependencies: {
'@apophis/fastify': `file:${ROOT}`,
},
};
writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(pkg));
const installResult = spawnSync('npm', ['install', '--silent', '--install-strategy=nested'], {
cwd: tmpDir, encoding: 'utf8', timeout: 120000,
});
assert.strictEqual(installResult.status, 0, `npm install failed: ${installResult.stderr}`);
const importRootResult = spawnSync('node', ['-e', "import('@apophis/fastify').then(m => console.log('OK:', Object.keys(m))).catch(e => { console.error('FAIL:', e.message); process.exit(1) })"], {
cwd: tmpDir, encoding: 'utf8', timeout: 30000,
});
assert.strictEqual(importRootResult.status, 0, `Import root failed: ${importRootResult.stderr}`);
assert.ok(importRootResult.stdout.includes('OK:'), `Import should print OK, got: ${importRootResult.stdout}`);
const importExtResult = spawnSync('node', ['-e', "import('@apophis/fastify/extensions').then(m => console.log('EXT OK:', Object.keys(m))).catch(e => { console.error('EXT FAIL:', e.message); process.exit(1) })"], {
cwd: tmpDir, encoding: 'utf8', timeout: 30000,
});
assert.strictEqual(importExtResult.status, 0, `Import extensions failed: ${importExtResult.stderr}`);
assert.ok(importExtResult.stdout.includes('EXT OK:'), `Extensions import should print OK, got: ${importExtResult.stdout}`);
const binResult = spawnSync('node', [join(tmpDir, 'node_modules/.bin/apophis'), '--version'], {
cwd: tmpDir, encoding: 'utf8', timeout: 30000,
});
assert.strictEqual(binResult.status, 0, `CLI bin failed: ${binResult.stderr}`);
assert.ok(binResult.stdout.includes('2.'), `CLI should print version starting with 2., got: ${binResult.stdout}`);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('TypeScript consumer can import and typecheck', () => {
const tmpDir = join(tmpdir(), `apophis-ts-consumer-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
try {
writeFileSync(join(tmpDir, 'package.json'), JSON.stringify({
name: 'ts-test',
version: '1.0.0',
type: 'module',
dependencies: {
'@apophis/fastify': `file:${ROOT}`,
'fastify': '^5.0.0',
'@fastify/swagger': '^9.0.0',
},
}));
const installResult = spawnSync('npm', ['install', '--silent', '--install-strategy=nested'], {
cwd: tmpDir, encoding: 'utf8', timeout: 120000,
});
assert.strictEqual(installResult.status, 0, `npm install failed: ${installResult.stderr}`);
writeFileSync(join(tmpDir, 'consumer.ts'), `
import Fastify from 'fastify'
import apophis, { createAuthExtension } from '@apophis/fastify'
import { jwtExtension } from '@apophis/fastify/extensions'
import { sseExtension } from '@apophis/fastify/extensions/sse'
import { websocketExtension } from '@apophis/fastify/extensions/websocket'
import { createSerializerExtension, createSerializerRegistry } from '@apophis/fastify/extensions/serializers'
import { createHeaderExtension } from '@apophis/fastify/extension/factories'
const app = Fastify()
app.register(apophis, {
extensions: [jwtExtension, sseExtension, websocketExtension],
})
`);
writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify({
compilerOptions: {
target: 'es2020',
module: 'nodenext',
moduleResolution: 'nodenext',
strict: true,
skipLibCheck: true,
},
include: ['consumer.ts'],
}));
const tsc = join(ROOT, 'node_modules/.bin/tsc');
const tscResult = spawnSync(tsc, ['--noEmit', '--project', tmpDir], {
cwd: tmpDir, encoding: 'utf8', timeout: 60000,
});
if (tscResult.status !== 0) {
console.error('TSC errors:', tscResult.stdout + tscResult.stderr);
}
assert.strictEqual(tscResult.status, 0, `TypeScript typecheck must pass cleanly. Got:\n${tscResult.stdout}${tscResult.stderr}`);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('npx apophis --help works in a temp project after npm install', () => {
const tmpDir = join(tmpdir(), `apophis-npx-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
const pkg = {
name: 'npx-test',
version: '1.0.0',
dependencies: {
'@apophis/fastify': `file:${ROOT}`,
},
};
writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(pkg, null, 2));
const installResult = spawnSync('npm', ['install', '--silent'], {
cwd: tmpDir,
encoding: 'utf8',
timeout: 120000,
});
assert.strictEqual(installResult.status, 0, `npm install failed: ${installResult.stderr}`);
const helpResult = spawnSync('npx', ['apophis', '--help'], {
cwd: tmpDir,
encoding: 'utf8',
timeout: 30000,
});
assert.strictEqual(helpResult.status, 0, `npx apophis --help failed: ${helpResult.stderr}`);
assert(helpResult.stdout.includes('apophis'), 'Help should mention apophis');
rmSync(tmpDir, { recursive: true, force: true });
});
it('npx apophis doctor works in a temp project after npm install', () => {
const tmpDir = join(tmpdir(), `apophis-npx-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
const pkg = {
name: 'npx-test',
version: '1.0.0',
dependencies: {
'@apophis/fastify': `file:${ROOT}`,
},
};
writeFileSync(join(tmpDir, 'package.json'), JSON.stringify(pkg, null, 2));
const installResult = spawnSync('npm', ['install', '--silent'], {
cwd: tmpDir,
encoding: 'utf8',
timeout: 120000,
});
assert.strictEqual(installResult.status, 0, `npm install failed: ${installResult.stderr}`);
const doctorResult = spawnSync('npx', ['apophis', 'doctor'], {
cwd: tmpDir,
encoding: 'utf8',
timeout: 30000,
});
// doctor exits non-zero when peer deps are missing in a bare temp project,
// but it should still run and print the header
assert(doctorResult.stdout.includes('APOPHIS Doctor'), `Doctor should run and print header. stdout: ${doctorResult.stdout} stderr: ${doctorResult.stderr}`);
rmSync(tmpDir, { recursive: true, force: true });
});
it('declares supported Node policy and default confidence test path', () => {
const rootPkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8'));
assert.strictEqual(rootPkg.engines.node, '>=20.18.1 <21 || >=22 <23');
assert.strictEqual(rootPkg.scripts.test, 'npm run build && npm run test:src && npm run test:cli');
});
});