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