PSA/ee/appliance/operator/tests/tui-ink.test.mjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

440 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { render } from 'ink-testing-library';
import { TuiApp } from '../lib/tui.mjs';
function sleep(ms = 0) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function makeStatus() {
return {
siteId: 'appliance-single-node',
nodeIp: '10.0.0.2',
connectivityMode: 'kubernetes-available',
topBlocker: {
layer: 'none',
reason: 'No blocker detected',
nextAction: 'No immediate action required.',
},
host: { status: 'healthy', details: 'Talos API reachable' },
cluster: {
apiReachable: true,
status: 'healthy',
nodeReadiness: [{ name: 'node-1', ready: true, status: 'True', message: '' }],
},
flux: {
status: 'healthy',
helmStatus: 'healthy',
sources: [],
kustomizations: [],
helmReleases: [],
},
workloads: {
status: 'healthy',
components: [
{ name: 'alga-core', status: 'healthy', ready: '1/1', message: '' },
{ name: 'db', status: 'healthy', ready: '1/1', message: '' },
],
},
release: {
selectedReleaseVersion: '1.0.0',
appVersion: '1.0.0',
manifestDigest: 'sha256:release',
appUrl: 'https://psa.example.com',
},
configPaths: {
configDir: '/tmp/site',
kubeconfig: '/tmp/kubeconfig',
talosconfig: '/tmp/talosconfig',
},
};
}
function makeEnv(overrides = {}) {
return {
runtime: {
assetRoot: '/tmp',
resetScript: '/tmp/reset-appliance-data.sh',
supportBundleScript: '/tmp/collect-support-bundle.sh',
},
configBaseDir: '/tmp/config-base',
siteIds: ['appliance-single-node'],
siteSelectionRequired: false,
site: {
siteId: 'appliance-single-node',
configDir: '/tmp/site',
kubeconfig: '/tmp/kubeconfig',
talosconfig: '/tmp/talosconfig',
nodeIpFile: '/tmp/site/node-ip',
appUrlFile: '/tmp/site/app-url',
},
paths: {
kubeconfig: '/tmp/kubeconfig',
talosconfig: '/tmp/talosconfig',
},
nodeIp: '10.0.0.2',
appUrl: 'https://psa.example.com',
...overrides,
};
}
function makeActions(overrides = {}) {
return {
collectStatus: async () => makeStatus(),
runRepairRelease: async () => ({ ok: true }),
runReset: async () => ({ ok: true }),
runSupportBundle: async () => ({ ok: true }),
listAppliancePods: async () => ({
fetchedAt: '2026-03-25T12:00:00Z',
namespaces: ['msp'],
errors: [],
pods: [
{
key: 'msp/alga-core-0',
namespace: 'msp',
name: 'alga-core-0',
phase: 'Running',
status: 'Running',
ready: '2/2',
restarts: 0,
age: '10m',
},
],
}),
readPodLogsTail: async () => ({
ok: true,
lines: [
{ timestamp: '2026-03-25T12:00:00Z', text: '2026-03-25T12:00:00Z ready' },
{ timestamp: '2026-03-25T12:00:01Z', text: '2026-03-25T12:00:01Z healthy' },
],
}),
readPodLogsSince: async () => ({ ok: true, lines: [] }),
...overrides,
};
}
function pressEnter(ui) {
ui.stdin.write('\r');
}
function typeText(ui, text) {
ui.stdin.write(text);
}
function pressJ(ui, count = 1) {
for (let index = 0; index < count; index += 1) {
ui.stdin.write('j');
}
}
test('T007: Ink shell renders persistent layout regions instead of sequential prompt output', async () => {
const ui = render(
React.createElement(TuiApp, {
initialEnv: makeEnv(),
actions: makeActions(),
onExit: () => {},
}),
);
await sleep(20);
const frame = ui.lastFrame() || '';
assert.match(frame, /Alga PSA Operator/);
assert.match(frame, /Actions/);
assert.match(frame, /Status Dashboard/);
assert.doesNotMatch(frame, /Live Progress/);
assert.match(frame, /Status/);
assert.doesNotMatch(frame, /Select \[1\]/);
ui.unmount();
});
test('T008: Ink lifecycle forms are keyboard-navigable, including repair and reset confirmation', async () => {
const repairUi = render(
React.createElement(TuiApp, {
initialEnv: makeEnv(),
actions: makeActions(),
onExit: () => {},
}),
);
await sleep(20);
pressJ(repairUi, 2);
await sleep(20);
pressEnter(repairUi);
await sleep(20);
let frame = repairUi.lastFrame() || '';
assert.match(frame, /Repair Release Form/);
assert.match(frame, /Cleanup Failed Workloads/);
repairUi.unmount();
const resetUi = render(
React.createElement(TuiApp, {
initialEnv: makeEnv(),
actions: makeActions(),
onExit: () => {},
}),
);
await sleep(20);
// Navigate to Reset and verify challenge behavior.
pressJ(resetUi, 4);
await sleep(20);
pressEnter(resetUi);
await sleep(20);
frame = resetUi.lastFrame() || '';
assert.match(frame, /Reset Form/);
assert.match(frame, /Wipes namespace msp/);
pressEnter(resetUi);
await sleep(20);
frame = resetUi.lastFrame() || '';
assert.match(frame, /Reset confirmation mismatch/i);
resetUi.unmount();
const resetConfirmUi = render(
React.createElement(TuiApp, {
initialEnv: makeEnv(),
actions: makeActions(),
onExit: () => {},
}),
);
await sleep(20);
pressJ(resetConfirmUi, 4);
await sleep(20);
pressEnter(resetConfirmUi);
await sleep(20);
typeText(resetConfirmUi, 'WIPE appliance-single-node');
pressEnter(resetConfirmUi);
await sleep(20);
frame = resetConfirmUi.lastFrame() || '';
assert.match(frame, /Type WIPE appliance-single-node: WIPE/);
assert.match(frame, /Reset confirmation mismatch|Enter confirm/i);
resetConfirmUi.unmount();
});
test('site-bound actions defer to site picker when no site is selected', async () => {
const env = makeEnv({
siteIds: ['site-a', 'site-b'],
siteSelectionRequired: true,
suggestedSiteId: 'site-a',
site: null,
paths: {
kubeconfig: null,
talosconfig: null,
},
nodeIp: '10.0.0.9',
appUrl: 'http://10.0.0.9:3000',
});
const ui = render(
React.createElement(TuiApp, {
initialEnv: env,
actions: makeActions(),
onExit: () => {},
}),
);
await sleep(20);
let frame = ui.lastFrame() || '';
assert.match(frame, /Selected appliance: unselected/);
assert.match(frame, /Status/);
pressEnter(ui);
await sleep(20);
frame = ui.lastFrame() || '';
assert.match(frame, /Select Appliance Site/);
assert.match(frame, /continue to Status/);
ui.unmount();
});
test('T009: Ink progress stream stays in dedicated region while status dashboard remains visible', async () => {
const ui = render(
React.createElement(TuiApp, {
initialEnv: makeEnv(),
actions: makeActions({
runRepairRelease: async (_env, options) => {
options.onProgress?.({ type: 'phase', phase: 'Helm', line: 'Helm phase' });
options.onProgress?.({ type: 'line', line: 'helmrelease/alga-core reconciling' });
options.onProgress?.({ type: 'done', line: 'repair complete' });
await sleep(10);
return { ok: true };
},
}),
onExit: () => {},
}),
);
await sleep(20);
pressJ(ui, 2); // Repair Release
await sleep(20);
pressEnter(ui); // Form
await sleep(20);
let frame = ui.lastFrame() || '';
assert.match(frame, /Repair Release Form/);
pressEnter(ui); // Confirm
await sleep(20);
pressEnter(ui); // Run
await sleep(40);
frame = ui.lastFrame() || '';
assert.match(frame, /Repair Release completed successfully/);
assert.match(frame, /Live Progress/);
assert.match(frame, /Helm phase/);
assert.match(frame, /helmrelease\/alga-core reconciling/);
assert.match(frame, /Status Dashboard/);
ui.unmount();
});
test('T010: Workload console lists appliance-scoped pods with status columns and preserves selection on refresh', async () => {
let pollCount = 0;
const ui = render(
React.createElement(TuiApp, {
initialEnv: makeEnv(),
actions: makeActions({
listAppliancePods: async () => {
pollCount += 1;
const updatedAge = pollCount > 1 ? '11m' : '10m';
return {
fetchedAt: `2026-03-25T12:00:0${Math.min(pollCount, 9)}Z`,
namespaces: ['msp'],
errors: [],
pods: [
{
key: 'msp/alga-core-0',
namespace: 'msp',
name: 'alga-core-0',
phase: 'Running',
status: 'Running',
ready: '2/2',
restarts: 0,
age: updatedAge,
},
],
};
},
}),
onExit: () => {},
}),
);
await sleep(20);
pressJ(ui, 1); // Workloads
await sleep(20);
pressEnter(ui);
await sleep(60);
let frame = ui.lastFrame() || '';
assert.match(frame, /Workloads/);
assert.match(frame, /Namespace: msp/);
assert.match(frame, /alga-core-0/);
assert.match(frame, /Ready\s+Restarts\s+Age/);
pressJ(ui); // stays on only row
await sleep(20);
ui.stdin.write('r');
await sleep(40);
frame = ui.lastFrame() || '';
assert.match(frame, /> alga-core-0/);
assert.match(frame, /11m|10m/);
ui.unmount();
});
test('T011: Log viewer opens from workload list and Escape returns to workloads layout', async () => {
const ui = render(
React.createElement(TuiApp, {
initialEnv: makeEnv(),
actions: makeActions(),
onExit: () => {},
}),
);
await sleep(20);
pressJ(ui, 1);
await sleep(20);
pressEnter(ui);
await sleep(50);
pressEnter(ui);
await sleep(40);
let frame = ui.lastFrame() || '';
assert.match(frame, /Logs: msp\/alga-core-0/);
assert.match(frame, /ready/);
ui.stdin.write('\u001B'); // escape
await sleep(40);
frame = ui.lastFrame() || '';
assert.match(frame, /Workloads/);
assert.match(frame, /> alga-core-0/);
ui.unmount();
});
test('T012: Log viewer prepends older chunks, toggles follow mode, and bounds line count', async () => {
let tailCalls = 0;
const ui = render(
React.createElement(TuiApp, {
initialEnv: makeEnv(),
actions: makeActions({
readPodLogsTail: async (_env, _pod, options) => {
tailCalls += 1;
const tail = Number(options?.tailLines || 0);
const lines = [];
for (let index = 0; index < tail; index += 1) {
const stamp = `2026-03-25T12:00:${String(index % 60).padStart(2, '0')}Z`;
lines.push({ timestamp: stamp, text: `${stamp} line-${index}` });
}
return { ok: true, lines };
},
readPodLogsSince: async () => ({
ok: true,
lines: [{ timestamp: '2026-03-25T12:10:00Z', text: '2026-03-25T12:10:00Z live-line' }],
}),
}),
onExit: () => {},
}),
);
await sleep(20);
pressJ(ui, 1);
await sleep(20);
pressEnter(ui);
await sleep(40);
pressEnter(ui); // open logs
await sleep(40);
let frame = ui.lastFrame() || '';
assert.match(frame, /Follow: on/);
ui.stdin.write('k');
await sleep(40);
frame = ui.lastFrame() || '';
assert.match(frame, /Follow: paused/);
for (let index = 0; index < 140; index += 1) {
ui.stdin.write('k');
}
await sleep(40);
pressEnter(ui);
await sleep(70);
frame = ui.lastFrame() || '';
const countMatch = frame.match(/Lines:\s+(\d+)/);
assert.ok(countMatch);
const lineCount = Number(countMatch[1]);
assert.ok(lineCount > 120);
assert.ok(lineCount <= 400);
assert.ok(tailCalls >= 2);
for (let index = 0; index < 260; index += 1) {
ui.stdin.write('j');
}
await sleep(1700);
frame = ui.lastFrame() || '';
assert.match(frame, /Follow: on/);
assert.match(frame, /live-line|line-/);
ui.unmount();
});