mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-19 12:18:30 -07:00
feat(overlay): add in-app changelog modal (#187)
This commit is contained in:
@@ -112,3 +112,101 @@ test('createCurlFetch requests updater metadata without Electron networking', as
|
||||
assert.equal(calls[0]?.options.encoding, 'buffer');
|
||||
assert.equal(calls[0]?.options.timeout, 65_000);
|
||||
});
|
||||
|
||||
test('curl fetch kills the child process when the caller aborts', async () => {
|
||||
let killed: string | undefined;
|
||||
let settle: ((error: Error | null, stdout: Buffer, stderr: Buffer) => void) | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: ((
|
||||
_file: string,
|
||||
_args: readonly string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void,
|
||||
) => {
|
||||
settle = callback;
|
||||
return {
|
||||
kill: (signal?: string) => {
|
||||
killed = signal;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
|
||||
controller.abort(new Error('deadline reached'));
|
||||
|
||||
await assert.rejects(pending, /deadline reached/);
|
||||
assert.equal(killed, 'SIGKILL', 'the stalled curl process is terminated');
|
||||
assert.ok(settle, 'the callback is still held by the stub');
|
||||
});
|
||||
|
||||
test('curl fetch preserves a non-Error abort reason', async () => {
|
||||
const controller = new AbortController();
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: (() => ({ kill: () => true })) as never,
|
||||
});
|
||||
|
||||
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
|
||||
controller.abort('plain string reason');
|
||||
|
||||
await pending.then(
|
||||
() => assert.fail('expected the aborted request to reject'),
|
||||
(error) => assert.equal(error, 'plain string reason'),
|
||||
);
|
||||
});
|
||||
|
||||
test('curl fetch synthesises an error when the signal carries no reason', async () => {
|
||||
const controller = new AbortController();
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: (() => ({ kill: () => true })) as never,
|
||||
});
|
||||
|
||||
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
|
||||
controller.abort();
|
||||
|
||||
// A bare abort() still supplies a DOMException reason, so that is what surfaces.
|
||||
await pending.then(
|
||||
() => assert.fail('expected the aborted request to reject'),
|
||||
(error) => assert.ok(error instanceof Error),
|
||||
);
|
||||
});
|
||||
|
||||
test('curl fetch rejects immediately when the signal is already aborted', async () => {
|
||||
let spawned = 0;
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('already gone'));
|
||||
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: (() => {
|
||||
spawned += 1;
|
||||
return { kill: () => true };
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
curlFetch('https://example.test/x', { signal: controller.signal }),
|
||||
/already gone/,
|
||||
);
|
||||
assert.equal(spawned, 0, 'no curl process is started for an aborted request');
|
||||
});
|
||||
|
||||
test('curl fetch still resolves normally when no signal is supplied', async () => {
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: ((
|
||||
_file: string,
|
||||
_args: readonly string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void,
|
||||
) => {
|
||||
callback(null, Buffer.from('{"ok":true}'), Buffer.alloc(0));
|
||||
return { kill: () => true };
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const response = await curlFetch('https://example.test/x');
|
||||
assert.equal(response.ok, true);
|
||||
assert.deepEqual(await response.json(), { ok: true });
|
||||
});
|
||||
|
||||
@@ -79,8 +79,24 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
|
||||
];
|
||||
addHeaderArgs(args, init.headers);
|
||||
args.push(url);
|
||||
// curl has its own --max-time, but a caller-supplied signal must be able to
|
||||
// cut the request short of it; otherwise a shorter caller deadline is a lie.
|
||||
const signal = init.signal instanceof AbortSignal ? init.signal : undefined;
|
||||
// Mirror fetch: reject with the caller's reason whatever its type, and only
|
||||
// synthesise an error when the signal carries none.
|
||||
const abortReason = (): unknown =>
|
||||
signal?.reason !== undefined ? signal.reason : new Error('curl request aborted');
|
||||
if (signal?.aborted) throw abortReason();
|
||||
|
||||
const body = await new Promise<Buffer>((resolve, reject) => {
|
||||
execFile(
|
||||
let onAbort: (() => void) | null = null;
|
||||
const settle = (run: () => void) => {
|
||||
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
||||
onAbort = null;
|
||||
run();
|
||||
};
|
||||
|
||||
const child = execFile(
|
||||
curlPath,
|
||||
args,
|
||||
{
|
||||
@@ -93,12 +109,21 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
|
||||
const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
|
||||
const errno = (error as NodeJS.ErrnoException).code;
|
||||
const fallback = errno ? `curl failed (${errno})` : 'curl failed';
|
||||
reject(new Error(stderrMessage.trim() || fallback));
|
||||
settle(() => reject(new Error(stderrMessage.trim() || fallback)));
|
||||
return;
|
||||
}
|
||||
resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
|
||||
settle(() => resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout)));
|
||||
},
|
||||
);
|
||||
|
||||
if (signal) {
|
||||
onAbort = () => {
|
||||
onAbort = null;
|
||||
child.kill('SIGKILL');
|
||||
reject(abortReason());
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -51,6 +51,13 @@ test('compareSemverLike orders prerelease identifiers within the same base versi
|
||||
assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true);
|
||||
});
|
||||
|
||||
test('compareSemverLike ignores build metadata, which carries no precedence', () => {
|
||||
assert.equal(compareSemverLike('0.15.0+build.2', '0.15.0+build.1'), 0);
|
||||
assert.equal(compareSemverLike('0.15.0-rc.1+build.2', '0.15.0-rc.1+build.1'), 0);
|
||||
assert.equal(compareSemverLike('0.15.1+build.1', '0.15.0+build.9') > 0, true);
|
||||
assert.equal(compareSemverLike('0.15.0+build.1', '0.15.0-rc.1') > 0, true);
|
||||
});
|
||||
|
||||
test('findReleaseAsset finds exact asset names only', () => {
|
||||
const release = {
|
||||
tag_name: 'v0.14.1',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { compareSemverLike } from '../../../core/utils/semver-compare';
|
||||
|
||||
export { compareSemverLike };
|
||||
|
||||
export interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
@@ -130,57 +133,3 @@ export function parseReleaseVersion(
|
||||
if (!release) return null;
|
||||
return release.tag_name.replace(/^v/i, '');
|
||||
}
|
||||
|
||||
export function compareSemverLike(a: string, b: string): number {
|
||||
const parse = (
|
||||
value: string,
|
||||
): {
|
||||
core: number[];
|
||||
prerelease: Array<number | string>;
|
||||
} => {
|
||||
const normalized = value.replace(/^v/i, '');
|
||||
const [coreText = '', ...prereleaseParts] = normalized.split('-');
|
||||
const core = coreText
|
||||
.split('.')
|
||||
.slice(0, 3)
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
while (core.length < 3) core.push(0);
|
||||
const prereleaseText = prereleaseParts.join('-');
|
||||
return {
|
||||
core,
|
||||
prerelease: prereleaseText
|
||||
? prereleaseText.split('.').map((part) => {
|
||||
const numeric = Number.parseInt(part, 10);
|
||||
return /^\d+$/.test(part) ? numeric : part;
|
||||
})
|
||||
: [],
|
||||
};
|
||||
};
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const leftPart = left.prerelease[i];
|
||||
const rightPart = right.prerelease[i];
|
||||
if (leftPart === undefined && rightPart === undefined) return 0;
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
return leftPart - rightPart;
|
||||
}
|
||||
if (typeof leftPart === 'number') return -1;
|
||||
if (typeof rightPart === 'number') return 1;
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ test('notifyUpdateAvailable routes notification surfaces from config', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifyUpdateAvailable adds an install action to overlay update notifications', async () => {
|
||||
test('notifyUpdateAvailable adds install and changelog actions to overlay update notifications', async () => {
|
||||
const payloads: OverlayNotificationPayload[] = [];
|
||||
|
||||
await notifyUpdateAvailable(
|
||||
@@ -53,7 +53,10 @@ test('notifyUpdateAvailable adds an install action to overlay update notificatio
|
||||
|
||||
const payload = payloads[0];
|
||||
assert.ok(payload);
|
||||
assert.deepEqual(payload.actions, [{ id: 'install-update', label: 'Update' }]);
|
||||
assert.deepEqual(payload.actions, [
|
||||
{ id: 'install-update', label: 'Update' },
|
||||
{ id: 'view-changelog', label: "What's New", keepOpen: true },
|
||||
]);
|
||||
assert.equal(payload.id, 'subminer-update-available');
|
||||
assert.equal(payload.persistent, true);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||
|
||||
export const UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available';
|
||||
export const INSTALL_UPDATE_ACTION_ID = 'install-update';
|
||||
export const VIEW_CHANGELOG_ACTION_ID = 'view-changelog';
|
||||
|
||||
export interface UpdateNotificationDeps {
|
||||
showSystemNotification: (title: string, body: string) => void;
|
||||
@@ -25,7 +26,10 @@ export async function notifyUpdateAvailable(
|
||||
body: message,
|
||||
variant: 'info',
|
||||
persistent: true,
|
||||
actions: [{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }],
|
||||
actions: [
|
||||
{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' },
|
||||
{ id: VIEW_CHANGELOG_ACTION_ID, label: "What's New", keepOpen: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (options.notificationType === 'osd' || options.notificationType === 'osd-system') {
|
||||
|
||||
Reference in New Issue
Block a user