// Release manifest script: the asset set every client relies on and the // `latest.json` shape the Tauri updater reads. import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import { assetUrl, checkAssetNames, expectedAssets, run, versionFromTag, } from '../../scripts/make-latest-json.js'; const TAG = 'v0.6.0'; const VERSION = '0.6.0'; const APPIMAGE = `claude-code-usage-bubble_${VERSION}_amd64.AppImage`; const DEB = `claude-code-usage-bubble_${VERSION}_amd64.deb`; const NOW = new Date('2026-10-21T09:30:00.000Z'); /** @type {string} */ let dir; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'make-latest-json-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); /** * Create release assets in the temp dir; a `.sig` holds a fake signature. * @param {string[]} names Asset names. */ const put = (names) => { for (const name of names) { writeFileSync(join(dir, name), name.endsWith('.sig') ? `sig-of-${name}\n` : 'bytes'); } }; const FULL = ['claude-code-usage-bubble.exe', 'claude-code-usage-bubble.exe.sig', APPIMAGE, `${APPIMAGE}.sig`, DEB]; describe('versionFromTag', () => { it('strips the v and keeps a pre-release suffix', () => { assert.equal(versionFromTag('v1.2.3'), '1.2.3'); assert.equal(versionFromTag('v0.0.0-test'), '0.0.0-test'); }); it('rejects tags that are not vX.Y.Z', () => { for (const tag of ['1.2.3', 'v1.2', 'v1.2.3.4', 'v1.2.3-', 'release', '']) { assert.throws(() => versionFromTag(tag), /not of the form/, tag); } }); }); describe('run', () => { it('writes exactly the two platforms with the signature content', () => { put(FULL); const manifest = run([TAG, dir], NOW); const written = JSON.parse(readFileSync(join(dir, 'latest.json'), 'utf8')); assert.deepEqual(written, manifest); assert.deepEqual(Object.keys(written).sort(), ['notes', 'platforms', 'pub_date', 'version']); assert.equal(written.version, VERSION); assert.equal(written.pub_date, '2026-10-21T09:30:00.000Z'); assert.deepEqual(Object.keys(written.platforms).sort(), ['linux-x86_64', 'windows-x86_64']); assert.equal(written.platforms['windows-x86_64'].signature, 'sig-of-claude-code-usage-bubble.exe.sig'); assert.equal(written.platforms['linux-x86_64'].signature, `sig-of-${APPIMAGE}.sig`); }); it('points each URL at the renamed asset of the same name', () => { put(FULL); const { platforms } = run([TAG, dir], NOW); const base = 'https://github.com/tiennm99/claude-code-usage-bubble/releases/download/v0.6.0/'; assert.equal(platforms['windows-x86_64'].url, `${base}claude-code-usage-bubble.exe`); assert.equal(platforms['linux-x86_64'].url, `${base}${APPIMAGE}`); for (const { url } of Object.values(platforms)) { assert.ok(FULL.includes(url.slice(base.length)), url); } }); it('writes only the Windows entry with --windows-only', () => { put(['claude-code-usage-bubble.exe', 'claude-code-usage-bubble.exe.sig']); const manifest = run(['--windows-only', TAG, dir], NOW); assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']); }); it('ignores a latest.json left by an earlier run', () => { put(FULL); run([TAG, dir], NOW); assert.doesNotThrow(() => run([TAG, dir], NOW)); }); it('fails on an asset name with a space', () => { put([...FULL.filter((n) => n !== APPIMAGE), 'Claude Code Usage Bubble_0.6.0_amd64.AppImage']); assert.throws(() => run([TAG, dir], NOW), /whitespace/); }); it('fails on a second .exe asset, whatever its case', () => { put([...FULL, 'claude-code-usage-bubble-setup.exe']); assert.throws(() => run([TAG, dir], NOW), /only \.exe asset allowed/); rmSync(join(dir, 'claude-code-usage-bubble-setup.exe')); put(['Other.EXE']); assert.throws(() => run([TAG, dir], NOW), /only \.exe asset allowed/); }); it('fails when a platform asset or its signature is missing', () => { put(FULL.filter((n) => n !== `${APPIMAGE}.sig`)); assert.throws(() => run([TAG, dir], NOW), /missing release assets: .*AppImage\.sig/); }); it('fails when the AppImage carries another version', () => { put([...FULL.filter((n) => !n.startsWith(APPIMAGE)), 'claude-code-usage-bubble_0.5.0_amd64.AppImage']); assert.throws(() => run([TAG, dir], NOW), /missing release assets/); }); it('fails on an empty signature', () => { put(FULL); writeFileSync(join(dir, 'claude-code-usage-bubble.exe.sig'), '\n'); assert.throws(() => run([TAG, dir], NOW), /exe\.sig is empty/); }); it('fails on bad arguments', () => { assert.throws(() => run([TAG], NOW), /usage/); assert.throws(() => run(['--linux-only', TAG, dir], NOW), /usage/); assert.throws(() => run(['0.6.0', dir], NOW), /not of the form/); }); }); describe('checkAssetNames', () => { it('rejects Linux assets on a Windows-only release', () => { assert.throws( () => checkAssetNames([...expectedAssets(VERSION, true), DEB], VERSION, true), /unexpected release assets/, ); }); it('lists the full asset set', () => { assert.deepEqual(expectedAssets(VERSION, false), [...FULL].sort()); }); }); describe('assetUrl', () => { it('builds the release-download URL', () => { assert.equal( assetUrl('v0.0.0-test', 'claude-code-usage-bubble.exe'), 'https://github.com/tiennm99/claude-code-usage-bubble/releases/download/v0.0.0-test/claude-code-usage-bubble.exe', ); }); });