318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
// 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');
|
|
});
|
|
});
|