Release ForgeFlow 0.6.0

This commit is contained in:
NuklearRabbit
2026-07-25 05:59:07 +02:00
parent cf1f67a823
commit 9d3933c878
48 changed files with 2208 additions and 466 deletions
+65
View File
@@ -178,3 +178,68 @@ test('stages a large Windows-sized partial selection through NUL-delimited stdin
assert.equal(status.counts.staged, names.length);
assert.equal(status.counts.unstaged, 0);
});
test('detects and removes a stale HEAD.lock while skipping Git object storage', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-head-lock-'));
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, 'README.md'), 'lock test\n');
await git(['add', '.'], root);
await git(['commit', '-m', 'Initial'], root);
const headLock = path.join(root, '.git', 'HEAD.lock');
const ignoredObjectLock = path.join(root, '.git', 'objects', 'fake.lock');
await fs.writeFile(headLock, 'stale');
await fs.writeFile(ignoredObjectLock, 'not a repository mutation lock');
const old = new Date(Date.now() - 60_000);
await fs.utimes(headLock, old, old);
const service = new GitService();
const report = await service.listGitLocks(root);
assert.deepEqual(report.locks.map((item) => item.name), ['HEAD.lock']);
const repaired = await service.repairStaleGitLocks(root, { minimumAgeMs: 0, allowWithoutProcessProbe: true });
assert.equal(repaired.removed.length, 1);
await assert.rejects(() => fs.stat(headLock), (error) => error.code === 'ENOENT');
assert.ok(await fs.stat(ignoredObjectLock));
});
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diverged-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const remote = path.join(root, 'remote.git');
const working = path.join(root, 'working');
const other = path.join(root, 'other');
await git(['init', '--bare', remote], root);
await git(['clone', remote, working], root);
await git(['config', 'user.name', 'ForgeFlow Test'], working);
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
await fs.writeFile(path.join(working, 'README.md'), 'initial\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await git(['clone', remote, other], root);
await git(['config', 'user.name', 'Other Test'], other);
await git(['config', 'user.email', 'other@example.invalid'], other);
await git(['checkout', 'main'], other);
await fs.writeFile(path.join(other, 'remote.txt'), 'remote\n');
await git(['add', '.'], other);
await git(['commit', '-m', 'Remote commit'], other);
await git(['push', 'origin', 'main'], other);
await fs.writeFile(path.join(working, 'local.txt'), 'local\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Local commit'], working);
const localBefore = (await git(['rev-parse', 'HEAD'], working)).stdout.trim();
const service = new GitService();
const scan = await service.reconcile(working);
assert.equal(scan.status.branch.ahead, 1);
assert.equal(scan.status.branch.behind, 1);
assert.ok(scan.recommendations.some((item) => item.action === 'backup-reset'));
const repaired = await service.repairSync(working, 'backup-reset');
assert.match(repaired.backupBranch, /^forgeflow\/backup-main-/);
assert.equal(repaired.status.branch.ahead, 0);
assert.equal(repaired.status.branch.behind, 0);
const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim();
assert.equal(backupSha, localBefore);
});
+32 -2
View File
@@ -20,7 +20,7 @@ test('commit workflow explains every disabled prerequisite', async () => {
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\.png/);
assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/);
});
@@ -43,8 +43,12 @@ test('Git mutations are serialized per repository and expose repair actions', as
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(renderer, /data-action="repair-index-lock"/);
assert.match(ipc, /GIT_LOCKS_RECENT/);
assert.match(ipc, /setTimeout\(resolve, 2_500\)/);
assert.match(renderer, /data-action="repair-git-locks"/);
assert.match(renderer, /Repository troubleshooting/);
assert.match(renderer, /data-action="repair-origin"/);
assert.match(renderer, /Open guided repository repair/);
});
@@ -54,3 +58,29 @@ test('SSH secrets are captured before the loading render clears password inputs'
const loading = renderer.indexOf("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');
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');
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');
assert.match(renderer, /repair-repository-sync/);
assert.match(renderer, /safety branch/);
assert.match(ipc, /repository:repair-sync/);
});
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import repositoryModule from '../src/main/repository-service.cjs';
const { RepositoryService } = repositoryModule;
function status(head = 'a'.repeat(40)) {
return {
head,
shortHead: head.slice(0, 7),
clean: true,
counts: { changed: 0, conflicts: 0 },
branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 }
};
}
const remote = {
id: 1,
name: 'Portfolio',
full_name: 'Jens/Portfolio',
owner: { login: 'Jens' },
private: true,
default_branch: 'main',
html_url: 'https://gitea.example/Jens/Portfolio',
clone_url: 'https://gitea.example/Jens/Portfolio.git',
ssh_url: 'git@gitea.example:Jens/Portfolio.git'
};
function service() {
return new RepositoryService({ data: { preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] } }, {}, {});
}
test('a synchronized commit is deployable when the server is unknown or older', () => {
const current = status();
const unknown = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: null, healthy: null } }
]);
assert.equal(unknown.readyToDeploy, true);
const older = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: 'b'.repeat(40), healthy: true } }
]);
assert.equal(older.readyToDeploy, true);
});
test('a healthy commit already live on the server is not offered for deployment again', () => {
const current = status();
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: true } }
]);
assert.equal(repository.readyToDeploy, false);
});
test('an unhealthy live commit remains eligible for a controlled redeploy', () => {
const current = status();
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: false } }
]);
assert.equal(repository.readyToDeploy, true);
});
+143 -2
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, bash } = require('../src/main/unraid-deployment-service.cjs');
const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash } = require('../src/main/unraid-deployment-service.cjs');
const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs');
test('Unraid remote paths cannot escape appdata project folder', () => {
@@ -107,7 +107,7 @@ test('successful SSH rollback records the formerly live SHA as the new rollback
const savedStates = [];
const operations = [];
const store = {
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '' }),
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }),
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }),
getDeploymentState: () => ({ liveSha, previousSha }),
addOperation: async (operation) => { operations.push(operation); return operation; },
@@ -124,3 +124,144 @@ test('successful SSH rollback records the formerly live SHA as the new rollback
assert.equal(savedStates.at(-1).previousSha, liveSha);
assert.equal(operations.at(-1).previousSha, liveSha);
});
test('generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name', () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
const compose = service.generatedCompose({ composeService: 'Portfolio', hostPort: 5150, containerPort: 80 }, { name: 'Portfolio' });
assert.match(compose, / portfolio:/);
assert.match(compose, /image: forgeflow\/portfolio:production/);
assert.match(compose, /container_name: Portfolio/);
});
test('SSH deployment dispatch returns a running operation while the remote build continues in background', async () => {
const sha = 'd'.repeat(40);
const operations = [];
let resolveRemote;
const store = {
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'Portfolio', cloneUrl: 'forgeflow-gitea:Jens/Portfolio.git', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }),
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }),
addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); },
saveDeploymentState: async () => ({})
};
const ssh = { exec: async () => new Promise((resolve) => { resolveRemote = resolve; }) };
const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null });
service.preflight = async () => ({ summary: { ready: true, blocking: [] }, inspection: { head: null } });
service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null });
const operation = await service.deploy({ repository: { fullName: 'Jens/Portfolio', name: 'Portfolio' }, profileId: 'production', sha });
assert.equal(operation.status, 'running');
assert.match(operation.logs.join('\n'), /background/i);
resolveRemote({ stdout: 'Container Portfolio started\n', stderr: '', exitCode: 0 });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(operations.at(-1).status, 'success');
});
test('Unraid preflight verifies repository access before a deployment can start', async () => {
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
assert.match(source, /server-git-access/);
assert.match(source, /git ls-remote --exit-code/);
assert.match(source, /Unraid → Gitea access/);
});
test('DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names', () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
const metadata = service.metadataCompose({
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio',
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/admin', dockerShell: '/bin/sh'
}, { name: 'Portfolio' }, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png');
assert.match(metadata, / portfolio:/);
assert.match(metadata, /image: forgeflow\/portfolio:production/);
assert.match(metadata, /container_name: Portfolio/);
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
assert.match(metadata, /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/);
assert.match(metadata, /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/);
});
test('DockerMan integration writes a persistent template fallback and invalidates cached metadata', () => {
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
const profile = {
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio',
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/', dockerShell: '/bin/sh'
};
const repository = { name: 'Portfolio' };
const icon = 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png';
const template = service.dockerManTemplate(profile, repository, icon);
const refresh = service.dockerManRefreshScript(profile, repository, icon);
assert.match(template, /<Name>Portfolio<\/Name>/);
assert.match(template, /<Repository>forgeflow\/portfolio:production<\/Repository>/);
assert.match(template, /<WebUI>http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/);
assert.match(template, /<Icon>file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/);
assert.match(refresh, /templates-user\/my-Portfolio\.xml/);
assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/);
assert.match(refresh, /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/);
assert.doesNotMatch(refresh, /dockerManRefreshScript/);
assert.equal(xmlEscape('A&B<"x">'), 'A&amp;B&lt;&quot;x&quot;&gt;');
});
test('built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage', async (t) => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const os = await import('node:os');
const path = await import('node:path');
const sourcePath = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-icon-'));
t.after(() => rm(sourcePath, { recursive: true, force: true }));
const asset = path.join(sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png');
await mkdir(path.dirname(asset), { recursive: true });
await writeFile(asset, Buffer.from([137, 80, 78, 71]));
const uploads = [];
const service = new UnraidDeploymentService({
store: {}, git: {}, diagnostics: null, sourcePath,
ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; } }
});
const icon = await service.prepareIcon({ iconMode: 'builtin', remoteFolder: 'Portfolio' }, { name: 'Portfolio' }, { id: 'unraid' });
assert.equal(icon, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png');
assert.equal(uploads.length, 1);
assert.equal(uploads[0][0], 'unraid');
assert.equal(uploads[0][1], asset);
assert.equal(uploads[0][2], '/boot/config/plugins/dockerMan/images/Portfolio-icon.png');
});
test('stuck SSH deployment is reconciled to success when exact SHA and container health are live', async () => {
const sha = 'f'.repeat(40);
const saved = [];
const operation = { id: 'op-1', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha, status: 'running', logs: [] };
const store = {
getOperation: () => operation,
addOperation: async (next) => { saved.push(next); return next; }
};
const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null });
service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true });
const result = await service.refreshOperation('op-1');
assert.equal(result.status, 'success');
assert.match(result.logs.at(-1), /reconciled/i);
assert.equal(saved.at(-1).status, 'success');
});
test('DockerMan metadata repair refreshes known Unraid icon caches after container recreation', async () => {
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
assert.match(source, /\/var\/lib\/docker\/unraid\/images/);
assert.match(source, /dynamix\.docker\.manager\/images/);
assert.match(source, /-icon\.png/);
assert.match(source, /cp \${shellQuote\(localIconPath\)}/);
assert.match(source, /--force-recreate/);
});
test('stuck deployment is cleared as superseded when a different healthy commit is already live', async () => {
const requested = 'a'.repeat(40);
const live = 'b'.repeat(40);
const operation = { id: 'op-superseded', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha: requested, status: 'running', logs: [] };
const saved = [];
const service = new UnraidDeploymentService({
store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; } },
ssh: {}, git: {}, diagnostics: null
});
service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true });
const result = await service.refreshOperation(operation.id);
assert.equal(result.status, 'cancelled');
assert.match(result.error, /Superseded/);
assert.equal(saved.at(-1).status, 'cancelled');
});
+119 -2
View File
@@ -1,11 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { EventEmitter } from 'node:events';
const require = createRequire(import.meta.url);
const { UpdateService } = require('../src/main/update-service.cjs');
const { UpdateService, waitForUpdaterStarted } = require('../src/main/update-service.cjs');
test('update check pins version to an exact branch commit', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-test-'));
@@ -50,3 +51,119 @@ test('update repository parts reject path injection', async () => {
await assert.rejects(() => service.check(), /unsupported characters/);
await rm(temp, { recursive: true, force: true });
});
test('source updater confirms an external STARTED marker before ForgeFlow may close', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-handshake-'));
const source = path.join(temp, 'source');
const scripts = path.join(source, 'scripts');
const archive = path.join(temp, 'update.zip');
await mkdir(scripts, { recursive: true });
await writeFile(path.join(scripts, 'apply-source-update.ps1'), '# test helper');
await writeFile(archive, 'PK fake archive');
let capturedArgs = null;
const spawnProcess = (_command, args) => {
capturedArgs = args;
const child = new EventEmitter();
child.pid = 4321;
child.unref = () => {};
queueMicrotask(() => child.emit('spawn'));
const statusIndex = args.indexOf('-StatusPath');
const statusPath = args[statusIndex + 1];
setTimeout(() => writeFile(statusPath, JSON.stringify({ state: 'started', expectedVersion: '0.5.3' })), 30);
return child;
};
const service = new UpdateService({
store: { data: { updates: {}, gitea: { baseUrl: 'https://example.test' } }, save: async () => {} },
gitea: {}, diagnostics: null,
appInfo: { version: '0.5.2', packaged: false },
sourcePath: source,
userDataPath: temp,
platform: 'win32',
spawnProcess,
powershellPath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
handshakeTimeoutMs: 1000,
handshakePollMs: 10
});
service.staged = { archivePath: archive, remoteVersion: '0.5.3', remoteSha: 'a'.repeat(40), sha256: 'b'.repeat(64) };
const result = await service.apply();
assert.equal(result.confirmed, true);
assert.ok(capturedArgs.includes('-StatusPath'));
assert.ok(capturedArgs.includes('-UpdateId'));
await rm(temp, { recursive: true, force: true });
});
test('source updater leaves ForgeFlow open when no STARTED marker arrives', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-timeout-'));
const statusPath = path.join(temp, 'status.json');
await writeFile(statusPath, JSON.stringify({ state: 'launching' }));
await assert.rejects(
() => waitForUpdaterStarted(statusPath, { timeoutMs: 80, pollMs: 10, childState: { exited: false, error: null } }),
(error) => error.code === 'UPDATE_HELPER_START_TIMEOUT'
);
await rm(temp, { recursive: true, force: true });
});
test('completed source update result is returned once and acknowledged', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-result-'));
const updates = path.join(temp, 'updates');
await mkdir(updates, { recursive: true });
const statusPath = path.join(updates, 'apply-test.status.json');
await writeFile(statusPath, JSON.stringify({
state: 'success', expectedVersion: '0.5.3', installedVersion: '0.5.3', restartLaunched: false,
message: 'installed', logPath: 'C:\\log.txt', updatedAt: new Date().toISOString()
}));
const service = new UpdateService({
store: { data: { updates: {} }, save: async () => {} }, gitea: {}, diagnostics: null,
appInfo: { version: '0.5.3', packaged: false }, sourcePath: temp, userDataPath: temp
});
const first = await service.consumeLatestResult();
const second = await service.consumeLatestResult();
assert.equal(first.state, 'success');
assert.equal(first.restartLaunched, false);
assert.equal(second, null);
const persisted = JSON.parse(await readFile(statusPath, 'utf8'));
assert.ok(persisted.acknowledgedAt);
await rm(temp, { recursive: true, force: true });
});
test('PowerShell update helper writes lifecycle status before waiting for ForgeFlow exit', async () => {
const script = await readFile(new URL('../scripts/apply-source-update.ps1', import.meta.url), 'utf8');
assert.match(script, /\[string\]\$StatusPath/);
assert.match(script, /Write-UpdateState -State "started"/);
assert.match(script, /Write-UpdateState -State "success"/);
assert.match(script, /Write-UpdateState -State "rolled-back"/);
assert.match(script, /UTF8Encoding\(\$false\)/);
assert.match(script, /WriteAllText/);
});
test('PowerShell update helper starts with param and has no BOM or stray leading slash', async () => {
const bytes = await readFile(new URL('../scripts/apply-source-update.ps1', import.meta.url));
assert.notDeepEqual([...bytes.subarray(0, 3)], [0xEF, 0xBB, 0xBF]);
const text = bytes.toString('utf8');
assert.match(text.trimStart(), /^param\(/);
assert.doesNotMatch(text.trimStart(), /^\\/);
assert.match(text, /node_modules\\electron\\dist\\electron\.exe/);
assert.match(text, /npm ci --no-audit --no-fund/);
assert.match(text, /package-lock\.json/);
assert.doesNotMatch(text, /Get-Command npm\.cmd/);
assert.ok(text.indexOf('Write-UpdateState -State "success"') < text.indexOf('Start-ForgeFlow -WorkingDirectory $SourcePath'));
});
test('release publisher verifies Gitea and bootstraps only the installed updater helper', async () => {
const script = await readFile(new URL('../Publish-ForgeFlow-Release.ps1', import.meta.url), 'utf8');
assert.match(script, /npm install --no-audit --no-fund/);
assert.match(script, /package-lock\.json/);
assert.match(script, /non-reproducible update/);
assert.match(script, /npm run check/);
assert.match(script, /git ls-remote origin/);
assert.match(script, /publishedCommit -ne \$localCommit/);
assert.match(script, /scripts\\apply-source-update\.ps1/);
assert.match(script, /without changing its version/);
assert.doesNotMatch(script, /Copy-Item[^\n]+package\.json/);
});