SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,349 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getUpdateInfo as checkUpdate } from '../src/update/check';
|
||||
|
||||
const getUpdateInfo = (force = false) =>
|
||||
checkUpdate(force, { mode: 'direct', mirror: '', mirrors: [] });
|
||||
|
||||
const LATEST_RELEASE_URL = 'https://api.github.com/repos/SnowLuma/SnowLuma/releases/latest';
|
||||
const NOTES_MAX = 4_000;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const savedUpdateCheck = process.env.SNOWLUMA_UPDATE_CHECK;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function mockFetch(impl: (...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>) {
|
||||
const fetchMock = vi.fn<(...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>>(impl);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.SNOWLUMA_UPDATE_CHECK;
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error('unexpected fetch');
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
if (savedUpdateCheck === undefined) {
|
||||
delete process.env.SNOWLUMA_UPDATE_CHECK;
|
||||
} else {
|
||||
process.env.SNOWLUMA_UPDATE_CHECK = savedUpdateCheck;
|
||||
}
|
||||
});
|
||||
|
||||
vi.mock('@snowluma/common/version', () => ({ APP_VERSION: '0.0.0' }));
|
||||
|
||||
describe('getUpdateInfo', () => {
|
||||
it.each(['0', 'false', 'off', 'no', 'FALSE', ' Off ', 'No'] as const)(
|
||||
'skips the GitHub request when SNOWLUMA_UPDATE_CHECK=%j',
|
||||
async (value) => {
|
||||
process.env.SNOWLUMA_UPDATE_CHECK = value;
|
||||
const fetchMock = mockFetch(async () => jsonResponse({ tag_name: 'v9.9.9' }));
|
||||
const t0 = Date.now();
|
||||
const result = await getUpdateInfo(true);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
current: '0.0.0',
|
||||
latest: null,
|
||||
hasUpdate: false,
|
||||
htmlUrl: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
error: 'disabled',
|
||||
});
|
||||
expect(result.checkedAt).toBeGreaterThanOrEqual(t0);
|
||||
expect(result.checkedAt).toBeLessThanOrEqual(Date.now());
|
||||
},
|
||||
);
|
||||
|
||||
// Must run before any successful check: errors are not cached, but a prior
|
||||
// success stays in the module-level TTL cache and would hide this retry.
|
||||
it('retries after a failed check instead of caching the error', async () => {
|
||||
const fetchMock = mockFetch(async () => {
|
||||
throw new Error('ECONNRESET');
|
||||
});
|
||||
|
||||
const failed = await getUpdateInfo();
|
||||
expect(failed).toMatchObject({
|
||||
current: '0.0.0',
|
||||
latest: null,
|
||||
hasUpdate: false,
|
||||
htmlUrl: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
error: expect.stringContaining('ECONNRESET'),
|
||||
});
|
||||
|
||||
fetchMock.mockImplementation(async () => jsonResponse({ tag_name: 'v1.0.0' }));
|
||||
const ok = await getUpdateInfo();
|
||||
expect(ok.latest).toBe('1.0.0');
|
||||
expect(ok.hasUpdate).toBe(true);
|
||||
expect(ok.error).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it.each(['', '1', 'true', 'yes', 'on', ' '] as const)(
|
||||
'is enabled when SNOWLUMA_UPDATE_CHECK=%j',
|
||||
async (value) => {
|
||||
process.env.SNOWLUMA_UPDATE_CHECK = value;
|
||||
mockFetch(async () => jsonResponse({ tag_name: 'v1.0.0' }));
|
||||
const result = await getUpdateInfo(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.latest).toBe('1.0.0');
|
||||
},
|
||||
);
|
||||
|
||||
it('GETs the latest stable release with the GitHub API headers', async () => {
|
||||
const fetchMock = mockFetch(async () => jsonResponse({ tag_name: 'v1.2.3' }));
|
||||
await getUpdateInfo(true);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(LATEST_RELEASE_URL);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual({
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'SnowLuma/0.0.0',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a newer release onto hasUpdate=true and strips the v prefix', async () => {
|
||||
const t0 = Date.now();
|
||||
mockFetch(async () =>
|
||||
jsonResponse({
|
||||
tag_name: ' v1.14.7 ',
|
||||
html_url: 'https://github.com/SnowLuma/SnowLuma/releases/tag/v1.14.7',
|
||||
body: 'hello',
|
||||
published_at: '2026-06-01T12:00:00Z',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getUpdateInfo(true);
|
||||
expect(result).toMatchObject({
|
||||
current: '0.0.0',
|
||||
latest: '1.14.7',
|
||||
hasUpdate: true,
|
||||
htmlUrl: 'https://github.com/SnowLuma/SnowLuma/releases/tag/v1.14.7',
|
||||
notes: 'hello',
|
||||
publishedAt: '2026-06-01T12:00:00Z',
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.checkedAt).toBeGreaterThanOrEqual(t0);
|
||||
expect(result.checkedAt).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('sets hasUpdate=false when latest is not strictly newer than current', async () => {
|
||||
mockFetch(async () => jsonResponse({ tag_name: '0.0.0' }));
|
||||
const same = await getUpdateInfo(true);
|
||||
expect(same.latest).toBe('0.0.0');
|
||||
expect(same.hasUpdate).toBe(false);
|
||||
|
||||
mockFetch(async () => jsonResponse({ tag_name: 'v0.0.0-rc.1' }));
|
||||
const older = await getUpdateInfo(true);
|
||||
expect(older.latest).toBe('0.0.0-rc.1');
|
||||
expect(older.hasUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a missing body / html_url / published_at as null', async () => {
|
||||
mockFetch(async () => jsonResponse({ tag_name: 'v5.0.0', body: '' }));
|
||||
const result = await getUpdateInfo(true);
|
||||
expect(result).toMatchObject({
|
||||
latest: '5.0.0',
|
||||
hasUpdate: true,
|
||||
htmlUrl: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates release notes to 4000 characters', async () => {
|
||||
mockFetch(async () =>
|
||||
jsonResponse({ tag_name: 'v6.0.0', body: 'n'.repeat(NOTES_MAX + 1) }),
|
||||
);
|
||||
const result = await getUpdateInfo(true);
|
||||
expect(result.notes).toBe('n'.repeat(NOTES_MAX));
|
||||
});
|
||||
|
||||
it('returns github <status> when the API is not ok', async () => {
|
||||
mockFetch(async () => jsonResponse({ message: 'rate limited' }, 403));
|
||||
const result = await getUpdateInfo(true);
|
||||
expect(result).toMatchObject({
|
||||
current: '0.0.0',
|
||||
latest: null,
|
||||
hasUpdate: false,
|
||||
htmlUrl: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
error: expect.stringContaining('GitHub 403'),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{}, { tag_name: ' ' }, { tag_name: '' }] as const)(
|
||||
'returns no tag when tag_name is missing or blank: %j',
|
||||
async (body) => {
|
||||
mockFetch(async () => jsonResponse(body));
|
||||
const result = await getUpdateInfo(true);
|
||||
expect(result.error).toBe('no tag');
|
||||
expect(result.hasUpdate).toBe(false);
|
||||
expect(result.latest).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['v01.2.3', 'v1.2.x', 'v1.2.3.4', 'v1.2.3-rc.01', 'v1.2.3+build..1'])(
|
||||
'reports an invalid release tag without offering an update: %s',
|
||||
async (tag) => {
|
||||
mockFetch(async () => jsonResponse({ tag_name: tag }));
|
||||
expect(await getUpdateInfo(true)).toMatchObject({
|
||||
latest: null,
|
||||
hasUpdate: false,
|
||||
error: 'invalid release version',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves build metadata in release tags', async () => {
|
||||
mockFetch(async () => jsonResponse({ tag_name: 'v1.2.3+build.01' }));
|
||||
expect(await getUpdateInfo(true)).toMatchObject({
|
||||
latest: '1.2.3+build.01',
|
||||
hasUpdate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the Error message when fetch throws an Error', async () => {
|
||||
mockFetch(async () => {
|
||||
throw new Error('getaddrinfo ENOTFOUND');
|
||||
});
|
||||
const result = await getUpdateInfo(true);
|
||||
expect(result.error).toContain('getaddrinfo ENOTFOUND');
|
||||
expect(result.hasUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it('uses network error when fetch throws a non-Error', async () => {
|
||||
mockFetch(async () => {
|
||||
throw 'timeout';
|
||||
});
|
||||
const result = await getUpdateInfo(true);
|
||||
expect(result.error).toContain('timeout');
|
||||
expect(result.hasUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades when the response body is not JSON', async () => {
|
||||
mockFetch(async () => new Response('not-json', { status: 200 }));
|
||||
const result = await getUpdateInfo(true);
|
||||
expect(result.hasUpdate).toBe(false);
|
||||
expect(result.latest).toBeNull();
|
||||
expect(result.error).toEqual(expect.any(String));
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('never throws', async () => {
|
||||
mockFetch(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
await expect(getUpdateInfo(true)).resolves.toMatchObject({
|
||||
error: expect.stringContaining('boom'),
|
||||
hasUpdate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses a successful result until force=true', async () => {
|
||||
const fetchMock = mockFetch(async () =>
|
||||
jsonResponse({
|
||||
tag_name: 'v3.1.0',
|
||||
html_url: 'https://github.com/SnowLuma/SnowLuma/releases/tag/v3.1.0',
|
||||
body: 'cached',
|
||||
published_at: '2026-07-01T00:00:00Z',
|
||||
}),
|
||||
);
|
||||
|
||||
const first = await getUpdateInfo(true);
|
||||
const second = await getUpdateInfo();
|
||||
const third = await getUpdateInfo(false);
|
||||
expect(second).toBe(first);
|
||||
expect(third).toBe(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const forced = await getUpdateInfo(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(forced).toMatchObject({
|
||||
latest: '3.1.0',
|
||||
hasUpdate: true,
|
||||
notes: 'cached',
|
||||
});
|
||||
expect(forced).not.toBe(first);
|
||||
});
|
||||
|
||||
it('keeps a cached success after a forced failed check', async () => {
|
||||
const fetchMock = mockFetch(async () => jsonResponse({ tag_name: 'v4.0.0' }));
|
||||
const good = await getUpdateInfo(true);
|
||||
expect(good.latest).toBe('4.0.0');
|
||||
|
||||
fetchMock.mockImplementation(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const bad = await getUpdateInfo(true);
|
||||
expect(bad.error).toContain('boom');
|
||||
|
||||
const cached = await getUpdateInfo();
|
||||
expect(cached).toBe(good);
|
||||
expect(cached.error).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('collapses concurrent checks onto one fetch', async () => {
|
||||
const pending = deferred<Response>();
|
||||
const fetchMock = mockFetch(() => pending.promise);
|
||||
const p1 = getUpdateInfo(true);
|
||||
const p2 = getUpdateInfo(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
pending.resolve(
|
||||
jsonResponse({
|
||||
tag_name: 'v2.0.0',
|
||||
html_url: 'https://github.com/SnowLuma/SnowLuma/releases/tag/v2.0.0',
|
||||
body: 'two',
|
||||
published_at: '2026-07-01T00:00:00Z',
|
||||
}),
|
||||
);
|
||||
const [a, b] = await Promise.all([p1, p2]);
|
||||
expect(a).toEqual(b);
|
||||
expect(a).toMatchObject({
|
||||
latest: '2.0.0',
|
||||
hasUpdate: true,
|
||||
htmlUrl: 'https://github.com/SnowLuma/SnowLuma/releases/tag/v2.0.0',
|
||||
notes: 'two',
|
||||
publishedAt: '2026-07-01T00:00:00Z',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reuse the previous source configuration cache after a mirror change', async () => {
|
||||
const source = { mode: 'auto', mirror: '', mirrors: ['https://first.example'] } as const;
|
||||
const fetchMock = mockFetch(async () => jsonResponse({ tag_name: 'v3.0.0' }));
|
||||
const first = await checkUpdate(false, { ...source, mirrors: [...source.mirrors] });
|
||||
fetchMock.mockImplementation(async () => jsonResponse({ tag_name: 'v3.0.1' }));
|
||||
const second = await checkUpdate(false, { ...source, mirrors: ['https://second.example'] });
|
||||
expect(first.latest).toBe('3.0.0');
|
||||
expect(second.latest).toBe('3.0.1');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user