import test from 'node:test'; import assert from 'node:assert/strict'; 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, 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-')); const saved = []; const store = { data: { gitea: { baseUrl: 'https://gitea.example.test' }, updates: { owner: 'Jens', repo: 'ForgeFlow', branch: 'main', autoCheck: true } }, async save() { saved.push(true); } }; const calls = []; const gitea = { async getBranch(owner, repo, branch) { calls.push(['branch', owner, repo, branch]); return { commit: { id: 'a'.repeat(40) } }; }, async getRepositoryFile(input) { calls.push(['file', input]); return { decoded: JSON.stringify({ name: 'forgeflow', version: '0.4.1' }) }; } }; const service = new UpdateService({ store, gitea, diagnostics: null, appInfo: { version: '0.4.0', packaged: false }, sourcePath: temp, userDataPath: temp }); const result = await service.check(); assert.equal(result.available, true); assert.equal(result.remoteSha, 'a'.repeat(40)); assert.equal(calls[1][1].ref, 'a'.repeat(40)); assert.equal(saved.length, 1); await rm(temp, { recursive: true, force: true }); }); test('update repository parts reject path injection', async () => { const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-test-')); const service = new UpdateService({ store: { data: { updates: { owner: '../Jens', repo: 'ForgeFlow', branch: 'main' } }, save: async () => {} }, gitea: {}, diagnostics: null, appInfo: { version: '0.4.0', packaged: false }, sourcePath: temp, userDataPath: temp }); 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/); });