Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as NodeModule from "node:module";

import { assert, describe, it } from "@effect/vitest";
import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts";
import { computeReleaseNotes } from "electron-updater/out/providers/GitHubProvider.js";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
Expand All @@ -22,6 +25,57 @@ import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts";
import * as DesktopUpdates from "./DesktopUpdates.ts";
import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts";

type ReleaseFeed = Parameters<typeof computeReleaseNotes>[2];
type RunningVersion = Parameters<typeof computeReleaseNotes>[0];

// electron-updater's own dependencies, which apps/desktop does not declare.
const requireFromUpdater = NodeModule.createRequire(
NodeModule.createRequire(import.meta.url).resolve("electron-updater"),
);
const { parseXml } = requireFromUpdater("builder-util-runtime") as {
parseXml: (xml: string) => ReleaseFeed;
};
const semver = requireFromUpdater("semver") as { parse: (version: string) => RunningVersion };

const releaseTitle = (version: string) =>
version.includes("-nightly.")
? `T3 Code Nightly ${version} (0123456789ab)`
: `T3 Code v${version}`;

const releaseFeed = (...versions: ReadonlyArray<string>) =>
parseXml(
`<feed xmlns="http://www.w3.org/2005/Atom">${versions
.map(
(version) =>
`<entry><link rel="alternate" type="text/html" href="https://github.com/pingdotgg/t3code/releases/tag/v${version}"/><title>${releaseTitle(version)}</title><content type="html">&lt;ul&gt;&lt;li&gt;${version} change&lt;/li&gt;&lt;/ul&gt;</content></entry>`,
)
.join("")}</feed>`,
);

/** The update info electron-updater's GitHub provider reports when /releases/latest names `version`. */
const stableUpdateInfo = (
feed: ReleaseFeed,
version: string,
runningVersion: string,
fullChangelog: boolean,
) => {
const latestRelease =
feed
.getElements("entry")
.find((entry) => entry.element("link").attribute("href").endsWith(`/tag/v${version}`)) ??
feed.element("entry");
return {
version,
releaseName: latestRelease.elementValueOrEmpty("title"),
releaseNotes: computeReleaseNotes(
semver.parse(runningVersion),
fullChangelog,
feed,
latestRelease,
),
};
};

describe("DesktopUpdates", () => {
it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -109,6 +163,51 @@ describe("DesktopUpdates", () => {
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});

it.effect.each([
{
name: "shows no notes for a Stable release missing from the releases feed",
runningVersion: "1.2.3",
feedVersions: ["1.2.5-nightly.20261102.1", "1.2.5-nightly.20261101.1"],
expected: [],
},
{
name: "shows a Stable release's own notes from the releases feed",
runningVersion: "1.2.3",
feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4", "1.2.4-nightly.20261030.1"],
expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }],
},
{
name: "shows a Stable release's notes when switching down from a newer Nightly",
runningVersion: "1.2.5-nightly.20261027.1",
feedVersions: ["1.2.5-nightly.20261102.1", "1.2.4"],
expected: [{ version: "1.2.4", items: ["1.2.4 change"], totalItems: 1 }],
},
])("$name", ({ runningVersion, feedVersions, expected }) => {
const harness = makeHarness();

return Effect.scoped(
Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
yield* updates.configure;

harness.emit(
"update-available",
stableUpdateInfo(
releaseFeed(...feedVersions),
"1.2.4",
runningVersion,
harness.fullChangelog(),
),
);
yield* flushCallbacks;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const state = yield* updates.getState;
assert.equal(state.availableVersion, "1.2.4");
assert.deepEqual(state.releaseNotes, expected);
}),
).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
});

it.effect("preserves a queued installer when the feed has no update", () => {
const harness = makeHarness();

Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as IpcChannels from "../ipc/channels.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopUpdatePolicy from "./DesktopUpdatePolicy.ts";
import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts";
import {
normalizeDesktopUpdateReleaseNotes,
releaseNotesOfOfferedVersion,
} from "./releaseNotes.ts";
import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease";
import { compareSemverVersions } from "@t3tools/shared/semver";
import {
Expand Down Expand Up @@ -72,6 +75,7 @@ const UpdateInfo = Schema.Struct({
version: Schema.String,
// Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately.
releaseNotes: Schema.optional(Schema.Unknown),
releaseName: Schema.optional(Schema.NullOr(Schema.String)),
releaseDate: Schema.optional(Schema.String),
});

Expand Down Expand Up @@ -939,7 +943,7 @@ export const make = Effect.gen(function* () {

const checkedAt = yield* currentIsoTimestamp;
const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes(
info.releaseNotes,
releaseNotesOfOfferedVersion(info),
info.version,
state.channel,
info.releaseDate,
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/updates/releaseNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,22 @@ interface NormalizedDesktopUpdateReleaseNotes {
readonly omittedReleaseCount: number;
}

const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

/**
* Untagged notes come from the feed entry named by releaseName, which is the newest entry when the
* offered release is not on the feed's first page. Keep them only when that entry is the offered one.
*/
export function releaseNotesOfOfferedVersion(info: {
readonly version: string;
readonly releaseNotes?: unknown;
readonly releaseName?: string | null | undefined;
}): unknown {
if (typeof info.releaseNotes !== "string" || !info.releaseName) return info.releaseNotes;
const namesVersion = new RegExp(`(?:^|[^\\w.-])v?${escapeRegExp(info.version)}(?![\\w.-])`);
return namesVersion.test(info.releaseName) ? info.releaseNotes : undefined;
}

/** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */
export function normalizeDesktopUpdateReleaseNotes(
releaseNotes: unknown,
Expand Down
Loading