Release ForgeFlow 0.8.1
Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readAcceptanceConfig } from '../scripts/acceptance.mjs';
|
||||
|
||||
test('acceptance harness requires an explicit complete environment', () => {
|
||||
assert.throws(() => readAcceptanceConfig({}), /Missing acceptance environment variables/);
|
||||
const config = readAcceptanceConfig({ FORGEFLOW_GITEA_URL: 'https://gitea.test/', FORGEFLOW_GITEA_TOKEN: 'token', FORGEFLOW_REPOSITORY: 'owner/app', FORGEFLOW_LOCAL_PATH: 'C:/Projects/App', FORGEFLOW_BRANCH: 'main', FORGEFLOW_STATUS_URL: 'https://app.test/status', FORGEFLOW_HEALTH_URL: 'https://app.test/health' });
|
||||
assert.equal(config.baseUrl, 'https://gitea.test');
|
||||
assert.equal(config.workflow, 'deploy.yml');
|
||||
assert.throws(() => readAcceptanceConfig({ ...process.env, FORGEFLOW_GITEA_URL: 'x', FORGEFLOW_GITEA_TOKEN: 'x', FORGEFLOW_REPOSITORY: 'invalid', FORGEFLOW_LOCAL_PATH: 'x', FORGEFLOW_BRANCH: 'x', FORGEFLOW_STATUS_URL: 'x', FORGEFLOW_HEALTH_URL: 'x' }), /owner\/repository/);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import auditModule from '../src/main/audit-service.cjs';
|
||||
|
||||
const { AuditService } = auditModule;
|
||||
|
||||
test('audit service appends ordered records and exports CSV', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-audit-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const audit = new AuditService({ userDataPath: root, appInfo: { version: 'test' } });
|
||||
await Promise.all([
|
||||
audit.append('deployment.requested', { repository: 'owner/app', sha: 'a'.repeat(40), note: 'Release, wave 1' }),
|
||||
audit.append('deployment.completed', { repository: 'owner/app', result: 'success' })
|
||||
]);
|
||||
const entries = await audit.list();
|
||||
assert.equal(entries.length, 2);
|
||||
assert.equal(entries[0].event, 'deployment.completed');
|
||||
const destination = path.join(root, 'audit.csv');
|
||||
const result = await audit.exportTo(destination, 'csv');
|
||||
assert.equal(result.count, 2);
|
||||
assert.match(await fs.readFile(destination, 'utf8'), /"Release, wave 1"/);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import backupModule from '../src/main/configuration-backup.cjs';
|
||||
|
||||
const { sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup } = backupModule;
|
||||
|
||||
test('configuration backups exclude credentials and operation history', () => {
|
||||
const clean = sanitizeConfiguration({
|
||||
gitea: { baseUrl: 'https://gitea.test', encryptedToken: 'secret-token' },
|
||||
servers: [{ id: 'server', host: 'unraid.test', encryptedPassword: 'password', encryptedPassphrase: 'passphrase' }],
|
||||
operations: [{ id: 'operation', sha: 'a'.repeat(40) }],
|
||||
preferences: { autoRefresh: true }
|
||||
});
|
||||
assert.equal(clean.gitea.encryptedToken, null);
|
||||
assert.equal('encryptedPassword' in clean.servers[0], false);
|
||||
assert.equal('encryptedPassphrase' in clean.servers[0], false);
|
||||
assert.deepEqual(clean.operations, []);
|
||||
});
|
||||
|
||||
test('configuration backups round-trip with authenticated encryption', () => {
|
||||
const serialized = createEncryptedBackup({ workspaceRoots: ['C:/Projects'], gitea: { encryptedToken: 'secret' } }, 'correct horse battery staple');
|
||||
assert.doesNotMatch(serialized, /C:\/Projects|secret/);
|
||||
const restored = readEncryptedBackup(serialized, 'correct horse battery staple');
|
||||
assert.deepEqual(restored.configuration.workspaceRoots, ['C:/Projects']);
|
||||
assert.equal(restored.configuration.gitea.encryptedToken, null);
|
||||
assert.throws(() => readEncryptedBackup(serialized, 'incorrect passphrase'), /could not be decrypted/i);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import policyModule from '../src/shared/deployment-policy.cjs';
|
||||
|
||||
const { evaluateDeploymentPolicy } = policyModule;
|
||||
|
||||
test('deployment freeze and maintenance windows fail closed with reasoned overrides', () => {
|
||||
const profile = { deploymentPolicy: { frozen: true, freezeReason: 'Incident', requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } };
|
||||
const now = new Date(2026, 6, 28, 12, 0); // Tuesday
|
||||
assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release' }), (error) => error.code === 'DEPLOYMENT_POLICY_BLOCKED');
|
||||
assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true }), /override reason/i);
|
||||
const result = evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true, reason: 'Emergency recovery' });
|
||||
assert.equal(result.overridden, true);
|
||||
assert.equal(result.violations.length, 2);
|
||||
});
|
||||
|
||||
test('deployment policy accepts an in-window release with required note', () => {
|
||||
const now = new Date(2026, 6, 27, 9, 30); // Monday
|
||||
const profile = { deploymentPolicy: { requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } };
|
||||
assert.equal(evaluateDeploymentPolicy(profile, { now, note: 'Version 1.2' }).allowed, true);
|
||||
assert.throws(() => evaluateDeploymentPolicy(profile, { now }), /release note/i);
|
||||
});
|
||||
|
||||
test('overnight maintenance windows continue into the following day', () => {
|
||||
const profile = { deploymentPolicy: { maintenanceWindows: [{ days: [1], start: '22:00', end: '02:00' }] } };
|
||||
assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 27, 23, 0) }).allowed, true);
|
||||
assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 1, 0) }).allowed, true);
|
||||
assert.throws(() => evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 3, 0) }), /outside/);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import toolsModule from '../src/main/external-tools-service.cjs';
|
||||
|
||||
const { normalizeTool, expandTool } = toolsModule;
|
||||
|
||||
test('external tool templates expand as argument arrays without a shell', () => {
|
||||
const tool = normalizeTool({ executable: 'code.exe', args: ['--goto', '{file}:{line}', '{path}'] }, {});
|
||||
const invocation = expandTool(tool, { path: 'C:\\Projects\\App', file: 'C:\\Projects\\App\\src\\app.js', line: 12 });
|
||||
assert.equal(invocation.executable, 'code.exe');
|
||||
assert.deepEqual(invocation.args, ['--goto', 'C:\\Projects\\App\\src\\app.js:12', 'C:\\Projects\\App']);
|
||||
assert.throws(() => normalizeTool({ executable: 'code.exe\ncalc.exe', args: [] }, {}), /invalid/);
|
||||
});
|
||||
@@ -243,3 +243,30 @@ test('repairs a diverged branch by creating a safety branch before resetting to
|
||||
const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim();
|
||||
assert.equal(backupSha, localBefore);
|
||||
});
|
||||
|
||||
test('troubleshooter detects and aborts an interrupted merge without discarding committed history', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-interrupted-merge-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await git(['init'], root);
|
||||
await git(['config', 'user.name', 'ForgeFlow Test'], root);
|
||||
await git(['config', 'user.email', 'forgeflow@example.invalid'], root);
|
||||
await fs.writeFile(path.join(root, 'file.txt'), 'base\n');
|
||||
await git(['add', '.'], root);
|
||||
await git(['commit', '-m', 'Base'], root);
|
||||
await git(['checkout', '-b', 'other'], root);
|
||||
await fs.writeFile(path.join(root, 'file.txt'), 'other\n');
|
||||
await git(['commit', '-am', 'Other'], root);
|
||||
await git(['checkout', 'master'], root);
|
||||
await fs.writeFile(path.join(root, 'file.txt'), 'main\n');
|
||||
await git(['commit', '-am', 'Main'], root);
|
||||
await assert.rejects(git(['merge', 'other'], root));
|
||||
|
||||
const service = new GitService();
|
||||
assert.equal(await service.detectInterruptedOperation(root), 'merge');
|
||||
const result = await service.abortInterruptedOperation(root);
|
||||
assert.equal(result.aborted, 'merge');
|
||||
assert.equal(await service.detectInterruptedOperation(root), null);
|
||||
assert.equal(result.status.clean, true);
|
||||
const subject = await git(['log', '-1', '--pretty=%s'], root);
|
||||
assert.equal(subject.stdout.trim(), 'Main');
|
||||
});
|
||||
|
||||
@@ -88,3 +88,22 @@ test('checks repository workflow files through the contents API', async () => {
|
||||
service.request = async () => { const error = new Error('missing'); error.status = 404; throw error; };
|
||||
assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/missing.yml', ref: 'main' }), false);
|
||||
});
|
||||
|
||||
test('creates controlled pull requests and reads branch protection', async () => {
|
||||
const service = new GiteaService(makeStore());
|
||||
const calls = [];
|
||||
service.request = async (pathname, options = {}) => {
|
||||
calls.push({ pathname, options });
|
||||
if (pathname.includes('/branches/main')) return { data: { name: 'main', protected: true } };
|
||||
if (pathname.endsWith('/branch_protections')) return { data: [{ branch_name: 'main', required_approvals: 2, require_signed_commits: true }] };
|
||||
return { data: { number: 12, html_url: 'https://gitea.test/owner/app/pulls/12' } };
|
||||
};
|
||||
const protection = await service.getBranchProtection('owner', 'app', 'main');
|
||||
assert.equal(protection.protected, true);
|
||||
assert.equal(protection.requiredApprovals, 2);
|
||||
const pull = await service.createPullRequest({ owner: 'owner', repo: 'app', head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
|
||||
assert.equal(pull.number, 12);
|
||||
const create = calls.find((call) => call.options.method === 'POST');
|
||||
assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
|
||||
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
test("every preload invoke channel has a registered IPC handler", async () => {
|
||||
const preload = await readFile(
|
||||
new URL("../preload.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ipc = await readFile(
|
||||
new URL("../src/main/ipc.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const invokes = [...preload.matchAll(/invoke\(\s*['"]([^'"]+)['"]/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
const handlers = new Set(
|
||||
[...ipc.matchAll(/register\(\s*['"]([^'"]+)['"]/g)].map(
|
||||
(match) => match[1],
|
||||
),
|
||||
);
|
||||
assert.ok(invokes.length > 40, "expected the complete renderer API surface");
|
||||
assert.deepEqual(
|
||||
invokes.filter((channel) => !handlers.has(channel)),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("every renderer bridge call is exposed by the preload contract", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const preload = await readFile(
|
||||
new URL("../preload.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const calls = new Set(
|
||||
[...renderer.matchAll(/window\.forgeflow\.([A-Za-z0-9_]+)\s*\(/g)].map(
|
||||
(match) => match[1],
|
||||
),
|
||||
);
|
||||
const exposed = new Set(
|
||||
[...preload.matchAll(/^\s{2}([A-Za-z0-9_]+):/gm)].map((match) => match[1]),
|
||||
);
|
||||
assert.ok(calls.size > 40, "expected the complete renderer bridge surface");
|
||||
assert.deepEqual(
|
||||
[...calls].filter((method) => !exposed.has(method)),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import gitModule from '../src/main/git-service.cjs';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const git = (args, cwd) => exec('git', args, { cwd, encoding: 'utf8' });
|
||||
const { GitService, parseUnifiedDiff } = gitModule;
|
||||
|
||||
test('unified diff parser separates selectable hunks', () => {
|
||||
const parsed = parseUnifiedDiff('diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new\n@@ -10 +10 @@\n-x\n+y\n');
|
||||
assert.equal(parsed.hunks.length, 2);
|
||||
assert.equal(parsed.hunks[0].additions, 1);
|
||||
assert.equal(parsed.hunks[1].deletions, 1);
|
||||
});
|
||||
|
||||
test('stages only selected hunks using a server-generated patch', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-hunks-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await git(['init'], root);
|
||||
await git(['config', 'user.name', 'ForgeFlow Test'], root);
|
||||
await git(['config', 'user.email', 'forgeflow@example.invalid'], root);
|
||||
const original = [...Array(20)].map((_, index) => `line ${index + 1}`).join('\n') + '\n';
|
||||
await fs.writeFile(path.join(root, 'file.txt'), original);
|
||||
await git(['add', '.'], root); await git(['commit', '-m', 'Initial'], root);
|
||||
const lines = original.trimEnd().split('\n'); lines[0] = 'first changed'; lines[19] = 'last changed';
|
||||
await fs.writeFile(path.join(root, 'file.txt'), `${lines.join('\n')}\n`);
|
||||
const service = new GitService();
|
||||
const hunks = await service.diffHunks(root, 'file.txt');
|
||||
assert.equal(hunks.hunks.length, 2);
|
||||
await service.stageHunks(root, 'file.txt', [0]);
|
||||
const staged = (await git(['diff', '--cached'], root)).stdout;
|
||||
const unstaged = (await git(['diff'], root)).stdout;
|
||||
assert.match(staged, /first changed/); assert.doesNotMatch(staged, /last changed/);
|
||||
assert.match(unstaged, /last changed/); assert.doesNotMatch(unstaged, /first changed/);
|
||||
await service.commitStaged(root, 'Commit reviewed hunk');
|
||||
const afterCommit = (await git(['diff'], root)).stdout;
|
||||
assert.match(afterCommit, /last changed/);
|
||||
assert.doesNotMatch(afterCommit, /first changed/);
|
||||
});
|
||||
@@ -1,46 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
test('changed file list has an independently scrollable bounded layout', async () => {
|
||||
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/);
|
||||
assert.match(css, /\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/);
|
||||
assert.match(css, /\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/);
|
||||
test("changed file list has an independently scrollable bounded layout", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/renderer/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/,
|
||||
);
|
||||
});
|
||||
|
||||
test('commit workflow explains every disabled prerequisite', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("commit workflow explains every disabled prerequisite", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /Commit message <span class="required-mark">required/);
|
||||
assert.match(renderer, /Enter a commit message to enable commit and push/);
|
||||
assert.match(renderer, /ForgeFlow stages the selected files automatically/);
|
||||
assert.match(renderer, /Commit selected & push to Gitea/);
|
||||
assert.match(renderer, /data-action="commit-push"/);
|
||||
assert.match(renderer, /Commit staged hunks/);
|
||||
});
|
||||
|
||||
test('ITWorx branding is integrated into titlebar and setup', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("ITWorx branding is integrated into titlebar and setup", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /itworx-mark\.png/);
|
||||
assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/);
|
||||
});
|
||||
|
||||
|
||||
test('all modal content stays inside the viewport with a persistent action footer', async () => {
|
||||
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/);
|
||||
assert.match(css, /\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/);
|
||||
test("all modal content stays inside the viewport with a persistent action footer", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/renderer/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/,
|
||||
);
|
||||
assert.match(css, /\.modal-footer\s*\{[^}]*flex:\s*0 0 auto/);
|
||||
});
|
||||
|
||||
test('settings provides one-click normalization for legacy Gitea origins', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("settings provides one-click normalization for legacy Gitea origins", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /data-action="normalize-origins"/);
|
||||
assert.match(renderer, /Normalize all origins/);
|
||||
});
|
||||
|
||||
|
||||
test('Git mutations are serialized per repository and expose repair actions', async () => {
|
||||
const ipc = await readFile(new URL('../src/main/ipc.cjs', import.meta.url), 'utf8');
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("Git mutations are serialized per repository and expose repair actions", async () => {
|
||||
const ipc = await readFile(
|
||||
new URL("../src/main/ipc.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(ipc, /repositoryMutations = new Map/);
|
||||
assert.match(ipc, /withRepositoryMutation/);
|
||||
assert.match(ipc, /GIT_LOCKS_RECENT/);
|
||||
@@ -51,36 +86,122 @@ test('Git mutations are serialized per repository and expose repair actions', as
|
||||
assert.match(renderer, /Open guided repository repair/);
|
||||
});
|
||||
|
||||
|
||||
test('SSH secrets are captured before the loading render clears password inputs', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
const passwordCapture = renderer.indexOf("const password = document.querySelector('#server-password')");
|
||||
const loading = renderer.indexOf("setLoading(true, 'Saving encrypted SSH configuration…')");
|
||||
test("SSH secrets are captured before the loading render clears password inputs", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const passwordCapture = renderer.search(
|
||||
/const password = document\.querySelector\(["']#server-password["']\)/,
|
||||
);
|
||||
const loading = renderer.search(
|
||||
/setLoading\(true, ["']Saving encrypted SSH configuration/,
|
||||
);
|
||||
assert.ok(passwordCapture >= 0 && loading > passwordCapture);
|
||||
});
|
||||
|
||||
test('SSH deployments are polled in the background and Portfolio casing is preserved', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("SSH deployments are polled in the background and Portfolio casing is preserved", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /function startOperationPolling\(\)/);
|
||||
assert.match(renderer, /startOperationPolling\(\);/);
|
||||
assert.match(renderer, /Visible container name/);
|
||||
assert.match(renderer, /Compose service \(internal\)/);
|
||||
});
|
||||
|
||||
|
||||
test('deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /Built-in high-contrast ITWorx mark/);
|
||||
assert.match(renderer, /profile-icon-mode/);
|
||||
assert.match(renderer, /Repair DockerMan integration/);
|
||||
assert.match(renderer, /reconcile-deployment/);
|
||||
});
|
||||
|
||||
test('repository troubleshooting offers personalized synchronization repair actions', async () => {
|
||||
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
|
||||
const ipc = await readFile(new URL('../src/main/ipc.cjs', import.meta.url), 'utf8');
|
||||
test("repository troubleshooting offers personalized synchronization repair actions", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ipc = await readFile(
|
||||
new URL("../src/main/ipc.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(renderer, /repair-repository-sync/);
|
||||
assert.match(renderer, /safety branch/);
|
||||
assert.match(ipc, /repository:repair-sync/);
|
||||
});
|
||||
|
||||
test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const preload = await readFile(
|
||||
new URL("../preload.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
for (const phrase of [
|
||||
"Stage hunks",
|
||||
"Conflict guide",
|
||||
"Create pull request",
|
||||
"Open pull requests",
|
||||
"load-pull-requests",
|
||||
"Check branch protection",
|
||||
"Encrypted configuration backup",
|
||||
"Deployment policy",
|
||||
"Operational audit log",
|
||||
])
|
||||
assert.match(renderer, new RegExp(phrase, "i"));
|
||||
for (const method of [
|
||||
"stageHunks",
|
||||
"resolveConflict",
|
||||
"createPullRequest",
|
||||
"branchProtection",
|
||||
"openEditor",
|
||||
"openTerminal",
|
||||
"exportConfigurationBackup",
|
||||
"listAuditEvents",
|
||||
])
|
||||
assert.match(preload, new RegExp(`${method}:`));
|
||||
});
|
||||
|
||||
test("one-click troubleshooting excludes destructive or publishing Git actions", async () => {
|
||||
const renderer = await readFile(
|
||||
new URL("../src/renderer/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ipc = await readFile(
|
||||
new URL("../src/main/ipc.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(ipc, /action: 'abort-operation', safe: false/);
|
||||
assert.match(ipc, /action: 'push', safe: false/);
|
||||
assert.match(ipc, /const stale = lock\.ageMs >= 10_000/);
|
||||
assert.match(ipc, /\['fast-forward', 'fetch'\]\.includes\(issue\.action\)/);
|
||||
assert.doesNotMatch(
|
||||
ipc,
|
||||
/\['fast-forward', 'push', 'fetch'\]\.includes\(issue\.action\)/,
|
||||
);
|
||||
assert.match(
|
||||
renderer,
|
||||
/trouble\?\.issues\?\.some\(\(item\) => item\.repairable && item\.safe\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("premium repository workspace reserves separate rows for actions and release status", async () => {
|
||||
const styles = await readFile(
|
||||
new URL("../src/renderer/styles.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.repo-workspace\s*\{[^}]*grid-template-rows:\s*auto auto auto 39px minmax\(0, 1fr\)/s,
|
||||
);
|
||||
assert.match(styles, /prefers-reduced-motion/);
|
||||
assert.match(styles, /ForgeFlow 0\.8 premium visual system/);
|
||||
});
|
||||
|
||||
@@ -265,3 +265,39 @@ test('stuck deployment is cleared as superseded when a different healthy commit
|
||||
assert.match(result.error, /Superseded/);
|
||||
assert.equal(saved.at(-1).status, 'cancelled');
|
||||
});
|
||||
|
||||
test('existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth', () => {
|
||||
const { deriveDetectedProfile } = require('../src/main/unraid-deployment-service.cjs');
|
||||
const result = deriveDetectedProfile({
|
||||
repository: { name: 'blockpilot-autonomous', defaultBranch: 'main', sshUrl: 'ssh://git@gitea/Jens/blockpilot-autonomous.git' },
|
||||
server: { id: 'unraid', host: '192.168.10.150' },
|
||||
remoteFolder: 'blockpilot-autonomous',
|
||||
remotePath: '/mnt/user/appdata/blockpilot-autonomous',
|
||||
payload: {
|
||||
head: 'a'.repeat(40),
|
||||
branch: 'main',
|
||||
remote: 'ssh://git@gitea/Jens/blockpilot-autonomous.git',
|
||||
composeFiles: ['compose.yml'],
|
||||
compose: { services: { app: { image: 'blockpilot:test' } } },
|
||||
containers: [{
|
||||
Name: '/blockpilot',
|
||||
State: { Running: true },
|
||||
Config: { Image: 'blockpilot:test', Env: ['TOKEN=secret', 'MODE=prod'], Labels: { 'com.docker.compose.service': 'app', 'com.docker.compose.project': 'blockpilot' } },
|
||||
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
|
||||
NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '1223' }] }, Networks: { bridge: {} } },
|
||||
Mounts: [{ Type: 'bind', Source: '/mnt/user/appdata/blockpilot-autonomous/data', Destination: '/data', RW: true }]
|
||||
}],
|
||||
dockerManXml: '<Container><Name>blockpilot</Name><WebUI>http://[IP]:[PORT:1223]/</WebUI><Icon>https://example.test/icon.png</Icon><Shell>/bin/bash</Shell></Container>'
|
||||
}
|
||||
});
|
||||
assert.equal(result.profile.hostPort, 1223);
|
||||
assert.equal(result.profile.containerPort, 8080);
|
||||
assert.equal(result.profile.containerName, 'blockpilot');
|
||||
assert.equal(result.profile.composeService, 'app');
|
||||
assert.equal(result.profile.webUiUrl, 'http://[IP]:[PORT:1223]/');
|
||||
assert.equal(result.profile.iconUrl, 'https://example.test/icon.png');
|
||||
assert.equal(result.profile.dockerShell, '/bin/bash');
|
||||
assert.deepEqual(result.profile.detectedMetadata.envNames, ['TOKEN', 'MODE']);
|
||||
assert.ok(result.profile.preservePaths.includes('data'));
|
||||
assert.equal(result.provenance.hostPort.origin, 'docker-inspect');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import validation from '../src/shared/validation.cjs';
|
||||
|
||||
const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest } = validation;
|
||||
const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest, assertHttpUrl } = validation;
|
||||
|
||||
test('normalizes Gitea base URL', () => {
|
||||
assert.equal(normalizeBaseUrl('https://gitea.example.com/'), 'https://gitea.example.com');
|
||||
@@ -15,3 +15,8 @@ test('rejects blank commit messages', () => {
|
||||
test('requires exact SHA and workflow profile', () => {
|
||||
assert.throws(() => assertDeploymentRequest({ branch: 'main', workflowFile: 'deploy.yml' }, 'nope'), /commit SHA/i);
|
||||
});
|
||||
|
||||
test('accepts Unraid DockerMan WebUI placeholders only when explicitly enabled', () => {
|
||||
assert.equal(assertHttpUrl('http://[IP]:[PORT:1223]/', { allowUnraidTemplate: true }), 'http://[IP]:[PORT:1223]/');
|
||||
assert.throws(() => assertHttpUrl('http://[IP]:[PORT:1223]/'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user