409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
import { describe, it } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import * as fs from 'node:fs';
|
|
import * as net from 'node:net';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { spawn } from 'node:child_process';
|
|
|
|
import { resolveNatsBinary } from '../../src/utils/runner-transport.js';
|
|
import { assertExitCode, parseLastJson, runMxCli } from '../helpers/cli-harness.js';
|
|
|
|
interface IWebSocketLike {
|
|
addEventListener(
|
|
type: 'open' | 'error' | 'close',
|
|
listener: (event: unknown) => void,
|
|
options?: { once?: boolean },
|
|
): void;
|
|
close(): void;
|
|
}
|
|
|
|
async function getAvailablePorts(count: number): Promise<number[]> {
|
|
const servers = await Promise.all(
|
|
Array.from({ length: count }, () => new Promise<net.Server>((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
})),
|
|
);
|
|
const ports = servers.map((server) => {
|
|
const address = server.address();
|
|
assert.ok(address && typeof address === 'object');
|
|
return address.port;
|
|
});
|
|
await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
})));
|
|
return ports;
|
|
}
|
|
|
|
async function createRunInPlaceFixture(rootDir: string): Promise<string> {
|
|
const packageDir = path.join(rootDir, 'sample-run-package');
|
|
const runtimeDir = path.join(packageDir, 'dist', 'runtime');
|
|
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
fs.mkdirSync(path.join(packageDir, '.matrix'), { recursive: true });
|
|
const [natsPort, wsPort] = await getAvailablePorts(2);
|
|
|
|
fs.writeFileSync(
|
|
path.join(packageDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: '@acme/sample-run-package',
|
|
version: '1.0.0',
|
|
type: 'module',
|
|
main: './dist/runtime/index.js',
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(packageDir, 'matrix.json'),
|
|
JSON.stringify({
|
|
name: '@acme/sample-run-package',
|
|
version: '1.0.0',
|
|
runtime: {
|
|
language: 'typescript',
|
|
entry: './dist/runtime/index.js',
|
|
factory: {
|
|
kind: 'factory',
|
|
export: 'createSampleRunService',
|
|
bootstrap: {
|
|
overrides: {
|
|
mode: 'run-in-place',
|
|
},
|
|
},
|
|
instance: {
|
|
packageName: '@acme/sample-run-package',
|
|
packageRevision: '1.0.0',
|
|
class: 'service',
|
|
rootKind: 'package-root',
|
|
mount: 'sample-run.local',
|
|
autoStart: true,
|
|
},
|
|
},
|
|
},
|
|
components: [
|
|
{
|
|
type: 'SampleRunService',
|
|
export: 'SampleRunService',
|
|
mount: 'sample-run',
|
|
autoStart: true,
|
|
},
|
|
],
|
|
permissions: {
|
|
fsPolicy: 'none',
|
|
},
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(packageDir, '.matrix', 'dev.environment.json'),
|
|
JSON.stringify({
|
|
name: 'dev',
|
|
runtime: {
|
|
root: 'COM.TEST.RUNNER',
|
|
runtimeId: 'sample-run-dev',
|
|
},
|
|
nats: {
|
|
mode: 'embedded',
|
|
port: natsPort,
|
|
wsPort,
|
|
binaryPath: resolveNatsBinary(process.cwd()),
|
|
},
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(runtimeDir, 'index.js'),
|
|
`export async function createSampleRunService(overrides = {}, _services, _configResolution, bootstrapContext) {
|
|
return {
|
|
ok: true,
|
|
overrides,
|
|
bootContext: bootstrapContext,
|
|
runtime: {
|
|
async shutdown() {
|
|
return undefined;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
`,
|
|
'utf8',
|
|
);
|
|
|
|
return packageDir;
|
|
}
|
|
|
|
async function createServeFixture(rootDir: string): Promise<{
|
|
packageDir: string;
|
|
httpPort: number;
|
|
}> {
|
|
const packageDir = path.join(rootDir, 'sample-serve-package');
|
|
const distDir = path.join(packageDir, 'dist');
|
|
fs.mkdirSync(path.join(packageDir, '.matrix'), { recursive: true });
|
|
fs.mkdirSync(distDir, { recursive: true });
|
|
fs.mkdirSync(path.join(packageDir, 'branding'), { recursive: true });
|
|
const [httpPort, natsPort, wsPort] = await getAvailablePorts(3);
|
|
|
|
fs.writeFileSync(
|
|
path.join(packageDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: '@acme/sample-serve-package',
|
|
version: '1.0.0',
|
|
type: 'module',
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(packageDir, 'matrix.json'),
|
|
JSON.stringify({
|
|
name: '@acme/sample-serve-package',
|
|
version: '1.0.0',
|
|
webapp: {
|
|
distDir: 'dist',
|
|
entry: 'index.html',
|
|
appName: 'sample-serve',
|
|
},
|
|
permissions: {
|
|
fsPolicy: 'none',
|
|
},
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(packageDir, '.matrix', 'dev.environment.json'),
|
|
JSON.stringify({
|
|
name: 'dev',
|
|
runtime: {
|
|
root: 'COM.TEST.SERVE',
|
|
runtimeId: 'sample-serve-dev',
|
|
},
|
|
nats: {
|
|
mode: 'embedded',
|
|
port: natsPort,
|
|
wsPort,
|
|
binaryPath: resolveNatsBinary(process.cwd()),
|
|
},
|
|
http: {
|
|
enabled: true,
|
|
port: httpPort,
|
|
},
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(distDir, 'index.html'),
|
|
'<!DOCTYPE html><html><head><title>Sample Serve</title><link rel="manifest" href="/manifest.json"></head><body><div id="app">Sample Serve Fixture</div></body></html>\n',
|
|
'utf8',
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(distDir, 'app.js'),
|
|
'console.log("sample-serve");\n',
|
|
'utf8',
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(packageDir, 'branding', 'manifest.json'),
|
|
JSON.stringify({
|
|
name: 'Sample Serve',
|
|
short_name: 'Sample',
|
|
start_url: '/',
|
|
display: 'standalone',
|
|
icons: [{ src: '/branding/favicon.svg', sizes: '16x16', type: 'image/svg+xml' }],
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(packageDir, 'branding', 'favicon.svg'),
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#111827"/><path d="M4 11h8v2H4zM5 3h6v2H5zm1 4h4v2H6z" fill="#f8fafc"/></svg>\n',
|
|
'utf8',
|
|
);
|
|
|
|
return { packageDir, httpPort };
|
|
}
|
|
|
|
async function waitForHttpReady(
|
|
url: string,
|
|
child: ReturnType<typeof spawn>,
|
|
timeoutMs = 15_000,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) {
|
|
throw new Error(`mx run exited early with code ${child.exitCode}`);
|
|
}
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// Retry until the deadline expires.
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error(`Timed out waiting for ${url}`);
|
|
}
|
|
|
|
describe('mx run package front door', () => {
|
|
it('runs a package from the current folder via matrix.json runtime.factory and package-local environment', async () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-package-'));
|
|
const packageDir = await createRunInPlaceFixture(tempRoot);
|
|
|
|
assert.equal(fs.existsSync(path.join(packageDir, '.matrix', 'host.status.json')), false);
|
|
|
|
const result = await runMxCli(
|
|
['node', 'mx', 'run', '.', '--env', 'dev', '--check', '--json'],
|
|
{ cwd: packageDir },
|
|
);
|
|
|
|
assertExitCode(result, 0);
|
|
const payload = parseLastJson(result.logs) as Record<string, unknown>;
|
|
assert.equal(payload.packageName, '@acme/sample-run-package');
|
|
assert.equal(payload.packageDir, packageDir);
|
|
assert.equal(payload.environmentName, 'dev');
|
|
assert.equal(payload.root, 'COM.TEST.RUNNER');
|
|
assert.equal(payload.mount, 'sample-run.local');
|
|
assert.equal(payload.entryPath, path.join(packageDir, 'dist', 'runtime', 'index.js'));
|
|
assert.equal(payload.runnerTransport?.mode, 'embedded');
|
|
assert.equal(payload.runnerTransport?.root, 'COM.TEST.RUNNER');
|
|
assert.match(String(payload.runnerTransport?.url), /^nats:\/\/127\.0\.0\.1:\d+$/);
|
|
assert.match(String(payload.runnerTransport?.wsUrl), /^ws:\/\/127\.0\.0\.1:\d+$/);
|
|
});
|
|
|
|
it('fails clearly when a directory target does not declare a runtime factory', async () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-missing-service-'));
|
|
const packageDir = path.join(tempRoot, 'missing-service');
|
|
fs.mkdirSync(packageDir, { recursive: true });
|
|
|
|
const result = await runMxCli(
|
|
['node', 'mx', 'run', '.'],
|
|
{ cwd: packageDir },
|
|
);
|
|
|
|
assertExitCode(result, 1);
|
|
assert.match(
|
|
result.errors.join('\n'),
|
|
/does not declare a matrix\.json runtime\.factory/,
|
|
);
|
|
});
|
|
|
|
it('runs a webapp package as a Matrix asset runtime without a package HTTP server', async () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-webapp-assets-'));
|
|
const { packageDir } = await createServeFixture(tempRoot);
|
|
|
|
const result = await runMxCli(
|
|
['node', 'mx', 'run', '.', '--env', 'dev', '--check', '--json'],
|
|
{ cwd: packageDir },
|
|
);
|
|
|
|
assertExitCode(result, 0);
|
|
const payload = parseLastJson(result.logs) as Record<string, unknown>;
|
|
assert.equal(payload.packageName, '@acme/sample-serve-package');
|
|
assert.equal(payload.mode, 'webapp-assets');
|
|
assert.equal(payload.assetMount, 'system.runtimes.sample-serve-dev.http');
|
|
assert.equal(payload.baseUrl, undefined);
|
|
assert.equal(payload.runnerTransport?.mode, 'embedded');
|
|
});
|
|
|
|
it('publishes a served webapp HTTP origin in run metadata', async () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-webapp-origin-'));
|
|
const { packageDir, httpPort } = await createServeFixture(tempRoot);
|
|
|
|
const result = await runMxCli(
|
|
['node', 'mx', 'run', '.', '--env', 'dev', '--serve', '--check', '--json'],
|
|
{ cwd: packageDir },
|
|
);
|
|
|
|
assertExitCode(result, 0);
|
|
const payload = parseLastJson(result.logs) as Record<string, unknown>;
|
|
assert.equal(payload.packageName, '@acme/sample-serve-package');
|
|
assert.equal(payload.mode, 'standalone-webapp');
|
|
assert.equal(payload.assetMount, 'system.runtimes.sample-serve-dev.http');
|
|
assert.equal(payload.baseUrl, `http://127.0.0.1:${httpPort}`);
|
|
assert.equal(payload.httpPort, httpPort);
|
|
assert.equal(payload.runnerTransport?.mode, 'embedded');
|
|
});
|
|
|
|
it('serves built webapp assets with same-origin bootstrap and nats-ws routes', async () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mx-run-serve-'));
|
|
const { packageDir, httpPort } = await createServeFixture(tempRoot);
|
|
const mxCliDir = path.resolve(import.meta.dirname, '../..');
|
|
const cliEntry = path.resolve(import.meta.dirname, '../../src/index.ts');
|
|
const tsxLoader = path.resolve(import.meta.dirname, '../../../../node_modules/tsx/dist/loader.mjs');
|
|
const child = spawn(
|
|
process.execPath,
|
|
['--import', tsxLoader, cliEntry, 'run', packageDir, '--env', 'dev', '--serve'],
|
|
{
|
|
cwd: mxCliDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
},
|
|
);
|
|
|
|
const stdout: string[] = [];
|
|
const stderr: string[] = [];
|
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
stdout.push(chunk.toString('utf8'));
|
|
});
|
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
stderr.push(chunk.toString('utf8'));
|
|
});
|
|
|
|
try {
|
|
const baseUrl = `http://127.0.0.1:${httpPort}`;
|
|
await waitForHttpReady(`${baseUrl}/healthz`, child);
|
|
|
|
const html = await fetch(`${baseUrl}/`);
|
|
assert.equal(html.status, 200);
|
|
assert.match(await html.text(), /Sample Serve Fixture/);
|
|
|
|
const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
|
|
assert.equal(bootstrap.status, 200);
|
|
const payload = await bootstrap.json() as {
|
|
root?: string;
|
|
transportPlan?: { wsPath?: string; authMode?: string; sessionMode?: string };
|
|
};
|
|
assert.equal(payload.root, 'COM.TEST.SERVE');
|
|
assert.equal(payload.transportPlan?.wsPath, '/nats-ws');
|
|
assert.equal(payload.transportPlan?.authMode, 'anonymous');
|
|
assert.equal(payload.transportPlan?.sessionMode, 'local-client');
|
|
|
|
const manifest = await fetch(`${baseUrl}/manifest.json`);
|
|
assert.equal(manifest.status, 200);
|
|
const branding = await fetch(`${baseUrl}/branding/favicon.svg`);
|
|
assert.equal(branding.status, 200);
|
|
|
|
const WebSocketCtor = (globalThis as typeof globalThis & {
|
|
WebSocket?: new (url: string) => IWebSocketLike;
|
|
}).WebSocket;
|
|
assert.ok(WebSocketCtor, 'Node runtime must expose WebSocket for same-origin proxy proof');
|
|
const ws = new WebSocketCtor(`ws://127.0.0.1:${httpPort}/nats-ws`);
|
|
await new Promise<void>((resolve, reject) => {
|
|
ws.addEventListener('open', () => {
|
|
ws.close();
|
|
}, { once: true });
|
|
ws.addEventListener('error', (event) => {
|
|
reject(new Error(`WebSocket upgrade failed: ${String(event)}`));
|
|
}, { once: true });
|
|
ws.addEventListener('close', () => {
|
|
resolve();
|
|
}, { once: true });
|
|
});
|
|
} finally {
|
|
child.kill('SIGTERM');
|
|
await new Promise<void>((resolve) => {
|
|
child.once('exit', () => resolve());
|
|
});
|
|
}
|
|
|
|
assert.match(stdout.join(''), /serving @acme\/sample-serve-package/);
|
|
assert.doesNotMatch(stderr.join(''), /Run target not found|does not declare|Error:/);
|
|
});
|
|
});
|