live compose output during deploys, plus a per-stack deploy history tab

This commit is contained in:
Jarek Krochmalski committed 2026-09-08 07:47:30 +05:30
1 parent b2eb0b5dcb
commit 44295ea931
41 files changed
+2093 -1159

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ describe('buildRunDetails', () => {
const d = buildRunDetails({
options: { pull: false, build: true, forceRecreate: true },
summary: { containersCreated: 2, containersStarted: 2, containersRecreated: 0,
containerNames: ['app-1'],
imagesBuilt: ['app:1'], imagesPulled: [], buildSteps: 3, buildStepsCached: 1,
digest: undefined },
exitCode: 0,
+35
View File
@@ -16,6 +16,7 @@ import {
lastErrorLine,
buildDeployRunView,
buildDeployLogPanelState,
deployTallyFromRuns,
type DeployRun,
type DeployRunSummary
} from '../src/lib/utils/deploy-run-view';
@@ -205,6 +206,16 @@ describe('buildDeployRunView', () => {
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', () => {
@@ -289,3 +300,27 @@ describe('buildDeployLogPanelState', () => {
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 });
});
});
+18 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { summarize } from '../src/lib/server/deploy-summary-core';
import { summarize } from '../src/lib/utils/deploy-summary-core';
const REAL_RUN = [
' Image alpine:3.21 Pulling ',
@@ -76,6 +76,23 @@ describe('summarize', () => {
expect(s.containersCreated).toBe(0);
});
test('collects distinct container names in first-appearance order', () => {
// The name recurs across Created + Starting + Started, so containerNames is not
// any single verb Set -- it is the ordered union, each name once.
const s = summarize(REAL_RUN);
expect(s.containerNames).toEqual(['probea-built-1', 'probea-plain-1']);
});
test('a recreated container appears once in containerNames', () => {
const s = summarize([
' Container a Recreate ',
' Container a Recreated ',
' Container a Starting ',
' Container a Started '
]);
expect(s.containerNames).toEqual(['a']);
});
test('an image that was already local produces no pull entry at all', () => {
// Measured: compose emits neither Pulling nor Pulled for an image that is present.
// Absence is the only signal, so the summary must not invent one.
+65
View File
@@ -0,0 +1,65 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { parseDockerOutput } from '../src/lib/components/ui/error-dialog/error-dialog-core';
describe('parseDockerOutput: compose warning lines', () => {
test('captures the FULL warning even with escaped quotes inside msg', () => {
// The real docker-compose line that rendered as a truncated "The \" in the UI.
const line =
'time="06.09.2026 18:25:03" level=warning msg="The \\"MISSING_ONE\\" variable is not set. Defaulting to a blank string."';
const r = parseDockerOutput(line);
expect(r.warnings).toEqual(['The "MISSING_ONE" variable is not set. Defaulting to a blank string.']);
expect(r.parsed).toBe(true);
});
test('a plain warning with no escaped quotes still works', () => {
const line = 'time="x" level=warning msg="pull access denied"';
expect(parseDockerOutput(line).warnings).toEqual(['pull access denied']);
});
test('multiple warnings are all captured in full', () => {
const text = [
'time="t1" level=warning msg="The \\"A\\" variable is not set."',
'time="t2" level=warning msg="The \\"B\\" variable is not set."'
].join('\n');
expect(parseDockerOutput(text).warnings).toEqual([
'The "A" variable is not set.',
'The "B" variable is not set.'
]);
});
});
describe('parseDockerOutput: steps and errors', () => {
test('parses container/network/volume steps', () => {
const text = [
'Network foo_frontend Creating',
'Container foo-web-1 Created',
'Volume foo_data Created'
].join('\n');
const r = parseDockerOutput(text);
expect(r.steps).toEqual([
{ action: 'Network foo_frontend', status: 'creating' },
{ action: 'Container foo-web-1', status: 'created' },
{ action: 'Volume foo_data', status: 'created' }
]);
});
test('collects error lines', () => {
const text = 'Error response from daemon: failed to set up container networking';
const r = parseDockerOutput(text);
expect(r.error).toContain('failed to set up container networking');
expect(r.parsed).toBe(true);
});
test('a warning-only output does not become an error', () => {
const line = 'time="x" level=warning msg="The \\"FOO\\" variable is not set."';
expect(parseDockerOutput(line).error).toBeNull();
});
test('empty input parses to nothing', () => {
const r = parseDockerOutput('');
expect(r.parsed).toBe(false);
expect(r.warnings).toEqual([]);
expect(r.error).toBeNull();
});
});
-529
View File
@@ -1,529 +0,0 @@
/**
* Wires the stack_deploy run recorder into `deployGitStack()` (git.ts) itself, not
* into any single HTTP route. deployGitStack is the function every git-stack deploy
* trigger EXCEPT ONE funnels through -- a config-update-then-redeploy, both webhook
* methods, create-and-deploy, and (via runGitStackSync) the cron scheduler and the
* schedules page's "run now". Wiring the recorder at a single route (the way
* compose/+server.ts does it for non-git stacks) would leave every OTHER caller
* unrecorded -- crucially the cron- and webhook-triggered ones, the truly unattended
* runs the design doc's Meilenstein 5 is about.
*
* The one exception is the UI's own "Deploy" button, which streams live progress via
* deployGitStackWithProgress() -- a SEPARATE clone/read/deploy pipeline (it does not
* call deployGitStack at all) that therefore needed, and was missing, its OWN copy of
* this same recorder wiring. Reported by a reviewer and confirmed by reading both
* functions side by side: deployGitStack's recorder wiring never touches it. See the
* second describe block below for its coverage, and the Gegenprobe block at the
* bottom for why the guard there now expects TWO createRunRecorder call sites, not
* one.
*
* `syncGitStack()` (also in git.ts) is a private collaborator of deployGitStack,
* called as a plain in-module function reference, not through an imported
* specifier -- so unlike the non-git deploy routes' $lib/server/stacks dependency,
* it CANNOT be faked via mock.module(). This suite therefore drives a REAL git
* sync against a throwaway local repository served over `git://` by a real `git
* daemon` subprocess (loopback only, ephemeral port) -- a bare filesystem path or
* `file://` URL is deliberately rejected by git-url-safety.ts (SSRF guard) and
* this test does not, and should not, work around that. Only the boundary below
* deployGitStack -- $lib/server/stacks' deployStack() -- is faked, exactly the
* boundary the compose-PUT/dedicated-deploy-route tests already fake for the
* same reason (no real `docker compose` in a unit test).
*
* $lib/server/db and $lib/server/stacks are faked via the shared helpers -- see
* their doc comments for why a second direct mock.module() call for either would
* collide with other files that already own those specifiers for this process.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, mock } from 'bun:test';
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { registerDbFake } from './helpers/db-fake';
import { registerStacksFake } from './helpers/stacks-fake';
// notifyGitSync (git.ts) wraps sendEventNotification in its own try/catch and never
// lets it affect the deploy outcome, so a no-op stub is all this suite needs -- and
// all it should assert on; which notification fired for which trigger belongs to a
// test of notifyGitSync itself, not this one. No other file in the suite mocks this
// specifier (as of this writing), so a single direct mock.module() call here is safe
// -- see tests/helpers/stacks-fake.ts's doc comment for why db/stacks can't do the same.
mock.module('$lib/server/notifications', () => ({
sendEventNotification: async () => {}
}));
// -- $lib/server/db: an in-memory schedule_executions fake, plus the git-stack -----
// -- lookups deployGitStack/syncGitStack need. Same shape as
// tests/stack-compose-redeploy-run-record.test.ts's fake for the schedule_executions
// half; the git-specific reads/writes below are new to this file.
let created: Array<Record<string, unknown>>;
let updates: Array<Record<string, unknown>>;
let nextId: number;
let gitStackUpdates: Array<Record<string, unknown>>;
let upsertedSources: Array<Record<string, unknown>>;
function resetDbState() {
created = [];
updates = [];
nextId = 1;
gitStackUpdates = [];
upsertedSources = [];
}
resetDbState();
registerDbFake('createScheduleExecution', async (data: Record<string, unknown>) => {
const row = {
id: nextId++,
...data,
triggeredAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
duration: null,
errorMessage: null,
details: data.details ?? null,
logs: null,
createdAt: null
};
created.push(row);
return row;
});
registerDbFake('updateScheduleExecution', async (id: number, data: Record<string, unknown>) => {
updates.push({ id, ...data });
return { id, ...data };
});
/** The update() call that CLOSED the row (status !== 'running'), i.e. the one written
* by DeployRunRecorder.end() -- same helper shape as the compose-redeploy suite's
* endUpdate(). Only ever at most one per test: every case here deploys exactly once. */
function endUpdate() {
return updates.find((u) => u.status !== undefined);
}
/** All stack_deploy rows created for THIS suite's stack, as opposed to the
* git_stack_sync bookkeeping row a caller like git-stack-sync.ts creates separately
* (a different scheduleType, not exercised by this file -- this suite calls
* deployGitStack directly, the layer below that bookkeeping). */
function stackDeployRows() {
return created.filter((r) => r.scheduleType === 'stack_deploy');
}
let nonSecretVars: Record<string, string>;
let secretVars: Record<string, string>;
registerDbFake('getSecretEnvVarsAsRecord', async () => secretVars);
registerDbFake('getNonSecretEnvVarsAsRecord', async () => nonSecretVars);
registerDbFake('updateGitStack', async (id: number, data: Record<string, unknown>) => {
gitStackUpdates.push({ id, ...data });
return null;
});
registerDbFake('upsertStackSource', async (data: Record<string, unknown>) => {
upsertedSources.push(data);
});
// -- $lib/server/stacks: only deployStack (and getStackDir, used for the post-
// -- success stack_sources bookkeeping) are faked -- everything ABOVE that
// -- boundary (deployGitStack, syncGitStack) runs for real.
let deployStackCalls: Array<{ onLine?: (line: string) => void }>;
let deployStackResult: { success: boolean; output?: string; error?: string; resolvedSecrets?: string[] };
let deployStackOnLines: string[];
function resetStacksState() {
deployStackCalls = [];
deployStackResult = { success: true, output: 'deployed' };
deployStackOnLines = ['Container demo-stack-app-1 Started'];
}
resetStacksState();
registerStacksFake('deployStack', async (options: { onLine?: (line: string) => void }) => {
deployStackCalls.push(options);
for (const line of deployStackOnLines) options.onLine?.(line);
return deployStackResult;
});
registerStacksFake('getStackDir', async (stackName: string) => join('/tmp/stub-stacks', stackName));
// -- A real local git remote, served over git:// by a real `git daemon` -----------
//
// DATA_DIR is process-global and read at IMPORT time by git.ts (GIT_REPOS_DIR) and
// at call time by deploy-log-store.ts -- same precaution tests/deploy-endpoints.test.ts
// and the compose-redeploy suite already document for this shared env var.
const previousDataDir = process.env.DATA_DIR;
let workDir: string;
let daemon: ChildProcess;
let daemonPort: number;
const STACK = 'demo-git-stack';
function run(cmd: string, args: string[], cwd: string): void {
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
if (result.status !== 0) {
throw new Error(`${cmd} ${args.join(' ')} failed: ${result.stderr}`);
}
}
/** Waits for the daemon to actually accept connections (spawn is async) by polling
* a real `git ls-remote` against it, rather than a fixed sleep. */
async function waitForDaemon(port: number, deadlineMs: number): Promise<void> {
const start = Date.now();
let lastError: string | undefined;
while (Date.now() - start < deadlineMs) {
const probe = spawnSync('git', ['ls-remote', `git://127.0.0.1:${port}/remote.git`], {
encoding: 'utf8',
timeout: 2000
});
if (probe.status === 0) return;
lastError = probe.stderr;
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`git daemon on port ${port} never became ready: ${lastError}`);
}
beforeAll(async () => {
workDir = await mkdtemp(join(tmpdir(), 'dv-git-deploy-run-record-'));
process.env.DATA_DIR = workDir;
// Seed the bare "remote" repo with one commit on main, containing a compose file
// at the repo-relative path the fixture GitStackWithRepo below points at.
const bareDir = join(workDir, 'remote.git');
const scratchDir = join(workDir, 'scratch');
await mkdir(bareDir, { recursive: true });
run('git', ['init', '--bare', '-b', 'main', bareDir], workDir);
run('git', ['clone', bareDir, scratchDir], workDir);
run('git', ['config', 'user.email', 'test@example.invalid'], scratchDir);
run('git', ['config', 'user.name', 'Test'], scratchDir);
await writeFile(join(scratchDir, 'compose.yaml'), 'services:\n app:\n image: demo:1\n');
run('git', ['add', 'compose.yaml'], scratchDir);
run('git', ['commit', '-m', 'initial'], scratchDir);
run('git', ['push', 'origin', 'main'], scratchDir);
daemonPort = 20000 + Math.floor(Math.random() * 20000);
daemon = spawn(
'git',
[
'daemon',
'--reuseaddr',
'--export-all',
`--base-path=${workDir}`,
`--port=${daemonPort}`,
'--listen=127.0.0.1'
],
{ stdio: 'ignore' }
);
await waitForDaemon(daemonPort, 5000);
}, 15000);
afterAll(async () => {
daemon?.kill();
await rm(workDir, { recursive: true, force: true });
if (previousDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = previousDataDir;
}
});
// -- Route/function under test, imported AFTER DATA_DIR is set and all fakes are ---
// -- registered (git.ts reads DATA_DIR at module-load time for GIT_REPOS_DIR).
const { deployGitStack, deployGitStackWithProgress } = await import('../src/lib/server/git');
const { readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store');
const { hashComposeContent } = await import('../src/lib/server/deploy-run-record-core');
let gitStackId: number;
function repoUrl(): string {
return `git://127.0.0.1:${daemonPort}/remote.git`;
}
function makeGitStack(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: gitStackId,
stackName: STACK,
environmentId: null,
repositoryId: 1,
composePath: 'compose.yaml',
branch: null,
envFilePath: null,
autoUpdate: false,
autoUpdateSchedule: 'daily' as const,
autoUpdateCron: '',
webhookEnabled: false,
webhookSecret: null,
contextDir: null,
buildOnDeploy: false,
noBuildCache: false,
repullImages: false,
forceRedeploy: false,
lastSync: null,
lastCommit: null,
syncStatus: 'idle',
syncError: null,
syncedFiles: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
repository: {
id: 1,
name: 'test-repo',
url: repoUrl(),
branch: 'main',
credentialId: null
},
...overrides
};
}
let nextGitStackId = 1;
beforeEach(async () => {
resetDbState();
resetStacksState();
// A fresh, never-before-used stack id per test -- getStackRepoPath() derives the
// on-disk clone path from it (stack-<id>, since environmentId is null), so this
// also gives every test its own clone directory. Without this, a later test could
// inherit an earlier test's already-cloned repo at the SAME commit and silently
// take the "no changes" skip path instead of the deploy path it meant to test.
gitStackId = nextGitStackId++;
nonSecretVars = { PUBLIC_VAR: 'v1' };
secretVars = { DB_PASSWORD: 's3cr3t-value' };
registerDbFake('getGitStack', async () => makeGitStack());
registerDbFake('getGitRepository', async () => makeGitStack().repository);
// nextId always restarts at 1 above, so every test's (at most one) row reuses
// runId '1' -- clear its log file so tests don't leak content into each other via
// the shared DATA_DIR (same precaution the compose-redeploy suite documents).
await deleteRunLog(null, '1');
});
describe('deployGitStack -- stack_deploy run record (git-triggered deploys)', () => {
test('a manual-triggered deploy creates exactly one stack_deploy row, closed as success, with a readable log', async () => {
const result = await deployGitStack(gitStackId, { triggeredBy: 'manual', force: true });
expect(result.success).toBe(true);
expect(deployStackCalls).toHaveLength(1); // the sync->deploy path really ran
const rows = stackDeployRows();
expect(rows).toHaveLength(1);
expect(rows[0].entityName).toBe(STACK);
expect(rows[0].triggeredBy).toBe('manual');
expect(rows[0].scheduleId).toBe(0);
const update = endUpdate();
expect(update?.status).toBe('success');
expect(update?.errorMessage).toBeNull();
const runId = String(rows[0].id);
const log = await readRunLog(null, runId);
expect(log).toContain('Container demo-stack-app-1 Started');
});
test('composeHash matches the content actually synced from the git repository', async () => {
await deployGitStack(gitStackId, { triggeredBy: 'manual', force: true });
const update = endUpdate();
const details = update?.details as { composeHash: string };
expect(details.composeHash).toBe(hashComposeContent('services:\n app:\n image: demo:1\n'));
});
test('a cron-triggered deploy (the unattended case this wiring exists for) is recorded with triggeredBy=cron', async () => {
await deployGitStack(gitStackId, { triggeredBy: 'cron', force: false });
const rows = stackDeployRows();
expect(rows).toHaveLength(1);
expect(rows[0].triggeredBy).toBe('cron');
});
test('a webhook-triggered deploy is recorded with triggeredBy=webhook and no userId', async () => {
await deployGitStack(gitStackId, { triggeredBy: 'webhook', force: false });
const rows = stackDeployRows();
expect(rows).toHaveLength(1);
expect(rows[0].triggeredBy).toBe('webhook');
const update = endUpdate();
const details = update?.details as { userId?: number };
expect(details.userId).toBeUndefined();
});
test('a secret value in the deploy error text is redacted before it is stored', async () => {
deployStackResult = { success: false, error: 'compose up failed: DB_PASSWORD=s3cr3t-value rejected' };
deployStackOnLines = [];
const result = await deployGitStack(gitStackId, { triggeredBy: 'manual', force: true });
expect(result.success).toBe(false);
const update = endUpdate();
expect(update?.status).toBe('failed');
expect(String(update?.errorMessage)).not.toContain('s3cr3t-value');
});
// F4 fix: deployStack() resolves a bound secret provider's values INTERNALLY,
// after deployGitStack()'s `recorder` above was already built from the DB-only
// nonSecretVars/secretVars this suite's fixture returns. A provider-resolved
// secret (never in that DB-only set) is simulated via the fake's
// resolvedSecrets -- exactly StackOperationResult's real shape -- and must still
// be redacted from the stored error, via recorder.addSecrets(). This is the
// unattended (cron/webhook) path deployGitStack's own doc comment calls out --
// exactly where a bound secret provider is most likely to be in play unattended.
test('F4 regression: a provider-resolved secret (unknown at recorder construction) is redacted before it is stored', async () => {
const PROVIDER_SECRET = 'zzz-bulk-provider-9000';
deployStackResult = {
success: false,
error: `compose up failed: PROVIDER_TOKEN=${PROVIDER_SECRET} rejected`,
resolvedSecrets: [PROVIDER_SECRET]
};
deployStackOnLines = [];
const result = await deployGitStack(gitStackId, { triggeredBy: 'cron', force: true });
expect(result.success).toBe(false);
const update = endUpdate();
expect(update?.status).toBe('failed');
expect(String(update?.errorMessage)).not.toContain(PROVIDER_SECRET);
});
test('a second, unchanged sync (force=false, nothing to deploy) creates NO additional stack_deploy row', async () => {
// First call: the clone directory for this test's stack id does not exist yet,
// so getPreviousCommit() (git.ts) returns null -> commitChanged is always true
// on a first sync -> updated=true -> shouldDeploy=true even with force:false.
// This deploys for real and records exactly one row, same as the tests above.
const first = await deployGitStack(gitStackId, { triggeredBy: 'cron', force: false });
expect(first.success).toBe(true);
expect(first.skipped).toBeUndefined();
expect(stackDeployRows()).toHaveLength(1);
// Second call, same stack id, remote repository untouched in between:
// syncGitStack reads the ACTUAL previous commit off the clone directory the
// first call just left on disk (real git, not a fake), re-clones, and finds
// the same commit -> commitChanged=false -> updated=false -> with force:false
// and this fixture's forceRedeploy:false, shouldDeploy is false -> skip. The
// recorder-creation block in git.ts sits AFTER that `if (!shouldDeploy) return`
// branch, so a skip must create no new row at all.
const second = await deployGitStack(gitStackId, { triggeredBy: 'cron', force: false });
expect(second.success).toBe(true);
expect(second.skipped).toBe(true);
expect(deployStackCalls).toHaveLength(1); // still just the first call's deploy
expect(stackDeployRows()).toHaveLength(1); // no row added for the skipped second call
});
});
describe("deployGitStackWithProgress -- stack_deploy run record (the UI 'Deploy' button)", () => {
// deployGitStackWithProgress runs its OWN clone/read/deploy pipeline (it does not
// call deployGitStack), reporting 5 discrete stages via onProgress rather than
// deployGitStack's onLine -- see its doc comment in git.ts. It was, until this
// fix, the one caller of deployStack() that never went through createRunRecorder
// at all: every test below is red without the fix (verified by reverting it and
// re-running this file), because no stack_deploy row is created in the first
// place.
test('a manual UI deploy creates exactly one stack_deploy row, closed as success, with a readable log', async () => {
const progressEvents: Array<{ status: string; message?: string }> = [];
const result = await deployGitStackWithProgress(gitStackId, (data) => progressEvents.push(data));
expect(result.success).toBe(true);
expect(deployStackCalls).toHaveLength(1); // the clone->deploy path really ran
const rows = stackDeployRows();
expect(rows).toHaveLength(1);
expect(rows[0].entityName).toBe(STACK);
expect(rows[0].triggeredBy).toBe('manual');
expect(rows[0].scheduleId).toBe(0);
const update = endUpdate();
expect(update?.status).toBe('success');
expect(update?.errorMessage).toBeNull();
const runId = String(rows[0].id);
const log = await readRunLog(null, runId);
expect(log).toContain('Container demo-stack-app-1 Started');
// onLine mirrors deploy output into the run record's log file WITHOUT being
// forwarded to onProgress -- that callback's own contract is the 5 discrete
// stage messages this function sends itself, not raw compose output lines.
expect(progressEvents.some((e) => e.message?.includes('Container demo-stack-app-1'))).toBe(false);
expect(progressEvents.some((e) => e.status === 'complete')).toBe(true);
});
test('composeHash matches the content actually read from the cloned repository', async () => {
await deployGitStackWithProgress(gitStackId, () => {});
const update = endUpdate();
const details = update?.details as { composeHash: string };
expect(details.composeHash).toBe(hashComposeContent('services:\n app:\n image: demo:1\n'));
});
test('a secret value in the deploy error text is redacted before it is stored', async () => {
deployStackResult = { success: false, error: 'compose up failed: DB_PASSWORD=s3cr3t-value rejected' };
deployStackOnLines = [];
const result = await deployGitStackWithProgress(gitStackId, () => {});
expect(result.success).toBe(false);
const update = endUpdate();
expect(update?.status).toBe('failed');
expect(String(update?.errorMessage)).not.toContain('s3cr3t-value');
});
// F4 fix, same rationale as deployGitStack's own regression test above: a
// provider-resolved secret is only known AFTER deployStack() returns, so it must
// be fed into the recorder via addSecrets() before end() closes the row, or it
// bypasses redaction entirely.
test('F4 regression: a provider-resolved secret (unknown at recorder construction) is redacted before it is stored', async () => {
const PROVIDER_SECRET = 'zzz-bulk-provider-9000';
deployStackResult = {
success: false,
error: `compose up failed: PROVIDER_TOKEN=${PROVIDER_SECRET} rejected`,
resolvedSecrets: [PROVIDER_SECRET]
};
deployStackOnLines = [];
const result = await deployGitStackWithProgress(gitStackId, () => {});
expect(result.success).toBe(false);
const update = endUpdate();
expect(update?.status).toBe('failed');
expect(String(update?.errorMessage)).not.toContain(PROVIDER_SECRET);
});
});
describe('deployGitStack / deployGitStackWithProgress -- Gegenprobe: removing either recorder wiring turns its own describe block above red', () => {
async function readGitTsSource(): Promise<string> {
const { readFile } = await import('node:fs/promises');
const { dirname } = await import('node:path');
const { fileURLToPath } = await import('node:url');
const here = dirname(fileURLToPath(import.meta.url));
return readFile(join(here, '..', 'src', 'lib', 'server', 'git.ts'), 'utf8');
}
/**
* Isolates one exported function's own body: from its exact `export async
* function NAME(` signature up to (not including) the next top-level `export `.
* Good enough for these two specific, non-nested, sequentially-declared
* functions in this one file -- NOT a general-purpose brace matcher, and this
* guard cannot tell WHICH call site broke if the slicing itself were ever wrong.
* The behavioral tests in both describe blocks above are what actually prove
* each function records a run; this is a second, cheap line of defense on top
* of them, not a replacement for them.
*/
function functionBody(source: string, name: string): string {
const signature = `export async function ${name}(`;
const start = source.indexOf(signature);
if (start === -1) throw new Error(`function ${name} not found in git.ts`);
const nextExport = source.indexOf('\nexport ', start + signature.length);
return nextExport === -1 ? source.slice(start) : source.slice(start, nextExport);
}
test('deployGitStack calls createRunRecorder', async () => {
const body = functionBody(await readGitTsSource(), 'deployGitStack');
expect(body.match(/createRunRecorder\(/g) ?? []).toHaveLength(1);
});
test('deployGitStackWithProgress ALSO calls createRunRecorder -- this is the exact gap Finding 2 closes', async () => {
const body = functionBody(await readGitTsSource(), 'deployGitStackWithProgress');
expect(body.match(/createRunRecorder\(/g) ?? []).toHaveLength(1);
});
test('exactly two createRunRecorder call sites in the whole file, one per function', async () => {
const matches = (await readGitTsSource()).match(/createRunRecorder\(/g) ?? [];
expect(matches).toHaveLength(2);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test';
import { wrapHtmlLines, stripLogMarkers, GIT_LINE_MARKER } from '../src/lib/utils/log-lines';
describe('wrapHtmlLines', () => {
test('wraps each line in a log-line div', () => {
const out = wrapHtmlLines('a\nb');
expect(out).toBe('<div class="log-line">a</div><div class="log-line">b</div>');
});
test('renders an empty line as a non-collapsing space', () => {
expect(wrapHtmlLines('')).toBe('<div class="log-line"> </div>');
});
test('a git-marked line drops the marker and gets the git class + inline svg', () => {
const out = wrapHtmlLines(`${GIT_LINE_MARKER}Cloning repository...`);
expect(out).toContain('log-line-git');
expect(out).toContain('<svg');
expect(out).toContain('Cloning repository...');
// The literal marker text must not survive into the output.
expect(out).not.toContain(GIT_LINE_MARKER);
});
test('a plain line is untouched (no git class, no svg)', () => {
const out = wrapHtmlLines('Container web-1 Started');
expect(out).not.toContain('log-line-git');
expect(out).not.toContain('<svg');
});
test('stripLogMarkers removes the marker for copy/download', () => {
const input = `${GIT_LINE_MARKER}Cloning repository...\n Container web-1 Started\n${GIT_LINE_MARKER}Deploying...`;
expect(stripLogMarkers(input)).toBe('Cloning repository...\n Container web-1 Started\nDeploying...');
});
test('stripLogMarkers leaves marker-less text untouched', () => {
const input = ' Container web-1 Started\nError response from daemon: boom';
expect(stripLogMarkers(input)).toBe(input);
});
test('mixes git and plain lines independently', () => {
const out = wrapHtmlLines(`${GIT_LINE_MARKER}Fetching\nContainer web-1 Started`);
const parts = out.split('</div>').filter(Boolean);
expect(parts[0]).toContain('log-line-git');
expect(parts[1]).toContain('log-line');
expect(parts[1]).not.toContain('log-line-git');
});
});
@@ -0,0 +1,85 @@
import { describe, test, expect } from 'bun:test';
import {
resourceForScheduleType,
viewableScheduleTypes
} from '../src/lib/server/schedule-execution-access-core';
// Mirror of ALL_SCHEDULE_TYPES (db.ts) -- kept local so this stays a pure test
// with no DB import. If the union grows, the db.ts exhaustiveness guard catches it.
const ALL = [
'container_update',
'git_stack_sync',
'system_cleanup',
'env_update_check',
'image_prune',
'backup',
'restore',
'stack_deploy',
'deploy_log_reconcile'
];
describe('resourceForScheduleType', () => {
test('stack deploy + git sync map to stacks', () => {
expect(resourceForScheduleType('stack_deploy')).toBe('stacks');
expect(resourceForScheduleType('git_stack_sync')).toBe('stacks');
});
test('backup + restore map to backups', () => {
expect(resourceForScheduleType('backup')).toBe('backups');
expect(resourceForScheduleType('restore')).toBe('backups');
});
test('other known types map to schedules', () => {
for (const t of [
'container_update',
'env_update_check',
'image_prune',
'system_cleanup',
'deploy_log_reconcile'
]) {
expect(resourceForScheduleType(t)).toBe('schedules');
}
});
test('an unknown/new type falls back to schedules (never a weaker resource)', () => {
expect(resourceForScheduleType('something_new')).toBe('schedules');
expect(resourceForScheduleType('')).toBe('schedules');
});
});
describe('viewableScheduleTypes', () => {
test('all three resources viewable -> null (no filter needed)', () => {
expect(viewableScheduleTypes({ schedules: true, stacks: true, backups: true }, ALL)).toBeNull();
});
test('schedules only -> excludes stack_deploy/git_stack_sync/backup/restore', () => {
const allowed = viewableScheduleTypes({ schedules: true, stacks: false, backups: false }, ALL)!;
expect(allowed).toContain('container_update');
expect(allowed).toContain('system_cleanup');
expect(allowed).not.toContain('stack_deploy');
expect(allowed).not.toContain('git_stack_sync');
expect(allowed).not.toContain('backup');
expect(allowed).not.toContain('restore');
});
test('stacks only -> only stack types (never generic/backup)', () => {
const allowed = viewableScheduleTypes({ schedules: false, stacks: true, backups: false }, ALL)!;
expect(allowed).toEqual(['git_stack_sync', 'stack_deploy']);
});
test('backups only -> only backup types', () => {
const allowed = viewableScheduleTypes({ schedules: false, stacks: false, backups: true }, ALL)!;
expect(allowed.sort()).toEqual(['backup', 'restore']);
});
test('no resource viewable -> empty allow-list (caller sees nothing)', () => {
expect(viewableScheduleTypes({ schedules: false, stacks: false, backups: false }, ALL)).toEqual([]);
});
test('schedules + backups (not stacks) -> generic + backup, no stack_deploy', () => {
const allowed = viewableScheduleTypes({ schedules: true, stacks: false, backups: true }, ALL)!;
expect(allowed).toContain('container_update');
expect(allowed).toContain('backup');
expect(allowed).not.toContain('stack_deploy');
});
});
+57
View File
@@ -1,6 +1,7 @@
// tests/sse-run-recorder.test.ts
import { describe, expect, test } from 'bun:test';
import { createJobResponse, type RunRecorder } from '../src/lib/server/sse';
import { getJob } from '../src/lib/server/jobs';
/** A fake RunRecorder that records every line() call and resolves `ended` when end() fires. */
function createFakeRecorder() {
@@ -18,6 +19,7 @@ function createFakeRecorder() {
// line()/end() plumbing, not the F4 addSecrets() wiring (see
// deploy-run-record.test.ts and the route-level regression tests for that).
},
setContentHashes() {},
async end(ok: boolean, exitCode?: number, error?: string) {
resolveEnded({ ok, exitCode, error });
}
@@ -157,6 +159,7 @@ describe('createJobResponse RunRecorder hook', () => {
const recorder: RunRecorder = {
line() {},
addSecrets() {},
setContentHashes() {},
async end() {
calls++;
throw new Error('recorder failure');
@@ -179,6 +182,60 @@ describe('createJobResponse RunRecorder hook', () => {
expect(calls).toBe(1);
});
test('JSON path: a recorder.end() that throws does NOT break the response body', async () => {
// The recorder is a best-effort side effect (writes the run log); a failure to
// close it must never prevent the client from getting its JSON result.
const recorder: RunRecorder = {
line() {},
addSecrets() {},
setContentHashes() {},
async end() {
throw new Error('log write failed');
}
};
const request = new Request('http://x', { headers: { Accept: 'application/json' } });
const response = createJobResponse(
async (send) => {
send('result', { success: true, output: 'ok' });
},
request,
recorder
);
const body = await response.json();
expect(body).toEqual({ success: true, output: 'ok' });
});
test('streaming path: a recorder.end() that throws does NOT flip a successful job to failed', async () => {
// A throwing end() in the .then() branch must not reach .catch() (which would
// failJob a run that actually succeeded) -- end() is best-effort. Asserting the
// job STATUS (not just the end() call count) is what distinguishes the fix: the
// pre-existing recorderEnded guard already kept calls===1, but without the
// try/catch the throw propagated to .catch() and failJob flipped 'done' -> 'error'.
const recorder: RunRecorder = {
line() {},
addSecrets() {},
setContentHashes() {},
async end(ok: boolean) {
if (ok) throw new Error('log write failed'); // only the success close throws
}
};
const request = new Request('http://x'); // fire-and-forget path
const response = createJobResponse(
async (send) => {
send('result', { success: true, output: 'done' });
},
request,
recorder
);
const { jobId } = await response.json();
await new Promise((resolve) => setTimeout(resolve, 20));
const job = getJob(jobId);
expect(job?.status).toBe('done');
const result = job?.lines.findLast((l) => l.event === 'result')?.data as { success?: boolean } | undefined;
expect(result?.success).toBe(true);
});
test('omitting the recorder changes nothing (backward compat)', async () => {
const request = new Request('http://x', { headers: { Accept: 'application/json' } });
const response = createJobResponse(runOperation, request);
+40
View File
@@ -1,7 +1,20 @@
import { describe, test, expect } from 'bun:test';
import { spawn } from 'child_process';
import { EventEmitter } from 'node:events';
import { collectProcess } from '../src/lib/server/process-output-core';
/** A minimal ChildProcess stand-in that lets a test drive the EXACT chunk
* boundaries and stdout/stderr interleaving a real spawn can't guarantee. */
function fakeProc() {
const proc = new EventEmitter() as any;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.out = (s: string) => proc.stdout.emit('data', Buffer.from(s));
proc.err = (s: string) => proc.stderr.emit('data', Buffer.from(s));
proc.close = (code = 0) => proc.emit('close', code);
return proc;
}
describe('collectProcess with a line callback', () => {
test('emits each line separately and in order', async () => {
const lines: string[] = [];
@@ -22,4 +35,31 @@ describe('collectProcess with a line callback', () => {
const result = await collectProcess(child);
expect(result.stderr).toContain('unveraendert');
});
test('does NOT splice a partial stdout line onto an interleaved stderr chunk', async () => {
// stdout emits a partial line (no newline), stderr chunks arrive before the
// stdout line is completed. With a shared buffer these would concatenate into
// one corrupted line ("stdout-partSTDERR ..."); with per-stream buffers they
// stay separate.
const proc = fakeProc();
const lines: string[] = [];
const done = collectProcess(proc, (line) => lines.push(line));
proc.out('stdout-part'); // partial, no '\n'
proc.err('an error line\n'); // complete stderr line arrives mid-stdout-line
proc.out(' rest\n'); // completes the stdout line
proc.close(0);
await done;
expect(lines).toEqual(['an error line', 'stdout-part rest']);
});
test('flushes each stream trailing partial separately (stdout before stderr)', async () => {
const proc = fakeProc();
const lines: string[] = [];
const done = collectProcess(proc, (line) => lines.push(line));
proc.out('out-tail'); // partial stdout, never newline-terminated
proc.err('err-tail'); // partial stderr, never newline-terminated
proc.close(0);
await done;
expect(lines).toEqual(['out-tail', 'err-tail']);
});
});