Files
dockhand-upstream/tests/deploy-run-view.test.ts

327 lines
11 KiB
TypeScript

/**
* deploy-run-view — pure text/label composition for the Stack modal's Deploys tab.
* Import-free by design (see deploy-run-view.ts's doc comment) so it runs under Bun
* without pulling in $lib/server (better-sqlite3, ERR_DLOPEN_FAILED).
*
* Run with: bun test tests/deploy-run-view.test.ts
*/
import { describe, test, expect } from 'bun:test';
import {
formatStatusLabel,
formatTrigger,
formatRunDuration,
formatContainerSummary,
formatBuildStatus,
formatOptionChips,
lastErrorLine,
buildDeployRunView,
buildDeployLogPanelState,
deployTallyFromRuns,
type DeployRun,
type DeployRunSummary
} from '../src/lib/utils/deploy-run-view';
const summary = (over: Partial<DeployRunSummary> = {}): DeployRunSummary => ({
containersCreated: 0,
containersRecreated: 0,
containersStarted: 0,
imagesBuilt: [],
imagesPulled: [],
buildSteps: 0,
buildStepsCached: 0,
...over
});
const run = (over: Partial<DeployRun> = {}): DeployRun => ({
id: 1,
triggeredBy: 'manual',
duration: 1500,
status: 'success',
errorMessage: null,
details: { options: { pull: false, build: false, forceRecreate: false } },
...over
});
describe('formatBuildStatus', () => {
test('every build step came from cache', () => {
expect(formatBuildStatus(summary({ buildSteps: 3, buildStepsCached: 3 }))).toBe(
'Built from cache (3 steps)'
);
});
test('a single cached step is not pluralized wrong', () => {
expect(formatBuildStatus(summary({ buildSteps: 1, buildStepsCached: 1 }))).toBe(
'Built from cache'
);
});
test('partially built — some steps from cache, some not', () => {
expect(formatBuildStatus(summary({ buildSteps: 4, buildStepsCached: 1 }))).toBe(
'Built (4 steps, 1 from cache)'
);
});
test('built with no cache hits at all', () => {
expect(formatBuildStatus(summary({ buildSteps: 2, buildStepsCached: 0 }))).toBe(
'Built (2 steps)'
);
});
test('nothing built — a run that never touched the build step', () => {
expect(formatBuildStatus(summary({ buildSteps: 0, buildStepsCached: 0 }))).toBe(
'Nothing built'
);
});
test('no summary at all reads as unknown, not as "nothing built"', () => {
expect(formatBuildStatus(undefined)).toBe('Build status unknown');
});
});
describe('formatContainerSummary', () => {
test('no summary recorded says so explicitly — never "0 containers"', () => {
const text = formatContainerSummary(undefined);
expect(text).toBe('No summary recorded for this run');
expect(text).not.toMatch(/0/);
});
test('a run with zero container changes reads distinctly from "no summary"', () => {
expect(formatContainerSummary(summary())).toBe('No container changes');
});
test('created and started are joined, recreated omitted when zero', () => {
expect(
formatContainerSummary(summary({ containersCreated: 3, containersStarted: 1 }))
).toBe('3 created, 1 started');
});
test('all three counts present appear in created, recreated, started order', () => {
expect(
formatContainerSummary(
summary({ containersCreated: 1, containersRecreated: 2, containersStarted: 3 })
)
).toBe('1 created, 2 recreated, 3 started');
});
});
describe('formatOptionChips', () => {
test('no options recorded yields no chips', () => {
expect(formatOptionChips(undefined)).toEqual([]);
});
test('all flags false yields no chips', () => {
expect(formatOptionChips({ pull: false, build: false, forceRecreate: false })).toEqual([]);
});
test('every true flag becomes its own chip, in a fixed order', () => {
expect(formatOptionChips({ pull: true, build: true, forceRecreate: true })).toEqual([
'Pull',
'Build',
'Force recreate'
]);
});
test('a single flag yields a single chip', () => {
expect(formatOptionChips({ pull: true, build: false, forceRecreate: false })).toEqual(['Pull']);
});
});
describe('formatTrigger', () => {
test.each([
['manual', 'Manual'],
['cron', 'Scheduled'],
['webhook', 'Webhook'],
['startup', 'Startup']
])('%s -> %s', (input: string, expected: string) => {
expect(formatTrigger(input)).toBe(expected);
});
test('an unrecognized trigger value is not swallowed silently', () => {
expect(formatTrigger('something-new')).toBe('something-new');
});
});
describe('formatStatusLabel', () => {
test.each([
['success', 'Success'],
['failed', 'Failed'],
['running', 'Running']
])('%s -> %s', (input: string, expected: string) => {
expect(formatStatusLabel(input)).toBe(expected);
});
test('an unrecognized status value is passed through, not hidden', () => {
expect(formatStatusLabel('mystery')).toBe('mystery');
});
});
describe('formatRunDuration', () => {
test('null/undefined duration (a run still in progress or never started)', () => {
expect(formatRunDuration(null)).toBe('—');
expect(formatRunDuration(undefined)).toBe('—');
});
test('sub-second durations render in milliseconds', () => {
expect(formatRunDuration(340)).toBe('340ms');
});
test('sub-minute durations render with one decimal of seconds', () => {
expect(formatRunDuration(13515)).toBe('13.5s');
});
test('minute-scale durations render as Nm Ns', () => {
expect(formatRunDuration(125000)).toBe('2m 5s');
});
});
describe('lastErrorLine', () => {
test('no error message at all', () => {
expect(lastErrorLine(null)).toBeNull();
expect(lastErrorLine(undefined)).toBeNull();
expect(lastErrorLine('')).toBeNull();
});
test('a short single-line message is returned unchanged', () => {
expect(lastErrorLine('exit code 1')).toBe('exit code 1');
});
test('a multi-line message keeps only its last non-empty line', () => {
const msg = 'Pulling image...\nBuilding...\nError: container failed to start\n';
expect(lastErrorLine(msg)).toBe('Error: container failed to start');
});
test('a long single-line message is cut to maxLen with an ellipsis marker', () => {
const long = 'x'.repeat(300);
const result = lastErrorLine(long, 50);
expect(result?.length).toBe(50);
expect(result?.endsWith('…')).toBe(true);
expect(result?.startsWith('x'.repeat(49))).toBe(true);
});
});
describe('buildDeployRunView', () => {
test('a run without a summary (no output at all) never claims to know container/build facts', () => {
const view = buildDeployRunView(run({ details: { options: { pull: true, build: false, forceRecreate: false } } }));
expect(view.containerSummary).toBe('No summary recorded for this run');
expect(view.buildStatus).toBe('Build status unknown');
expect(view.imagesBuilt).toEqual([]);
expect(view.imagesPulled).toEqual([]);
// No summary -> no container names (for the name-matched icon chips).
expect(view.containerNames).toEqual([]);
});
test('containerNames pass through from the summary (and default to [] when absent)', () => {
const withNames = buildDeployRunView(run({ details: { summary: summary({ containerNames: ['app-1', 'redis-1'] }) } }));
expect(withNames.containerNames).toEqual(['app-1', 'redis-1']);
// A summary that predates the field (undefined) reads as an empty list, never undefined.
const noNames = buildDeployRunView(run({ details: { summary: summary() } }));
expect(noNames.containerNames).toEqual([]);
});
test('truncated is surfaced only when the record actually says so', () => {
expect(buildDeployRunView(run()).truncated).toBe(false);
expect(
buildDeployRunView(
run({ details: { options: { pull: false, build: false, forceRecreate: false }, truncated: true } })
).truncated
).toBe(true);
});
test('a failed run with a long error message carries the shortened last line', () => {
const longError = `Step 1 ok\nStep 2 ok\n${'boom '.repeat(60)}`;
const view = buildDeployRunView(run({ status: 'failed', errorMessage: longError }));
expect(view.errorSummary).not.toBeNull();
expect(view.errorSummary!.length).toBeLessThanOrEqual(200);
expect(view.statusLabel).toBe('Failed');
});
test('a successful run never surfaces an error summary even if errorMessage is set', () => {
const view = buildDeployRunView(run({ status: 'success', errorMessage: 'leftover text' }));
expect(view.errorSummary).toBeNull();
});
test('a running run in progress has no error summary and reads as Running', () => {
const view = buildDeployRunView(run({ status: 'running', duration: null, errorMessage: null }));
expect(view.errorSummary).toBeNull();
expect(view.statusLabel).toBe('Running');
expect(view.duration).toBe('—');
});
test('option chips and images pass through from the run summary', () => {
const view = buildDeployRunView(
run({
details: {
options: { pull: true, build: true, forceRecreate: false },
summary: summary({ imagesBuilt: ['app:1'], imagesPulled: ['redis:7'] })
}
})
);
expect(view.optionChips).toEqual(['Pull', 'Build']);
expect(view.imagesBuilt).toEqual(['app:1']);
expect(view.imagesPulled).toEqual(['redis:7']);
});
});
describe('buildDeployLogPanelState', () => {
test('a normal completed run: log fetchable, not truncated, deletable', () => {
const state = buildDeployLogPanelState(run({ status: 'success' }));
expect(state).toEqual({ logMissing: false, truncated: false, deletable: true });
});
test('a run reconciled as logMissing short-circuits to logMissing -- regardless of truncated', () => {
const state = buildDeployLogPanelState(
run({ status: 'success', details: { logMissing: true, truncated: true } })
);
expect(state.logMissing).toBe(true);
});
test('a truncated run surfaces truncated independently of logMissing', () => {
const state = buildDeployLogPanelState(run({ status: 'failed', details: { truncated: true } }));
expect(state.logMissing).toBe(false);
expect(state.truncated).toBe(true);
});
test('a running run is never deletable, even though its log is not missing', () => {
const state = buildDeployLogPanelState(run({ status: 'running', details: undefined }));
expect(state.deletable).toBe(false);
expect(state.logMissing).toBe(false);
});
test('mutating the run to running after it was deletable flips deletable back off', () => {
// Regression guard for a hand-rolled `run.status !== "running"` check drifting
// out of sync with this function if someone inlines the condition again later.
const base = run({ status: 'success' });
expect(buildDeployLogPanelState(base).deletable).toBe(true);
expect(buildDeployLogPanelState({ ...base, status: 'running' }).deletable).toBe(false);
});
test('a run with no details at all reads as available, not truncated, deletable', () => {
const state = buildDeployLogPanelState(run({ status: 'success', details: null }));
expect(state).toEqual({ logMissing: false, truncated: false, deletable: true });
});
});
describe('deployTallyFromRuns', () => {
const r = (status: string) => ({ status }) as Pick<DeployRun, 'status'>;
test('counts success as ok and failed as failed', () => {
expect(deployTallyFromRuns([r('success'), r('success'), r('failed')])).toEqual({
total: 3,
ok: 2,
failed: 1
});
});
test('running/queued/other count as neither ok nor failed but still in total', () => {
expect(deployTallyFromRuns([r('running'), r('queued'), r('skipped'), r('success')])).toEqual({
total: 4,
ok: 1,
failed: 0
});
});
test('empty list is all zeros', () => {
expect(deployTallyFromRuns([])).toEqual({ total: 0, ok: 0, failed: 0 });
});
});