hivecast-sdk/packages/cli/tests/unit/lifecycle-scaffold.spec.ts

285 lines
13 KiB
TypeScript

/**
* Developer Lifecycle — Scaffold & Validate Tests
*
* Tests the scaffold → validate → install pipeline WITHOUT requiring
* the daemon runtime. These tests verify file generation, content
* correctness, and manifest validation.
*
* For full daemon integration (discover → serve), see:
* tests/integration/daemon/e2e-developer-lifecycle.spec.ts
*/
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 { actorCommand } from '../../src/commands/actor.js';
import { webappCommand } from '../../src/commands/webapp.js';
import { validateCommand } from '../../src/commands/validate.js';
import { dependencyInstallNpmrcLines, installCommand } from '../../src/commands/install.js';
describe('developer lifecycle: scaffold + validate + install', () => {
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
// ---------------------------------------------------------------------------
// Actor scaffold
// ---------------------------------------------------------------------------
describe('actor scaffold', () => {
it('routes all Matrix runtime dependency scopes to the configured registry', () => {
const lines = dependencyInstallNpmrcLines({
registry: 'http://127.0.0.1:3000/api/packages/open-matrix/npm/',
token: 'token-runtime-install',
});
assert.ok(lines.includes('@open-matrix:registry=http://127.0.0.1:3000/api/packages/open-matrix/npm/'));
assert.ok(lines.includes('@matrix:registry=http://127.0.0.1:3000/api/packages/open-matrix/npm/'));
assert.ok(lines.includes('@omega:registry=http://127.0.0.1:3000/api/packages/open-matrix/npm/'));
assert.ok(lines.includes('always-auth=true'));
assert.ok(lines.includes('//127.0.0.1:3000/api/packages/open-matrix/npm/:_authToken=token-runtime-install'));
});
it('generates all expected files with correct content', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-actor-'));
tempDirs.push(tempRoot);
const actorDir = path.join(tempRoot, 'my-counter');
const result = await actorCommand('my-counter', { outputDir: actorDir });
// Return value shape
assert.equal(result.rootDir, actorDir);
assert.ok(result.className, 'className should be set');
assert.equal(result.mount, 'my-counter');
// All expected files
const expectedFiles = [
'matrix.json',
'package.json',
'tsconfig.json',
path.join('src', `${result.className}.ts`),
path.join('src', `create-standalone-${result.className}.ts`),
path.join('src', 'index.ts'),
path.join('examples', 'get-status.mxq'),
path.join('skills', 'my-counter.skill.md'),
path.join('tests', 'my-counter.spec.ts'),
];
for (const f of expectedFiles) {
assert.ok(fs.existsSync(path.join(actorDir, f)), `Missing: ${f}`);
}
// matrix.json
const manifest = JSON.parse(fs.readFileSync(path.join(actorDir, 'matrix.json'), 'utf8'));
assert.equal(manifest.name, '@matrix/my-counter');
assert.ok(Array.isArray(manifest.components));
assert.equal(manifest.components[0]?.mount, 'my-counter');
assert.equal(manifest.components[0]?.type, result.className);
assert.equal(manifest.runtime?.factory?.export, `createStandalone${result.className}`);
assert.equal(manifest.runtime?.factory?.instance?.componentType, result.className);
assert.equal(manifest.runtime?.factory?.instance?.autoStart, true);
// package.json
const pkg = JSON.parse(fs.readFileSync(path.join(actorDir, 'package.json'), 'utf8'));
assert.equal(pkg.type, 'module');
assert.equal(pkg.name, '@matrix/my-counter');
assert.equal(pkg.scripts?.build, 'tsc -p tsconfig.json');
assert.equal(pkg.scripts?.['build:watch'], 'tsc -w -p tsconfig.json --preserveWatchOutput');
assert.equal(pkg.scripts?.dev, 'tsc -w -p tsconfig.json --preserveWatchOutput');
assert.equal(pkg.scripts?.test, 'node --import tsx --test tests/**/*.spec.ts');
assert.ok(pkg.dependencies?.['@open-matrix/core']);
assert.equal(pkg.devDependencies?.['@open-matrix/core'], undefined);
// tsconfig.json
const tsconfig = JSON.parse(fs.readFileSync(path.join(actorDir, 'tsconfig.json'), 'utf8'));
assert.equal(tsconfig.compilerOptions.target, 'ES2022');
assert.equal(tsconfig.compilerOptions.module, 'NodeNext');
assert.equal(tsconfig.compilerOptions.declaration, true);
assert.equal(tsconfig.compilerOptions.incremental, true);
assert.equal(tsconfig.compilerOptions.tsBuildInfoFile, '.tsbuildinfo');
assert.equal(tsconfig.compilerOptions.sourceMap, true);
assert.deepEqual(tsconfig.include, ['src']);
// Actor source imports @open-matrix/core (not @open-matrix/core)
const actorSrc = fs.readFileSync(path.join(actorDir, 'src', `${result.className}.ts`), 'utf8');
assert.ok(actorSrc.includes('@open-matrix/core'), 'should import from @open-matrix/core');
assert.ok(!actorSrc.includes("'@open-matrix/core'"), 'should NOT import from @open-matrix/core');
});
it('validates successfully after scaffolding', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-actor-val-'));
tempDirs.push(tempRoot);
const actorDir = path.join(tempRoot, 'test-actor');
await actorCommand('test-actor', { outputDir: actorDir });
const validation = await validateCommand(actorDir);
assert.equal(validation.valid, true, `Diagnostics: ${JSON.stringify(validation.diagnostics)}`);
assert.equal(validation.packageName, '@matrix/test-actor');
});
it('installs into a packages directory', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-actor-inst-'));
tempDirs.push(tempRoot);
const actorDir = path.join(tempRoot, 'my-service');
await actorCommand('my-service', { outputDir: actorDir });
const packagesRoot = path.join(tempRoot, '.matrix', 'packages');
fs.mkdirSync(path.join(packagesRoot, 'node_modules'), { recursive: true });
await installCommand(actorDir, {
cwd: tempRoot,
packagesDir: packagesRoot,
force: true,
});
// Verify installed
const installedManifest = path.join(packagesRoot, 'node_modules', '@matrix', 'my-service', 'matrix.json');
assert.ok(fs.existsSync(installedManifest), 'Installed package should have matrix.json');
const manifest = JSON.parse(fs.readFileSync(installedManifest, 'utf8'));
assert.equal(manifest.name, '@matrix/my-service');
});
});
// ---------------------------------------------------------------------------
// Webapp scaffold
// ---------------------------------------------------------------------------
describe('webapp scaffold', () => {
it('generates all expected files with correct content', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-webapp-'));
tempDirs.push(tempRoot);
const appDir = path.join(tempRoot, 'my-dashboard');
const result = await webappCommand('my-dashboard', { outputDir: appDir });
// Return value shape
assert.equal(result.rootDir, appDir);
assert.ok(result.appClassName);
assert.ok(result.shellClassName);
assert.ok(result.tagName);
assert.equal(result.appName, 'my-dashboard');
// All expected webapp files
const expectedFiles = [
'matrix.json',
'package.json',
'tsconfig.json',
'vite.config.ts',
'index.html',
path.join('src', 'index.ts'),
path.join('src', 'init.ts'),
path.join('src', `${result.appClassName}.ts`),
path.join('src', 'shell', `${result.shellClassName}.ts`),
path.join('src', 'template', 'app.template.ts'),
path.join('src', 'styles', 'app.styles.ts'),
path.join('tests', 'my-dashboard.spec.ts'),
path.join('examples', 'introspect.mxq'),
];
for (const f of expectedFiles) {
assert.ok(fs.existsSync(path.join(appDir, f)), `Missing webapp file: ${f}`);
}
// matrix.json — webapp section (scoped name: @open-matrix/<name>)
const manifest = JSON.parse(fs.readFileSync(path.join(appDir, 'matrix.json'), 'utf8'));
assert.equal(manifest.name, '@open-matrix/my-dashboard');
assert.ok(manifest.webapp, 'should have webapp section');
assert.ok(manifest.webapp.distDir);
assert.equal(manifest.webapp.appName, 'my-dashboard');
// package.json — webapp deps
const pkg = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8'));
assert.equal(pkg.type, 'module');
assert.ok(pkg.scripts?.dev, 'should have vite dev script');
assert.ok(pkg.scripts?.build, 'should have vite build script');
assert.ok(pkg.devDependencies?.vite);
assert.ok(pkg.devDependencies?.['@open-matrix/core']);
assert.ok(pkg.devDependencies?.['@open-matrix/federation']);
// tsconfig.json — DOM libs, noEmit
const tsconfig = JSON.parse(fs.readFileSync(path.join(appDir, 'tsconfig.json'), 'utf8'));
assert.ok(tsconfig.compilerOptions.lib?.includes('DOM'));
assert.equal(tsconfig.compilerOptions.noEmit, true);
assert.equal(tsconfig.compilerOptions.module, 'ESNext');
assert.equal(tsconfig.compilerOptions.moduleResolution, 'Bundler');
// vite.config.ts — base path
const viteConfig = fs.readFileSync(path.join(appDir, 'vite.config.ts'), 'utf8');
assert.ok(viteConfig.includes('/apps/my-dashboard/'), 'vite base should point to /apps/my-dashboard/');
// index.html — custom element tag
const indexHtml = fs.readFileSync(path.join(appDir, 'index.html'), 'utf8');
assert.ok(indexHtml.includes(result.tagName), `HTML should contain custom element <${result.tagName}>`);
// Shell imports federation
const shellSrc = fs.readFileSync(
path.join(appDir, 'src', 'shell', `${result.shellClassName}.ts`),
'utf8',
);
assert.ok(shellSrc.includes('@open-matrix/core'), 'shell should import from @open-matrix/core');
});
it('validates successfully after scaffolding', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-webapp-val-'));
tempDirs.push(tempRoot);
const appDir = path.join(tempRoot, 'test-app');
await webappCommand('test-app', { outputDir: appDir });
const validation = await validateCommand(appDir);
assert.equal(validation.valid, true, `Diagnostics: ${JSON.stringify(validation.diagnostics)}`);
});
it('installs into a packages directory', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-webapp-inst-'));
tempDirs.push(tempRoot);
const appDir = path.join(tempRoot, 'my-app');
await webappCommand('my-app', { outputDir: appDir });
const packagesRoot = path.join(tempRoot, '.matrix', 'packages');
fs.mkdirSync(path.join(packagesRoot, 'node_modules'), { recursive: true });
await installCommand(appDir, {
cwd: tempRoot,
packagesDir: packagesRoot,
force: true,
});
// Scoped package: @open-matrix/my-app → node_modules/@open-matrix/my-app/
const installedManifest = path.join(packagesRoot, 'node_modules', '@open-matrix', 'my-app', 'matrix.json');
assert.ok(fs.existsSync(installedManifest), 'Installed webapp should have matrix.json');
const manifest = JSON.parse(fs.readFileSync(installedManifest, 'utf8'));
assert.equal(manifest.name, '@open-matrix/my-app');
assert.ok(manifest.webapp, 'Installed manifest should preserve webapp section');
});
});
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
describe('manifest validation', () => {
it('rejects manifest with missing required fields', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-lifecycle-bad-'));
tempDirs.push(tempRoot);
const badDir = path.join(tempRoot, 'bad-package');
fs.mkdirSync(badDir, { recursive: true });
fs.writeFileSync(
path.join(badDir, 'matrix.json'),
JSON.stringify({ name: 'bad-package' }, null, 2),
);
const validation = await validateCommand(badDir);
assert.ok(validation.diagnostics.length > 0, 'Should have diagnostics for bad manifest');
});
});
});