diff --git a/build-scripts/check-coverage-thresholds.js b/build-scripts/check-coverage-thresholds.js index 5632630..9e2cf62 100644 --- a/build-scripts/check-coverage-thresholds.js +++ b/build-scripts/check-coverage-thresholds.js @@ -13,6 +13,9 @@ const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); const thresholds = { 'src/main/main.ts': { lines: 12, statements: 12 }, 'src/main/preload.ts': { lines: 80, statements: 80 }, + 'src/main/processKill.ts': { lines: 80, statements: 80 }, + 'src/utils/validation.ts': { lines: 85, statements: 85 }, + 'src/utils/downloadLifecycle.ts': { lines: 90, statements: 90 }, }; function findCoverageEntry(suffix) { @@ -46,4 +49,4 @@ if (failures.length > 0) { process.exit(1); } -console.log('Coverage thresholds passed for main.ts and preload.ts.'); +console.log('Coverage thresholds passed.'); diff --git a/build-scripts/test-all.js b/build-scripts/test-all.js index 7705c1b..b18de74 100644 --- a/build-scripts/test-all.js +++ b/build-scripts/test-all.js @@ -16,6 +16,7 @@ const colors = { const results = { unit: { status: 'pending', passed: 0, failed: 0 }, + compile: { status: 'pending' }, lint: { status: 'pending' }, format: { status: 'pending' }, typecheck: { status: 'pending' }, @@ -152,6 +153,10 @@ function runConfigChecks() { 'package.json: scripts.test:all must run build-scripts/test-all.js' ); assertConfig(pkg.main === 'dist/main/main.js', 'package.json: main must be dist/main/main.js'); + assertConfig( + pkg.desktopName === 'com.burnttoasters.rosi.desktop', + 'package.json: desktopName must be com.burnttoasters.rosi.desktop' + ); assertConfig( Boolean(pkg.scripts && pkg.scripts['ffmpeg:check']), 'package.json: missing scripts.ffmpeg:check' @@ -186,6 +191,10 @@ function runConfigChecks() { Array.isArray(baseConfig.linux?.extraResources), 'electron-builder.base.yml: linux.extraResources missing for yt-dlp binaries' ); + assertConfig( + baseConfig.linux?.syncDesktopName === true, + 'electron-builder.base.yml: linux.syncDesktopName must be true' + ); assertConfig( Boolean(githubConfig.publish), 'electron-builder.github.yml: missing publish config' @@ -209,6 +218,7 @@ function runConfigChecks() { 'build/app-icon.icns', 'build/app-icon.png', 'build/appx/appxmanifest.xml', + 'com.burnttoasters.rosi.desktop', ]; for (const relativePath of requiredFiles) { assertConfig( @@ -234,6 +244,9 @@ function run() { const unitResult = runCommand('unit', 'npm test', parseUnitTests); results.unit.status = unitResult.ok ? 'passed' : 'failed'; + const compileResult = runCommand('compile', 'npm run compile'); + results.compile.status = compileResult.ok ? 'passed' : 'failed'; + const lintResult = runCommand('lint', 'npm run lint'); results.lint.status = lintResult.ok ? 'passed' : 'failed'; @@ -250,6 +263,7 @@ function run() { const summaryLines = [ `${colors.bold}Unit:${colors.reset} ${results.unit.status === 'passed' ? colors.green + '✓ PASS' : colors.red + '✗ FAIL'}${colors.reset} (${results.unit.passed} passed${results.unit.failed > 0 ? `, ${results.unit.failed} failed` : ''})`, + `${colors.bold}Compile:${colors.reset} ${results.compile.status === 'passed' ? colors.green + '✓ PASS' : colors.red + '✗ FAIL'}${colors.reset}`, `${colors.bold}Lint:${colors.reset} ${results.lint.status === 'passed' ? colors.green + '✓ PASS' : colors.red + '✗ FAIL'}${colors.reset}`, `${colors.bold}Format:${colors.reset} ${results.format.status === 'passed' ? colors.green + '✓ PASS' : colors.red + '✗ FAIL'}${colors.reset}`, `${colors.bold}Typecheck:${colors.reset} ${results.typecheck.status === 'passed' ? colors.green + '✓ PASS' : colors.red + '✗ FAIL'}${colors.reset}`, @@ -289,4 +303,11 @@ if (require.main === module) { run(); } -module.exports = { runCommand, runSyntaxChecks, runConfigChecks }; +module.exports = { + runCommand, + runSyntaxChecks, + runConfigChecks, + stripAnsi, + parseUnitTests, + results, +}; diff --git a/com.burnttoasters.rosi.desktop b/com.burnttoasters.rosi.desktop index f83458c..e84fb5c 100644 --- a/com.burnttoasters.rosi.desktop +++ b/com.burnttoasters.rosi.desktop @@ -6,5 +6,5 @@ Icon=com.burnttoasters.rosi Type=Application Categories=Utility;AudioVideo;Network; StartupNotify=true -StartupWMClass=Rosi +StartupWMClass=com.burnttoasters.rosi Keywords=video;download;yt-dlp;youtube;converter;ffmpeg; diff --git a/electron-builder.base.yml b/electron-builder.base.yml index 7c52f5e..fb673f7 100644 --- a/electron-builder.base.yml +++ b/electron-builder.base.yml @@ -66,6 +66,7 @@ linux: - rpm icon: build/app-icon.png category: Utility + syncDesktopName: true extraResources: - from: assets/ to: assets/ diff --git a/package.json b/package.json index 62f1df1..663d3d4 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "4.1.0-beta.2", "private": true, "description": "Electron GUI for yt-dlp", + "desktopName": "com.burnttoasters.rosi.desktop", "keywords": [ "electron", "yt-dlp", diff --git a/src/tests/buildScripts.gitPrune.test.ts b/src/tests/buildScripts.gitPrune.test.ts new file mode 100644 index 0000000..a772f93 --- /dev/null +++ b/src/tests/buildScripts.gitPrune.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +const { + parseArgs, + selectBranchesToDelete, + stripRemotePrefix, +} = require('../../build-scripts/git-prune-local-branches.js'); + +describe('git-prune-local-branches helpers', () => { + it('parseArgs reads remote, dry-run, and force flags', () => { + expect(parseArgs(['node', 'script.js'])).toEqual({ + remote: 'origin', + dryRun: false, + force: false, + }); + expect(parseArgs(['node', 'script.js', '--remote', 'upstream', '-n', '--force'])).toEqual({ + remote: 'upstream', + dryRun: true, + force: true, + }); + }); + + it('stripRemotePrefix removes the remote prefix', () => { + expect(stripRemotePrefix('origin/main', 'origin')).toBe('main'); + expect(stripRemotePrefix('origin/HEAD', 'origin')).toBeNull(); + expect(stripRemotePrefix('main', 'origin')).toBeNull(); + }); + + it('selectBranchesToDelete keeps current and remote-tracking branches', () => { + const result = selectBranchesToDelete( + ['main', 'feature/a', 'feature/b'], + ['main', 'feature/a'], + 'main' + ); + expect(result).toEqual(['feature/b']); + }); +}); diff --git a/src/tests/buildScripts.postRelease.test.ts b/src/tests/buildScripts.postRelease.test.ts new file mode 100644 index 0000000..6d33ea7 --- /dev/null +++ b/src/tests/buildScripts.postRelease.test.ts @@ -0,0 +1,70 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const { + cleanReleaseArtifacts, + copyReleaseAssets, + getAfterPackLocation, + run, +} = require('../../build-scripts/post-release-assets.js'); + +function makeTempDir(prefix: string) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +describe('post-release-assets helpers', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reads AFTER_PACK_LOC from the environment', () => { + expect(getAfterPackLocation({})).toBe(''); + expect(getAfterPackLocation({ AFTER_PACK_LOC: ' /tmp/rosi-mirror ' })).toBe( + '/tmp/rosi-mirror' + ); + }); + + it('cleans build-only release artifacts', () => { + const releaseDir = makeTempDir('rosi-release-clean-'); + tempDirs.push(releaseDir); + fs.mkdirSync(path.join(releaseDir, 'win-unpacked')); + fs.writeFileSync(path.join(releaseDir, 'builder-debug.yml'), 'debug'); + + cleanReleaseArtifacts(releaseDir); + + expect(fs.existsSync(path.join(releaseDir, 'win-unpacked'))).toBe(false); + expect(fs.existsSync(path.join(releaseDir, 'builder-debug.yml'))).toBe(false); + }); + + it('mirrors cleaned release assets to AFTER_PACK_LOC', () => { + const releaseDir = makeTempDir('rosi-release-src-'); + const destination = makeTempDir('rosi-release-dest-'); + tempDirs.push(releaseDir, destination); + fs.writeFileSync(path.join(releaseDir, 'ROSI-Linux-amd64.deb'), 'deb'); + + const result = run({ + releaseDir, + env: { AFTER_PACK_LOC: destination }, + }); + + expect(result).toEqual({ mirrored: true, destination: path.resolve(destination) }); + expect(fs.existsSync(path.join(destination, 'ROSI-Linux-amd64.deb'))).toBe(true); + expect(fs.existsSync(path.join(releaseDir, 'ROSI-Linux-amd64.deb'))).toBe(true); + }); + + it('rejects mirroring into a subdirectory of the release folder', () => { + const releaseDir = makeTempDir('rosi-release-nested-'); + tempDirs.push(releaseDir); + fs.writeFileSync(path.join(releaseDir, 'artifact.txt'), 'data'); + + expect(() => copyReleaseAssets(releaseDir, path.join(releaseDir, 'mirror'))).toThrow( + 'AFTER_PACK_LOC cannot be inside the release directory' + ); + }); +}); diff --git a/src/tests/buildScripts.testAll.test.ts b/src/tests/buildScripts.testAll.test.ts new file mode 100644 index 0000000..4a50faf --- /dev/null +++ b/src/tests/buildScripts.testAll.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +const { parseUnitTests, stripAnsi, results } = require('../../build-scripts/test-all.js'); + +describe('test-all helpers', () => { + it('stripAnsi removes terminal color codes', () => { + expect(stripAnsi('\u001b[32mPASS\u001b[0m')).toBe('PASS'); + }); + + it('parseUnitTests extracts passed and failed counts', () => { + parseUnitTests('Tests 12 passed | 2 failed (14)'); + expect(results.unit.passed).toBe(12); + expect(results.unit.failed).toBe(2); + }); +}); diff --git a/src/tests/constants.contract.test.ts b/src/tests/constants.contract.test.ts new file mode 100644 index 0000000..81c4d4d --- /dev/null +++ b/src/tests/constants.contract.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { + ALLOWED_AUDIO_FORMATS, + ALLOWED_BROWSERS, + ALLOWED_CONVERT_FORMATS, + CURRENT_SETTINGS_VERSION, + FORMAT_ID_PATTERN, + MAX_QUEUE_SIZE, + SUBTITLE_LANGS_PATTERN, +} from '../main/constants'; + +describe('constants contracts', () => { + it('accepts valid yt-dlp format IDs', () => { + expect(FORMAT_ID_PATTERN.test('137')).toBe(true); + expect(FORMAT_ID_PATTERN.test('hls-1080')).toBe(true); + expect(FORMAT_ID_PATTERN.test('136-drc')).toBe(true); + expect(FORMAT_ID_PATTERN.test('137; rm -rf /')).toBe(false); + expect(FORMAT_ID_PATTERN.test('best audio')).toBe(false); + }); + + it('accepts valid subtitle language lists', () => { + expect(SUBTITLE_LANGS_PATTERN.test('en')).toBe(true); + expect(SUBTITLE_LANGS_PATTERN.test('en,es,fr')).toBe(true); + expect(SUBTITLE_LANGS_PATTERN.test('en.*')).toBe(true); + expect(SUBTITLE_LANGS_PATTERN.test('en; rm -rf /')).toBe(false); + }); + + it('keeps allowed format and browser sets aligned with validation', () => { + expect(ALLOWED_CONVERT_FORMATS.has('mp4')).toBe(true); + expect(ALLOWED_CONVERT_FORMATS.has('avi')).toBe(false); + expect(ALLOWED_AUDIO_FORMATS.has('m4a')).toBe(true); + expect(ALLOWED_AUDIO_FORMATS.has('wma')).toBe(false); + expect(ALLOWED_BROWSERS.has('firefox')).toBe(true); + expect(ALLOWED_BROWSERS.has('internet explorer')).toBe(false); + }); + + it('keeps queue and settings version contracts stable', () => { + expect(MAX_QUEUE_SIZE).toBe(500); + expect(CURRENT_SETTINGS_VERSION).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/tests/downloader.session.test.ts b/src/tests/downloader.session.test.ts new file mode 100644 index 0000000..64f91a7 --- /dev/null +++ b/src/tests/downloader.session.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'events'; +import * as os from 'os'; +import * as path from 'path'; + +const { existsSyncMock, statSyncMock, mkdirSyncMock, spawnWithEnvMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + statSyncMock: vi.fn(), + mkdirSyncMock: vi.fn(), + spawnWithEnvMock: vi.fn(), +})); + +vi.mock('fs', () => ({ + existsSync: existsSyncMock, + statSync: statSyncMock, + mkdirSync: mkdirSyncMock, + rmSync: vi.fn(), +})); + +vi.mock('../main/platform', () => ({ + spawnWithEnv: spawnWithEnvMock, + getEffectiveFfmpegPath: vi.fn(() => 'ffmpeg'), + ytdlpBinary: 'yt-dlp', + isWindows: process.platform === 'win32', + isMac: process.platform === 'darwin', +})); + +vi.mock('../main/settings', () => ({ + loadSettings: vi.fn(() => ({ + settingsVersion: 1, + theme: 'system', + showConsoleOutput: false, + consoleCollapsed: false, + advancedOptions: false, + audioOnly: false, + convertEnabled: false, + convertFormat: 'mp4', + keepOriginalAfterConvert: true, + firstLaunch: false, + hookBrowser: false, + browserChoice: 'Chrome', + animateBackground: true, + notifications: true, + denoReminderDismissed: false, + gpuAcceleration: false, + gpuType: 'auto', + bestQuality: false, + ffmpegPath: '', + hideSupportModal: false, + checkUpdatesOnStartup: true, + updateChannel: 'auto', + audioFormat: 'mp3', + })), + recordDownload: vi.fn(), +})); + +vi.mock('electron', () => ({ + dialog: { + showMessageBox: vi.fn(), + }, +})); + +vi.mock('electron-log/main.js', () => ({ + default: { + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { + canStartDownload, + cancelActiveSession, + getDownloadSessionOwner, + isDownloadBusy, + startDownload, +} from '../main/downloader'; + +function createProc() { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: () => void; + killed: boolean; + pid: number; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.pid = 1234; + proc.killed = false; + proc.kill = vi.fn(() => { + proc.killed = true; + setImmediate(() => proc.emit('close', 1)); + }); + return proc; +} + +function createSender() { + return { + send: vi.fn(), + isDestroyed: () => false, + } as unknown as Electron.WebContents; +} + +describe('downloader session state', () => { + const outputPath = path.join(os.homedir(), 'Downloads', 'rosi-session-test'); + + beforeEach(() => { + vi.clearAllMocks(); + existsSyncMock.mockImplementation((target: string) => { + const normalized = String(target).replace(/\\/g, '/'); + return normalized.includes('yt-dlp') || normalized.includes('rosi-session-test'); + }); + statSyncMock.mockReturnValue({ isDirectory: () => true }); + spawnWithEnvMock.mockReturnValue(createProc()); + }); + + afterEach(() => { + cancelActiveSession(false); + }); + + it('starts idle with no active owner', () => { + expect(isDownloadBusy()).toBe(false); + expect(getDownloadSessionOwner()).toBeNull(); + expect(canStartDownload('manual')).toBe(true); + expect(canStartDownload('queue')).toBe(true); + }); + + it('tracks the active owner while a download is running', () => { + startDownload( + '/tmp/yt-dlp', + createSender(), + { url: 'https://example.com/video', outputPath }, + null, + undefined, + 'manual' + ); + + expect(isDownloadBusy()).toBe(true); + expect(getDownloadSessionOwner()).toBe('manual'); + expect(canStartDownload('manual')).toBe(true); + expect(canStartDownload('queue')).toBe(false); + }); + + it('rejects starting a queue download while manual owns the session', () => { + startDownload( + '/tmp/yt-dlp', + createSender(), + { url: 'https://example.com/video', outputPath }, + null, + undefined, + 'manual' + ); + + expect(() => + startDownload( + '/tmp/yt-dlp', + createSender(), + { url: 'https://example.com/other', outputPath }, + null, + undefined, + 'queue' + ) + ).toThrow('Download session already active with a different owner.'); + }); + + it('clears session state when cancelled', () => { + startDownload( + '/tmp/yt-dlp', + createSender(), + { url: 'https://example.com/video', outputPath }, + null, + undefined, + 'queue' + ); + + expect(isDownloadBusy()).toBe(true); + cancelActiveSession(false); + expect(isDownloadBusy()).toBe(false); + expect(getDownloadSessionOwner()).toBeNull(); + }); +}); diff --git a/src/tests/ipc.contract.test.ts b/src/tests/ipc.contract.test.ts new file mode 100644 index 0000000..e454384 --- /dev/null +++ b/src/tests/ipc.contract.test.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +function readRepoFile(relativePath: string) { + return fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8'); +} + +function extractQuotedCalls(source: string, pattern: RegExp): string[] { + return [...source.matchAll(pattern)].map((match) => match[1]).filter(Boolean); +} + +describe('IPC channel contracts', () => { + const preload = readRepoFile('src/main/preload.ts'); + const main = readRepoFile('src/main/main.ts'); + + const invokeChannels = extractQuotedCalls(preload, /ipcRenderer\.invoke\('([^']+)'/g); + const sendChannels = extractQuotedCalls(preload, /ipcRenderer\.send\('([^']+)'/g); + const rendererEventChannels = extractQuotedCalls(preload, /ipcRenderer\.on\('([^']+)'/g); + const mainHandlers = extractQuotedCalls(main, /ipcMain\.handle\('([^']+)'/g); + const mainEvents = extractQuotedCalls(main, /ipcMain\.on\('([^']+)'/g); + + it('registers a main handler for every preload invoke channel', () => { + const missing = invokeChannels.filter((channel) => !mainHandlers.includes(channel)); + expect(missing, `missing ipcMain.handle registrations: ${missing.join(', ')}`).toEqual([]); + }); + + it('registers a main listener for every preload send channel', () => { + const missing = sendChannels.filter((channel) => !mainEvents.includes(channel)); + expect(missing, `missing ipcMain.on registrations: ${missing.join(', ')}`).toEqual([]); + }); + + it('documents renderer event channels used by preload subscriptions', () => { + expect(rendererEventChannels.sort()).toEqual( + [ + 'complete', + 'prepare-for-close', + 'progress', + 'queue-update', + 'settings-imported', + 'updater-progress', + 'updater-status', + ].sort() + ); + }); +}); diff --git a/src/tests/ipcValidation.paths.test.ts b/src/tests/ipcValidation.paths.test.ts new file mode 100644 index 0000000..d45042f --- /dev/null +++ b/src/tests/ipcValidation.paths.test.ts @@ -0,0 +1,49 @@ +import * as os from 'os'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; +import { validateDownloadPath, validateFfmpegPathValue } from '../utils/ipcValidation'; + +function pathOutsideAllowedDownloadBases(): string { + if (process.platform === 'win32') { + return path.join(process.env.SystemRoot ?? 'C:\\Windows', 'Temp', 'rosi-outside-test'); + } + return '/tmp/rosi-outside-test'; +} + +describe('ipc path validation', () => { + it('accepts home-relative download paths', () => { + const homeFolder = path.join(os.homedir(), 'Downloads', 'rosi'); + const result = validateDownloadPath(homeFolder); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toBe(path.resolve(homeFolder)); + } + }); + + it('rejects relative download paths', () => { + const result = validateDownloadPath('relative/downloads'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('INVALID_PATH'); + } + }); + + it('rejects paths outside allowed download bases', () => { + const result = validateDownloadPath(pathOutsideAllowedDownloadBases()); + expect(result.ok).toBe(false); + }); + + it('accepts empty download path as clear-to-default', () => { + const result = validateDownloadPath(' '); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data).toBe(''); + }); + + it('validates ffmpeg path sentinel and empty values', () => { + expect(validateFfmpegPathValue(undefined).ok).toBe(true); + expect(validateFfmpegPathValue('ffmpeg').ok).toBe(true); + expect(validateFfmpegPathValue(' ').ok).toBe(true); + expect(validateFfmpegPathValue('relative/ffmpeg').ok).toBe(false); + expect(validateFfmpegPathValue(path.join(os.homedir(), 'bin', 'ffprobe')).ok).toBe(false); + }); +}); diff --git a/src/tests/ipcValidation.test.ts b/src/tests/ipcValidation.test.ts index 6535710..9e7514a 100644 --- a/src/tests/ipcValidation.test.ts +++ b/src/tests/ipcValidation.test.ts @@ -11,6 +11,13 @@ import { validateSettingsPatchPayload, } from '../utils/ipcValidation'; +function pathOutsideAllowedDownloadBases(): string { + if (process.platform === 'win32') { + return path.join(process.env.SystemRoot ?? 'C:\\Windows', 'Temp', 'rosi-outside-test'); + } + return '/tmp/rosi-outside-test'; +} + describe('ipc validation helpers', () => { it('builds typed ok and error result wrappers', () => { expect(okResult({ started: true })).toEqual({ @@ -53,7 +60,7 @@ describe('ipc validation helpers', () => { it('rejects outputPath outside the user home directory', () => { const result = validateDownloadRequestPayload({ url: 'https://example.com', - outputPath: '/tmp/downloads', + outputPath: pathOutsideAllowedDownloadBases(), }); expect(result.ok).toBe(false); if (!result.ok) { @@ -253,7 +260,9 @@ describe('ipc validation helpers', () => { expect(validateSettingsPatchPayload({ downloadFolder: null }).ok).toBe(false); expect(validateSettingsPatchPayload({ downloadFolder: 42 }).ok).toBe(false); expect(validateSettingsPatchPayload({ downloadFolder: 'relative/path' }).ok).toBe(false); - expect(validateSettingsPatchPayload({ downloadFolder: '/tmp/outside-home' }).ok).toBe(false); + expect( + validateSettingsPatchPayload({ downloadFolder: pathOutsideAllowedDownloadBases() }).ok + ).toBe(false); expect(validateSettingsPatchPayload({ downloadFolder: 'x'.repeat(4097) }).ok).toBe(false); }); diff --git a/src/tests/msStoreDetector.test.ts b/src/tests/msStoreDetector.test.ts new file mode 100644 index 0000000..581f574 --- /dev/null +++ b/src/tests/msStoreDetector.test.ts @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import * as fs from 'fs'; +import * as path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const REPO = path.resolve(__dirname, '..', '..'); +const DETECTOR = path.join(REPO, 'src', 'renderer', 'msStoreDetector.js'); + +function runDetector(channel: string | null) { + document.body.innerHTML = ` + + + `; + (window as unknown as { api?: { getChannel: () => string | null } }).api = { + getChannel: () => channel, + }; + const source = fs.readFileSync(DETECTOR, 'utf-8'); + (0, eval)(source); +} + +describe('msStoreDetector', () => { + beforeEach(() => { + document.body.innerHTML = ''; + delete (window as unknown as { api?: unknown }).api; + }); + + it('hides update controls for Microsoft Store builds', () => { + runDetector('msstore'); + + expect(document.getElementById('checkUpdateBtn')?.style.display).toBe('none'); + expect(document.getElementById('checkUpdatesOnStartupLabel')?.style.display).toBe('none'); + }); + + it('leaves update controls visible for GitHub builds', () => { + runDetector('github'); + + expect(document.getElementById('checkUpdateBtn')?.style.display).toBe(''); + expect(document.getElementById('checkUpdatesOnStartupLabel')?.style.display).toBe(''); + }); +}); diff --git a/src/tests/packaging.contract.test.ts b/src/tests/packaging.contract.test.ts new file mode 100644 index 0000000..1743f35 --- /dev/null +++ b/src/tests/packaging.contract.test.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +function readRepoFile(relativePath: string) { + return fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8'); +} + +describe('packaging and desktop contracts', () => { + it('keeps Linux desktop integration aligned across package, builder, and Flatpak', () => { + const pkg = JSON.parse(readRepoFile('package.json')); + const desktop = readRepoFile('com.burnttoasters.rosi.desktop'); + const baseConfig = readRepoFile('electron-builder.base.yml'); + + expect(pkg.desktopName).toBe('com.burnttoasters.rosi.desktop'); + expect(baseConfig).toMatch(/syncDesktopName:\s*true/); + expect(desktop).toMatch(/StartupWMClass=com\.burnttoasters\.rosi/); + expect(desktop).toMatch(/Icon=com\.burnttoasters\.rosi/); + }); + + it('keeps renderer script load order stable', () => { + const indexHtml = readRepoFile('src/renderer/index.html'); + const moduleIndex = indexHtml.indexOf('modules/ui.js'); + const engineIndex = indexHtml.indexOf('rosiEngine.js'); + const detectorIndex = indexHtml.indexOf('msStoreDetector.js'); + + expect(moduleIndex).toBeGreaterThan(-1); + expect(engineIndex).toBeGreaterThan(moduleIndex); + expect(detectorIndex).toBeGreaterThan(engineIndex); + }); + + it('keeps splash screen basics for headless packaging checks', () => { + const splash = readRepoFile('src/renderer/splash.html'); + + expect(splash).toMatch(//); + expect(splash).toMatch(/Content-Security-Policy/); + expect(splash).toMatch(/