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'; import { createMatrixPackage } from '../helpers/package-fixtures.js'; describe('FP-30 mx publish preflight', () => { const tempDirs: string[] = []; afterEach(() => { while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { fs.rmSync(dir, { recursive: true, force: true }); } } }); it('requires login and enforces immutable version publish', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-publish-')); tempDirs.push(root); const fixture = createMatrixPackage(root, { packageName: '@acme/fp30-publish', version: '1.0.0', componentType: 'PublishActor', mount: 'fp30.publish', }); const registryDir = path.join(root, 'registry'); const credentialsFile = path.join(root, '.matrix', 'credentials.json'); const unauthenticatedPublish = await runMxCli([ 'node', 'mx', 'publish', '--registry-dir', registryDir, '--credentials-file', credentialsFile, '--json', ], { cwd: fixture.packageDir }); assertExitCode(unauthenticatedPublish, 1); assert.equal( unauthenticatedPublish.errors.some((line) => line.includes('MX_AUTH_REQUIRED')), true ); const loginResult = await runMxCli([ 'node', 'mx', 'login', '--username', 'alice', '--token', 'token-abc', '--credentials-file', credentialsFile, '--json', ], { cwd: fixture.packageDir }); assertExitCode(loginResult, 0); const publishResult = await runMxCli([ 'node', 'mx', 'publish', '--registry-dir', registryDir, '--credentials-file', credentialsFile, '--json', ], { cwd: fixture.packageDir }); assertExitCode(publishResult, 0); const publishPayload = parseLastJson(publishResult.logs) as { packageName: string; version: string; registryTarballPath?: string; }; assert.equal(publishPayload.packageName, fixture.packageName); assert.equal(publishPayload.version, fixture.version); assert.equal(Boolean(publishPayload.registryTarballPath && fs.existsSync(publishPayload.registryTarballPath)), true); const republishResult = await runMxCli([ 'node', 'mx', 'publish', '--registry-dir', registryDir, '--credentials-file', credentialsFile, '--json', ], { cwd: fixture.packageDir }); assertExitCode(republishResult, 1); assert.equal(republishResult.errors.some((line) => line.includes('MX_VERSION_EXISTS')), true); }); it('allows webapp-only packages without actor components', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-publish-webapp-')); tempDirs.push(root); const packageDir = path.join(root, 'webapp-only'); const registryDir = path.join(root, 'registry'); const credentialsFile = path.join(root, '.matrix', 'credentials.json'); fs.mkdirSync(path.join(packageDir, 'dist'), { recursive: true }); fs.writeFileSync(path.join(packageDir, 'dist', 'index.html'), 'Webapp only\n', 'utf8'); fs.writeFileSync( path.join(packageDir, 'matrix.json'), JSON.stringify( { name: '@acme/fp30-webapp-only', version: '1.0.0', runtime: { language: 'typescript', entry: 'dist/index.html' }, webapp: { distDir: 'dist', entry: 'index.html', appName: 'webapp-only' }, components: [], permissions: { fsPolicy: 'none' }, }, null, 2, ), 'utf8', ); fs.writeFileSync( path.join(packageDir, 'package.json'), JSON.stringify( { name: '@acme/fp30-webapp-only', version: '1.0.0', type: 'module', files: ['dist', 'matrix.json'], }, null, 2, ), 'utf8', ); const loginResult = await runMxCli([ 'node', 'mx', 'login', '--username', 'alice', '--token', 'token-webapp-only', '--credentials-file', credentialsFile, '--json', ], { cwd: packageDir }); assertExitCode(loginResult, 0); const publishResult = await runMxCli([ 'node', 'mx', 'publish', '--registry-dir', registryDir, '--credentials-file', credentialsFile, '--json', ], { cwd: packageDir }); assertExitCode(publishResult, 0); const publishPayload = parseLastJson(publishResult.logs) as { packageName: string; version: string; registryTarballPath?: string; }; assert.equal(publishPayload.packageName, '@acme/fp30-webapp-only'); assert.equal(publishPayload.version, '1.0.0'); assert.equal(Boolean(publishPayload.registryTarballPath && fs.existsSync(publishPayload.registryTarballPath)), true); }); it('allows runtime-only packages with a root actor and no components', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-publish-runtime-only-')); tempDirs.push(root); const packageDir = path.join(root, 'runtime-only'); const registryDir = path.join(root, 'registry'); const credentialsFile = path.join(root, '.matrix', 'credentials.json'); fs.mkdirSync(path.join(packageDir, 'dist'), { recursive: true }); fs.writeFileSync(path.join(packageDir, 'dist', 'index.js'), 'export class RuntimeOnlyActor {}\n', 'utf8'); fs.writeFileSync( path.join(packageDir, 'matrix.json'), JSON.stringify( { name: '@acme/fp30-runtime-only', version: '1.0.0', root: { type: 'RuntimeOnlyActor', export: 'RuntimeOnlyActor', description: 'Runtime-only root actor', }, runtime: { language: 'typescript', entry: 'dist/index.js' }, components: [], permissions: { fsPolicy: 'none' }, }, null, 2, ), 'utf8', ); fs.writeFileSync( path.join(packageDir, 'package.json'), JSON.stringify( { name: '@acme/fp30-runtime-only', version: '1.0.0', type: 'module', files: ['dist', 'matrix.json'], }, null, 2, ), 'utf8', ); const loginResult = await runMxCli([ 'node', 'mx', 'login', '--username', 'alice', '--token', 'token-runtime-only', '--credentials-file', credentialsFile, '--json', ], { cwd: packageDir }); assertExitCode(loginResult, 0); const publishResult = await runMxCli([ 'node', 'mx', 'publish', '--registry-dir', registryDir, '--credentials-file', credentialsFile, '--json', ], { cwd: packageDir }); assertExitCode(publishResult, 0); const publishPayload = parseLastJson(publishResult.logs) as { packageName: string; version: string; registryTarballPath?: string; }; assert.equal(publishPayload.packageName, '@acme/fp30-runtime-only'); assert.equal(publishPayload.version, '1.0.0'); assert.equal(Boolean(publishPayload.registryTarballPath && fs.existsSync(publishPayload.registryTarballPath)), true); }); it('allows service factory packages declared by matrix.json runtime.factory', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-fp30-publish-service-')); tempDirs.push(root); const packageDir = path.join(root, 'service-factory'); const registryDir = path.join(root, 'registry'); const credentialsFile = path.join(root, '.matrix', 'credentials.json'); fs.mkdirSync(path.join(packageDir, 'dist'), { recursive: true }); fs.writeFileSync( path.join(packageDir, 'dist', 'index.js'), 'export async function createServiceFactory() { return { runtime: { shutdown() {} } }; }\n', 'utf8', ); fs.writeFileSync( path.join(packageDir, 'matrix.json'), JSON.stringify( { name: '@acme/fp30-service-factory', version: '1.0.0', runtime: { language: 'typescript', entry: 'dist/index.js', factory: { kind: 'factory', export: 'createServiceFactory', instance: { id: 'RUNTIME-SERVICE-FACTORY', class: 'service', rootKind: 'package-root', autoStart: true, }, }, }, components: [], permissions: { fsPolicy: 'none' }, }, null, 2, ), 'utf8', ); fs.writeFileSync( path.join(packageDir, 'package.json'), JSON.stringify( { name: '@acme/fp30-service-factory', version: '1.0.0', type: 'module', files: ['dist', 'matrix.json'], }, null, 2, ), 'utf8', ); const loginResult = await runMxCli([ 'node', 'mx', 'login', '--username', 'alice', '--token', 'token-service-factory', '--credentials-file', credentialsFile, '--json', ], { cwd: packageDir }); assertExitCode(loginResult, 0); const publishResult = await runMxCli([ 'node', 'mx', 'publish', '--registry-dir', registryDir, '--credentials-file', credentialsFile, '--json', ], { cwd: packageDir }); assertExitCode(publishResult, 0); const publishPayload = parseLastJson(publishResult.logs) as { packageName: string; version: string; registryTarballPath?: string; }; assert.equal(publishPayload.packageName, '@acme/fp30-service-factory'); assert.equal(publishPayload.version, '1.0.0'); assert.equal(Boolean(publishPayload.registryTarballPath && fs.existsSync(publishPayload.registryTarballPath)), true); }); });