import { afterEach, describe, it } from 'node:test'; import { strict as assert } from 'node:assert'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { runMxCli, parseLastJson, assertExitCode } from '../helpers/cli-harness.js'; function createLifecyclePackage(rootDir: string, packageName: string, scriptBody: string): string { const packageDir = path.join(rootDir, packageName.replace(/^@/, '').replace('/', '-')); fs.mkdirSync(path.join(packageDir, 'dist'), { recursive: true }); fs.writeFileSync( path.join(packageDir, 'matrix.json'), JSON.stringify( { name: packageName, version: '1.0.0', runtime: { language: 'javascript', entry: 'dist/index.js' }, components: [], install: { seed: { script: 'dist/seed.js', description: 'seed test hook', }, }, permissions: { fsPolicy: 'none' }, }, null, 2, ), 'utf8', ); fs.writeFileSync( path.join(packageDir, 'package.json'), JSON.stringify( { name: packageName, version: '1.0.0', type: 'module', main: 'dist/index.js', }, null, 2, ), 'utf8', ); fs.writeFileSync(path.join(packageDir, 'dist', 'index.js'), 'export const ready = true;\n', 'utf8'); fs.writeFileSync(path.join(packageDir, 'dist', 'seed.js'), scriptBody, 'utf8'); return packageDir; } describe('FP-30 mx install lifecycle', () => { const tempDirs: string[] = []; afterEach(() => { delete process.env.MX_TEST_MARKER; while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { fs.rmSync(dir, { recursive: true, force: true }); } } }); it('runs install lifecycle hooks and exposes install env', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-install-lifecycle-')); tempDirs.push(root); const workspaceDir = path.join(root, 'workspace'); fs.mkdirSync(workspaceDir, { recursive: true }); const packagesDir = path.join(root, '.matrix', 'packages'); const markerPath = path.join(root, 'seed-marker.json'); process.env.MX_TEST_MARKER = markerPath; const packageDir = createLifecyclePackage( root, '@acme/install-lifecycle', `import { writeFileSync } from 'node:fs'; const marker = process.env.MX_TEST_MARKER; if (!marker) throw new Error('missing marker'); writeFileSync(marker, JSON.stringify({ phase: process.env.MATRIX_INSTALL_PHASE, packagesDir: process.env.MATRIX_PACKAGES_DIR, packageDir: process.env.MATRIX_PACKAGE_DIR, packageName: process.env.MATRIX_PACKAGE_NAME }), 'utf8'); `, ); const result = await runMxCli([ 'node', 'mx', 'install', packageDir, '--packages-dir', packagesDir, '--json', ], { cwd: workspaceDir }); assertExitCode(result, 0); const payload = parseLastJson(result.logs) as { targetDir: string; lifecyclePhases: string[]; }; assert.deepEqual(payload.lifecyclePhases, ['seed']); assert.equal(fs.existsSync(payload.targetDir), true); assert.equal(fs.existsSync(markerPath), true); const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')) as Record; assert.equal(marker.phase, 'seed'); assert.equal(path.resolve(marker.packagesDir), path.resolve(packagesDir)); assert.equal(path.resolve(marker.packageDir), path.resolve(payload.targetDir)); assert.equal(marker.packageName, '@acme/install-lifecycle'); }); it('rolls back package install when a lifecycle hook fails', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-install-lifecycle-fail-')); tempDirs.push(root); const workspaceDir = path.join(root, 'workspace'); fs.mkdirSync(workspaceDir, { recursive: true }); const packagesDir = path.join(root, '.matrix', 'packages'); const packageDir = createLifecyclePackage( root, '@acme/install-lifecycle-fail', `throw new Error('seed failed');\n`, ); const result = await runMxCli([ 'node', 'mx', 'install', packageDir, '--packages-dir', packagesDir, '--json', ], { cwd: workspaceDir }); assertExitCode(result, 1); assert.equal( result.errors.some((line) => line.includes('install.seed failed')), true, `Expected lifecycle failure in stderr, got:\n${result.errors.join('\n')}`, ); const installedPath = path.join(packagesDir, 'node_modules', '@acme', 'install-lifecycle-fail'); assert.equal(fs.existsSync(installedPath), false); }); });