diff --git a/src/dbtPowerUserExtension.ts b/src/dbtPowerUserExtension.ts
index 7308f49e4..318bf6a9d 100644
--- a/src/dbtPowerUserExtension.ts
+++ b/src/dbtPowerUserExtension.ts
@@ -190,7 +190,8 @@ export class DBTPowerUserExtension implements Disposable {
await this.mcpServer.updateMcpExtensionApi();
this.dbtProjectContainer.setContext(context);
this.dbtProjectContainer.initializeWalkthrough();
- this.whatsNewPanel.checkAndShowOnActivation();
+ // Settles once the open decision is made; it never rejects.
+ void this.whatsNewPanel.checkAndShowOnActivation();
await this.dbtProjectContainer.detectDBT();
await this.dbtProjectContainer.initializeDBTProjects();
await this.statusBars.initialize();
diff --git a/src/telemetry/events.ts b/src/telemetry/events.ts
index 7fdc210db..ec281293c 100644
--- a/src/telemetry/events.ts
+++ b/src/telemetry/events.ts
@@ -78,6 +78,13 @@ export enum TelemetryEvents {
"AltimateCode/TroubleshootCodeActionClick" = "AltimateCode/TroubleshootCodeActionClick",
"DocumentationEditor/UnitTestGenerateClick" = "DocumentationEditor/UnitTestGenerateClick",
"WhatsNew/PageOpened" = "whats_new_page_opened",
+ // The changelog feed could not be fetched: `reason` is a class (http_403,
+ // timeout, dns, proxy, network, tls, other), `fallback` says whether a cached
+ // manifest stood in. `trigger` is who started the fetch: a page the user
+ // opens while the automatic fetch is in flight shares that fetch, so its
+ // failure reports `auto`. Not an error event: it is the user's network, and
+ // an automatic open is skipped rather than shown broken.
+ "WhatsNew/FeedUnavailable" = "whats_new_feed_unavailable",
// Carries a `source` so the three changelog links (topbar, hero CTA, footer,
// plus the error state) can be told apart.
"WhatsNew/FullChangelogClicked" = "full_changelog_clicked",
diff --git a/src/test/suite/whatsNewFeed.test.ts b/src/test/suite/whatsNewFeed.test.ts
new file mode 100644
index 000000000..faa067b86
--- /dev/null
+++ b/src/test/suite/whatsNewFeed.test.ts
@@ -0,0 +1,242 @@
+import { beforeEach, describe, expect, it, jest } from "@jest/globals";
+import fetch from "node-fetch";
+import {
+ WhatsNewPanel,
+ feedFailureReason,
+} from "../../webview_provider/whatsNewPanel";
+
+const MANIFEST_CACHE_KEY = "whatsNew.cachedManifest";
+
+const FEED = `
+Tue, 14 Oct 2026 10:00:00 GMT
+- [Feature] Fetch before openingdbt-power-user
+https://altimate.ai/changelog#fetchMon, 13 Oct 2026 10:00:00 GMT
+desc
+`;
+
+/**
+ * Builds the panel with only the collaborators the manifest path touches;
+ * node-fetch is the jest-mapped mock, so every network outcome can be
+ * replayed. The response the page receives is captured through
+ * sendResponseToWebview.
+ */
+const manifestHarness = (opts: { trigger?: "auto" | "manual" } = {}) => {
+ const store = new Map();
+ const responses: Record[] = [];
+ const telemetry = {
+ sendTelemetryEvent: jest.fn(),
+ sendTelemetryError: jest.fn(),
+ };
+ const dbtTerminal = { debug: jest.fn(), warn: jest.fn(), error: jest.fn() };
+ const panel = Object.create(WhatsNewPanel.prototype) as Record<
+ string,
+ unknown
+ > & {
+ serveManifest: (id: string) => Promise;
+ fetchManifest: (trigger: "auto" | "manual") => Promise;
+ };
+ panel.dbtProjectContainer = {
+ extensionVersion: "0.66.0",
+ getFromGlobalState: (key: string) => store.get(key),
+ setToGlobalState: (key: string, value: unknown): void => {
+ store.set(key, value);
+ },
+ };
+ panel.telemetry = telemetry;
+ panel.dbtTerminal = dbtTerminal;
+ panel.currentTrigger = opts.trigger ?? "manual";
+ panel.sendResponseToWebview = (r: Record) => {
+ responses.push(r);
+ };
+ return { panel, store, responses, telemetry, dbtTerminal };
+};
+
+// jest.config.js maps node-fetch to src/test/mock/node-fetch.ts (a jest.fn).
+const fetchMock = fetch as unknown as jest.Mock<
+ (...args: unknown[]) => Promise
+>;
+const okFeed = () =>
+ fetchMock.mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: async () => FEED,
+ });
+const httpStatus = (status: number) =>
+ fetchMock.mockResolvedValue({ ok: false, status, text: async () => "" });
+const networkDown = () =>
+ fetchMock.mockRejectedValue(
+ Object.assign(new TypeError("fetch failed"), {
+ cause: { code: "ECONNREFUSED" },
+ }),
+ );
+
+describe("feedFailureReason", () => {
+ it.each([
+ [
+ Object.assign(new Error("This operation was aborted"), {
+ name: "AbortError",
+ }),
+ "timeout",
+ ],
+ [new Error("Feed request failed with 403"), "http_403"],
+ [new Error("Feed request failed with 503"), "http_503"],
+ [
+ new Error(
+ "request to https://x failed, reason: getaddrinfo ENOTFOUND altimate.ai",
+ ),
+ "dns",
+ ],
+ [
+ new Error(
+ "request to https://x failed, reason: unable to verify the first certificate",
+ ),
+ "tls",
+ ],
+ [
+ Object.assign(new TypeError("fetch failed"), {
+ cause: { code: "SELF_SIGNED_CERT_IN_CHAIN" },
+ }),
+ "tls",
+ ],
+ [
+ new Error("request to https://x failed, reason: read ECONNRESET"),
+ "network",
+ ],
+ [
+ new Error(
+ "request to https://x failed, reason: Failed to establish a socket connection to proxies: PROXY 10.0.0.1:8080",
+ ),
+ "proxy",
+ ],
+ [
+ new Error("tunneling socket could not be established, statusCode=407"),
+ "proxy",
+ ],
+ [
+ Object.assign(new TypeError("fetch failed"), {
+ cause: { code: "ECONNREFUSED" },
+ }),
+ "network",
+ ],
+ [
+ new Error(
+ "Client network socket disconnected before secure TLS connection was established",
+ ),
+ "network",
+ ],
+ [new Error("something else entirely"), "other"],
+ ["not even an error", "other"],
+ ])("%s -> %s", (error, reason) => {
+ expect(feedFailureReason(error)).toBe(reason);
+ });
+});
+
+describe("WhatsNewPanel manifest path", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("serves a fetched manifest, caches it and reports the page open", async () => {
+ okFeed();
+ const h = manifestHarness({ trigger: "manual" });
+
+ await h.panel.serveManifest("req-1");
+
+ expect(h.responses).toHaveLength(1);
+ expect(h.responses[0]).toMatchObject({
+ syncRequestId: "req-1",
+ status: true,
+ });
+ const data = h.responses[0].data as { items: unknown[]; version: string };
+ expect(data.items).toHaveLength(1);
+ expect(data.version).toBe("0.66.0");
+ expect(h.store.get(MANIFEST_CACHE_KEY)).toBe(data);
+ expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith(
+ "whats_new_page_opened",
+ { version: "0.66.0", trigger: "manual" },
+ { items_shown: 1 },
+ );
+ expect(h.dbtTerminal.warn).not.toHaveBeenCalled();
+ });
+
+ it("answers a blocked feed with a failed response and a feed-unavailable event, not an error", async () => {
+ httpStatus(403);
+ const h = manifestHarness({ trigger: "manual" });
+
+ await h.panel.serveManifest("req-2");
+
+ expect(h.responses[0]).toMatchObject({
+ syncRequestId: "req-2",
+ status: false,
+ error: "Feed request failed with 403",
+ });
+ expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledTimes(1);
+ expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith(
+ "whats_new_feed_unavailable",
+ { reason: "http_403", trigger: "manual", fallback: "none" },
+ );
+ expect(h.telemetry.sendTelemetryError).not.toHaveBeenCalled();
+ expect(h.dbtTerminal.error).not.toHaveBeenCalled();
+ expect(h.dbtTerminal.warn).toHaveBeenCalledTimes(1);
+ expect(String(h.dbtTerminal.warn.mock.calls[0][1])).toContain("(http_403)");
+ });
+
+ it("falls back to the cached manifest when the network is down, and reports the running version", async () => {
+ networkDown();
+ const h = manifestHarness({ trigger: "auto" });
+ const cached = {
+ product: "dbt-power-user",
+ version: "0.65.0",
+ items: [{ title: "old" }],
+ };
+ h.store.set(MANIFEST_CACHE_KEY, cached);
+
+ await h.panel.serveManifest("req-3");
+
+ expect(h.responses[0]).toMatchObject({ status: true, data: cached });
+ expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith(
+ "whats_new_feed_unavailable",
+ { reason: "network", trigger: "auto", fallback: "cache" },
+ );
+ expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith(
+ "whats_new_page_opened",
+ { version: "0.66.0", trigger: "auto" },
+ { items_shown: 1 },
+ );
+ expect(String(h.dbtTerminal.warn.mock.calls[0][1])).toContain(
+ "showing the cached manifest",
+ );
+ });
+
+ it("serves the manifest fetched before an automatic open without fetching again", async () => {
+ const h = manifestHarness({ trigger: "auto" });
+ const preloaded = {
+ product: "dbt-power-user",
+ version: "0.66.0",
+ items: [],
+ };
+ h.panel.pending = Promise.resolve(preloaded);
+
+ await h.panel.serveManifest("req-4");
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(h.responses[0]).toMatchObject({ status: true, data: preloaded });
+ expect(h.panel.pending).toBeUndefined();
+ expect(h.panel.pendingTaken).toBe(false);
+ });
+
+ it("a timed-out feed is classed as a timeout", async () => {
+ fetchMock.mockRejectedValue(
+ Object.assign(new Error("This operation was aborted"), {
+ name: "AbortError",
+ }),
+ );
+ const h = manifestHarness();
+
+ await expect(h.panel.fetchManifest("auto")).rejects.toThrow("aborted");
+ expect(h.telemetry.sendTelemetryEvent).toHaveBeenCalledWith(
+ "whats_new_feed_unavailable",
+ { reason: "timeout", trigger: "auto", fallback: "none" },
+ );
+ });
+});
diff --git a/src/test/suite/whatsNewTrigger.test.ts b/src/test/suite/whatsNewTrigger.test.ts
index 6de1fb687..7fa621f36 100644
--- a/src/test/suite/whatsNewTrigger.test.ts
+++ b/src/test/suite/whatsNewTrigger.test.ts
@@ -10,9 +10,12 @@ const LAST_SEEN_VERSION_KEY = "whatsNew.lastSeenVersion";
* activation persists the current version whether or not the page opened, so
* each manual reload consumes the trigger and the next attempt tests nothing.
*/
-const activationHarness = (opts: { enabled?: boolean } = {}) => {
+const activationHarness = (
+ opts: { enabled?: boolean; feedDown?: boolean; deferred?: boolean } = {},
+) => {
const store = new Map();
const shown: string[] = [];
+ const manifest = { product: "dbt-power-user", version: "x", items: [] };
const container = {
extensionVersion: "0.0.0",
@@ -27,9 +30,36 @@ const activationHarness = (opts: { enabled?: boolean } = {}) => {
const panel = Object.create(WhatsNewPanel.prototype) as Record<
string,
unknown
- > & { checkAndShowOnActivation: () => void };
+ > & {
+ checkAndShowOnActivation: () => Promise;
+ serveManifest: (id: string) => Promise;
+ noteOpen: (trigger: string) => void;
+ onPanelDisposed: () => void;
+ };
panel.dbtProjectContainer = container;
panel.show = (trigger: string) => shown.push(trigger);
+ const dbtTerminal = { debug: jest.fn(), warn: jest.fn(), error: jest.fn() };
+ const responses: Record[] = [];
+ panel.dbtTerminal = dbtTerminal;
+ panel.telemetry = { sendTelemetryEvent: jest.fn() };
+ panel.sendResponseToWebview = (r: Record) => {
+ responses.push(r);
+ };
+ // The automatic open waits for the feed; a failed fetch is already logged
+ // and reported inside fetchManifest, so here it only rejects.
+ let resolveFetch: (m: typeof manifest) => void = () => undefined;
+ const fetchManifest = jest.fn(() => {
+ if (opts.feedDown) {
+ return Promise.reject(new Error("Feed request failed with 403"));
+ }
+ if (opts.deferred) {
+ return new Promise((resolve) => {
+ resolveFetch = resolve;
+ });
+ }
+ return Promise.resolve(manifest);
+ });
+ panel.fetchManifest = fetchManifest;
(workspace.getConfiguration as jest.Mock).mockReturnValue({
get: (_key: string, fallback: boolean) => opts.enabled ?? fallback,
@@ -37,12 +67,35 @@ const activationHarness = (opts: { enabled?: boolean } = {}) => {
return {
shown,
+ manifest,
+ fetchManifest,
+ pending: () => panel.pending as Promise | undefined,
+ container,
+ panel,
+ dbtTerminal,
+ responses,
+ resolveFetch: (m: typeof manifest) => resolveFetch(m),
+ /** The user runs "Show What's New": the panel exists, the page asks. */
+ openManually: () => {
+ panel.noteOpen("manual");
+ panel._panel = {};
+ return panel.serveManifest("req");
+ },
+ /** Same, but the page is still loading and has not asked yet. */
+ openManuallyPageLoading: () => {
+ panel.noteOpen("manual");
+ panel._panel = {};
+ },
+ /** The page's request, once its webview is up. */
+ pageRequests: () => panel.serveManifest("req"),
+ /** The user closes the page. */
+ closePage: () => panel.onPanelDisposed(),
lastSeen: () => store.get(LAST_SEEN_VERSION_KEY),
setLastSeen: (v: string) => store.set(LAST_SEEN_VERSION_KEY, v),
/** Simulate the extension activating while reporting `version`. */
- activateAs: (version: string) => {
+ activateAs: async (version: string) => {
container.extensionVersion = version;
- panel.checkAndShowOnActivation();
+ await panel.checkAndShowOnActivation();
},
};
};
@@ -52,56 +105,184 @@ describe("WhatsNewPanel.checkAndShowOnActivation", () => {
jest.clearAllMocks();
});
- it("stays silent on a fresh install but records the version", () => {
+ it("stays silent on a fresh install but records the version", async () => {
const h = activationHarness();
- h.activateAs("0.62.1");
+ await h.activateAs("0.62.1");
// A first-time user gets the setup walkthrough, not a changelog for
// changes they were never around for.
expect(h.shown).toEqual([]);
expect(h.lastSeen()).toBe("0.62.1");
});
- it("opens once on a minor upgrade, then stays silent on reload", () => {
+ it("opens once on a minor upgrade, then stays silent on reload", async () => {
const h = activationHarness();
h.setLastSeen("0.62.1");
- h.activateAs("0.63.0");
+ await h.activateAs("0.63.0");
expect(h.shown).toEqual(["auto"]);
expect(h.lastSeen()).toBe("0.63.0");
// Reloading the window must not re-open the page.
- h.activateAs("0.63.0");
+ await h.activateAs("0.63.0");
expect(h.shown).toEqual(["auto"]);
});
- it("replays a realistic release sequence, opening only on minor bumps", () => {
+ it("keeps the page closed when the feed is unreachable and nothing is cached", async () => {
+ // Upgrading on a network that blocks the feed must not greet the user with
+ // an error page; the version is still recorded so the next upgrade is a
+ // fresh decision, not a backlog.
+ const h = activationHarness({ feedDown: true });
+ h.setLastSeen("0.62.1");
+
+ await h.activateAs("0.63.0");
+ expect(h.fetchManifest).toHaveBeenCalledWith("auto");
+ expect(h.shown).toEqual([]);
+ expect(h.pending()).toBeUndefined();
+ expect(h.lastSeen()).toBe("0.63.0");
+ });
+
+ it("fetches before opening and keeps the manifest for the page", async () => {
+ const h = activationHarness();
+ h.setLastSeen("0.62.1");
+
+ await h.activateAs("0.63.0");
+ expect(h.fetchManifest).toHaveBeenCalledTimes(1);
+ expect(h.shown).toEqual(["auto"]);
+ await expect(h.pending()).resolves.toBe(h.manifest);
+ });
+
+ it("a manual open while the activation fetch is in flight shares that fetch", async () => {
+ const h = activationHarness({ deferred: true });
+ h.setLastSeen("0.62.1");
+
+ const activation = h.activateAs("0.63.0"); // fetch still in flight
+ const manual = h.openManually(); // the user runs the command meanwhile
+ h.resolveFetch(h.manifest);
+ await Promise.all([activation, manual]);
+
+ expect(h.fetchManifest).toHaveBeenCalledTimes(1);
+ expect(h.responses[0]).toMatchObject({ status: true, data: h.manifest });
+ // The page the user opened is already in front; nothing to open on top.
+ expect(h.shown).toEqual([]);
+ expect(h.pending()).toBeUndefined();
+ });
+
+ it("a manual page closed before the shared fetch settles is not reopened", async () => {
+ const h = activationHarness({ deferred: true });
+ h.setLastSeen("0.62.1");
+
+ const activation = h.activateAs("0.63.0"); // fetch still in flight
+ const manual = h.openManually(); // the page's request takes the fetch
+ h.closePage(); // the user closes the page before it settles
+ h.resolveFetch(h.manifest);
+ await Promise.all([activation, manual]);
+
+ // The user has had and dismissed the page; nothing comes back on its own.
+ expect(h.shown).toEqual([]);
+ expect(h.fetchManifest).toHaveBeenCalledTimes(1);
+ expect(h.pending()).toBeUndefined();
+ });
+
+ it("a manual page still loading when the fetch settles takes it afterwards", async () => {
+ const h = activationHarness({ deferred: true });
+ h.setLastSeen("0.62.1");
+
+ const activation = h.activateAs("0.63.0"); // fetch still in flight
+ h.openManuallyPageLoading(); // panel exists, webview not up yet
+ h.resolveFetch(h.manifest);
+ await activation;
+ expect(h.shown).toEqual([]); // the user's page is in front
+ await h.pageRequests();
+
+ expect(h.fetchManifest).toHaveBeenCalledTimes(1);
+ expect(h.responses[0]).toMatchObject({ status: true, data: h.manifest });
+ expect(h.pending()).toBeUndefined();
+ });
+
+ it("a manual page closed before it ever asked is neither reopened nor served stale", async () => {
+ const h = activationHarness({ deferred: true });
+ h.setLastSeen("0.62.1");
+
+ const activation = h.activateAs("0.63.0");
+ h.openManuallyPageLoading();
+ h.closePage(); // closed before the webview asked for anything
+ h.resolveFetch(h.manifest);
+ await activation;
+
+ expect(h.shown).toEqual([]);
+ expect(h.pending()).toBeUndefined();
+ // A later manual open fetches afresh.
+ const later = h.openManually();
+ h.resolveFetch(h.manifest);
+ await later;
+ expect(h.fetchManifest).toHaveBeenCalledTimes(2);
+ expect(h.responses[0]).toMatchObject({ status: true, data: h.manifest });
+ });
+
+ it("a page that fails to open is logged, and activation still settles", async () => {
+ const h = activationHarness();
+ h.setLastSeen("0.62.1");
+ h.panel.show = () => {
+ throw new Error("no webview host");
+ };
+
+ await expect(h.activateAs("0.63.0")).resolves.toBeUndefined();
+ expect(h.dbtTerminal.error).toHaveBeenCalledWith(
+ "whatsNew:checkAndShowOnActivation",
+ expect.any(String),
+ expect.any(Error),
+ );
+ expect(h.pending()).toBeUndefined();
+ });
+
+ it("a failing state read is logged, and activation still settles", async () => {
+ const h = activationHarness();
+ h.container.getFromGlobalState = () => {
+ throw new Error("storage gone");
+ };
+
+ await expect(h.activateAs("0.63.0")).resolves.toBeUndefined();
+ expect(h.dbtTerminal.error).toHaveBeenCalledTimes(1);
+ expect(h.shown).toEqual([]);
+ });
+
+ it("does not touch the feed when there is nothing to open", async () => {
+ const h = activationHarness();
+ h.setLastSeen("0.62.1");
+
+ await h.activateAs("0.62.2");
+ expect(h.fetchManifest).not.toHaveBeenCalled();
+ expect(h.shown).toEqual([]);
+ });
+
+ it("replays a realistic release sequence, opening only on minor bumps", async () => {
const h = activationHarness();
h.setLastSeen("0.62.1");
for (const version of ["0.62.2", "0.62.3", "0.63.0", "0.63.1", "1.0.0"]) {
- h.activateAs(version);
+ await h.activateAs(version);
}
expect(h.shown).toEqual(["auto", "auto"]);
expect(h.lastSeen()).toBe("1.0.0");
});
- it("records the version even when suppressed, so no upgrade double-prompts", () => {
+ it("records the version even when suppressed, so no upgrade double-prompts", async () => {
// The persist happens before the decision: a patch release consumes
// nothing, but the stored version still advances.
const h = activationHarness();
h.setLastSeen("0.62.1");
- h.activateAs("0.62.9");
+ await h.activateAs("0.62.9");
expect(h.shown).toEqual([]);
expect(h.lastSeen()).toBe("0.62.9");
});
- it("never opens when the user disabled the setting", () => {
+ it("never opens when the user disabled the setting", async () => {
const h = activationHarness({ enabled: false });
h.setLastSeen("0.62.1");
- h.activateAs("0.63.0");
+ await h.activateAs("0.63.0");
expect(h.shown).toEqual([]);
// Still tracked, so re-enabling doesn't fire a stale backlog.
expect(h.lastSeen()).toBe("0.63.0");
diff --git a/src/webview_provider/whatsNewPanel.ts b/src/webview_provider/whatsNewPanel.ts
index 91824f67f..a9237c4a3 100644
--- a/src/webview_provider/whatsNewPanel.ts
+++ b/src/webview_provider/whatsNewPanel.ts
@@ -55,7 +55,8 @@ const WHATS_NEW_FEED_URL = "https://altimate.ai/changelog.rss.xml";
// Bound the feed fetch so a stalled endpoint can't hang the panel — on timeout
// we abort and fall back to the cached manifest.
const FEED_FETCH_TIMEOUT_MS = 10_000;
-const CHANGELOG_BASE_URL = "https://altimate.ai/changelog?utm_source=dbt-power-user&utm_medium=extension";
+const CHANGELOG_BASE_URL =
+ "https://altimate.ai/changelog?utm_source=dbt-power-user&utm_medium=extension";
const PRODUCT_SLUG = "dbt-power-user";
// globalState keys
@@ -134,11 +135,76 @@ const toAnchor = (link: string): string => {
type WhatsNewTrigger = "auto" | "manual";
+/**
+ * Why a feed fetch failed, as a small closed set for telemetry. The message
+ * itself stays out of the event: it is per-machine network detail (proxy
+ * hosts, DNS names) that cannot be acted on in aggregate, and the class is
+ * what tells a client-side problem (timeouts) from the user's network
+ * (blocked, offline, intercepted TLS).
+ */
+export const feedFailureReason = (error: unknown): string => {
+ const err = error as
+ | {
+ name?: string;
+ message?: string;
+ cause?: { code?: string; message?: string };
+ }
+ | undefined;
+ if (err?.name === "AbortError") {
+ return "timeout";
+ }
+ const text = [err?.message, err?.cause?.code, err?.cause?.message]
+ .filter(Boolean)
+ .join(" ");
+ const status = /Feed request failed with (\d{3})/.exec(text);
+ if (status) {
+ return `http_${status[1]}`;
+ }
+ if (/getaddrinfo|ENOTFOUND|EAI_AGAIN/i.test(text)) {
+ return "dns";
+ }
+ if (/certificate|CERT_|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(text)) {
+ return "tls";
+ }
+ // VS Code's proxy layer ("Failed to establish a socket connection to
+ // proxies: PROXY host:port") and a proxy refusing the CONNECT tunnel
+ // ("tunneling socket could not be established, statusCode=407").
+ if (/\bprox(y|ies)\b|tunneling socket/i.test(text)) {
+ return "proxy";
+ }
+ if (
+ /ECONN|EHOSTUNREACH|ENETUNREACH|ETIMEDOUT|EPIPE|socket hang up|network socket|fetch failed/i.test(
+ text,
+ )
+ ) {
+ return "network";
+ }
+ return "other";
+};
+
export class WhatsNewPanel extends AltimateWebviewProvider {
public static readonly viewType = "dbtPowerUser.WhatsNew";
protected viewPath = "/whats-new";
protected panelDescription = "What's New in dbt Power User";
private currentTrigger: WhatsNewTrigger = "manual";
+ /**
+ * The fetch started for an automatic open. The page's own request awaits
+ * it instead of fetching again, whether the page was opened by that
+ * automatic trigger or by the user in the meantime.
+ */
+ private pending: Promise | undefined;
+ /**
+ * Set once a page's request has taken `pending`. That page was opened by
+ * the user, and it stays theirs even if they close it before the fetch
+ * settles: the automatic open must not bring it back.
+ */
+ private pendingTaken = false;
+ /**
+ * Set when the user opens the page while the activation fetch is in
+ * flight. The automatic open then stands down whatever becomes of that
+ * page: its request takes the fetch, or they closed it before it loaded.
+ */
+ private userOpenedMeanwhile = false;
public constructor(
protected dbtProjectContainer: DBTProjectContainer,
@@ -170,7 +236,7 @@ export class WhatsNewPanel extends AltimateWebviewProvider {
* Code's own Release Notes: a one-off webview in the center, not a sidebar.
*/
public show(trigger: WhatsNewTrigger): void {
- this.currentTrigger = trigger;
+ this.noteOpen(trigger);
if (this._panel) {
(this._panel as WebviewPanel).reveal?.();
@@ -193,16 +259,35 @@ export class WhatsNewPanel extends AltimateWebviewProvider {
);
this._panel = webviewPanel;
- webviewPanel.onDidDispose(() => {
- this._panel = undefined;
- this._webview = undefined;
- this.isWebviewReady = false;
- });
+ webviewPanel.onDidDispose(() => this.onPanelDisposed());
this._webview = webviewPanel.webview;
this.renderWebviewView();
}
+ /** The open decision's bookkeeping, separate from the webview plumbing. */
+ private noteOpen(trigger: WhatsNewTrigger): void {
+ this.currentTrigger = trigger;
+ if (trigger === "manual" && this.pending !== undefined) {
+ this.userOpenedMeanwhile = true;
+ }
+ this.dbtTerminal.debug(
+ "whatsNew:show",
+ `Opening the What's New page (trigger=${trigger})`,
+ );
+ }
+
+ private onPanelDisposed(): void {
+ this._panel = undefined;
+ this._webview = undefined;
+ this.isWebviewReady = false;
+ // A page closed before it asked for the manifest leaves nothing to serve
+ // it to; a later open fetches afresh rather than reading a stale one.
+ if (!this.pendingTaken) {
+ this.pending = undefined;
+ }
+ }
+
protected renderWebviewView(): void {
if (!this._webview) {
return;
@@ -218,29 +303,80 @@ export class WhatsNewPanel extends AltimateWebviewProvider {
* Auto-open decision, run once on activation (mirrors initializeWalkthrough).
* Opens only on a minor/major version bump; patch releases stay silent.
* The current version is always persisted so users are not re-prompted.
+ * The returned promise settles once the open decision is final; it never
+ * rejects.
*/
- public checkAndShowOnActivation(): void {
- const current = this.dbtProjectContainer.extensionVersion;
- const lastSeen = this.dbtProjectContainer.getFromGlobalState(
- LAST_SEEN_VERSION_KEY,
- ) as string | undefined;
-
- this.dbtProjectContainer.setToGlobalState(LAST_SEEN_VERSION_KEY, current);
-
- const enabled = workspace
- .getConfiguration("dbt")
- .get("showChangelogOnUpdate", true);
- if (!enabled) {
- return;
+ public async checkAndShowOnActivation(): Promise {
+ try {
+ const current = this.dbtProjectContainer.extensionVersion;
+ const lastSeen = this.dbtProjectContainer.getFromGlobalState(
+ LAST_SEEN_VERSION_KEY,
+ ) as string | undefined;
+
+ this.dbtProjectContainer.setToGlobalState(LAST_SEEN_VERSION_KEY, current);
+
+ const enabled = workspace
+ .getConfiguration("dbt")
+ .get("showChangelogOnUpdate", true);
+ if (!enabled) {
+ return;
+ }
+
+ // Fresh install: defer to the setup walkthrough rather than the
+ // changelog.
+ if (!lastSeen) {
+ return;
+ }
+
+ if (WhatsNewPanel.isMinorOrMajorUpgrade(lastSeen, current)) {
+ await this.showWhenFeedAvailable();
+ }
+ } catch (error) {
+ // Nothing awaits this at activation; a failure here is ours to log.
+ this.dbtTerminal.error(
+ "whatsNew:checkAndShowOnActivation",
+ "What's New activation check failed",
+ error,
+ );
}
+ }
- // Fresh install: defer to the setup walkthrough rather than the changelog.
- if (!lastSeen) {
+ /**
+ * An automatic open waits for the manifest: a user upgrading into the panel
+ * on a network that blocks the feed must not meet an error page as the first
+ * thing after the update. The fetched manifest is kept so the page does not
+ * fetch a second time once it loads.
+ */
+ private async showWhenFeedAvailable(): Promise {
+ const fetch = this.fetchManifest("auto");
+ this.pending = fetch;
+ this.pendingTaken = false;
+ this.userOpenedMeanwhile = false;
+ let fetched = false;
+ try {
+ await fetch;
+ fetched = true;
+ } catch {
+ // fetchManifest has logged the failure and reported its reason.
+ }
+ // A failed fetch is never replayed to a later open. A successful one is
+ // kept for the page that will ask for it: the one opened below, or one
+ // the user opened meanwhile that is still loading (a page that already
+ // took it has cleared it itself).
+ if (this.pending === fetch && !fetched) {
+ this.pending = undefined;
+ }
+ // The user opened the page while the fetch was in flight (whatever became
+ // of it since), or a page is open for another reason: nothing to open on
+ // top.
+ if (!fetched || this.userOpenedMeanwhile || this._panel !== undefined) {
return;
}
-
- if (WhatsNewPanel.isMinorOrMajorUpgrade(lastSeen, current)) {
+ try {
this.show("auto");
+ } catch (error) {
+ this.pending = undefined;
+ throw error;
}
}
@@ -273,11 +409,7 @@ export class WhatsNewPanel extends AltimateWebviewProvider {
const { command, syncRequestId } = message;
switch (command) {
case "getWhatsNewManifest":
- this.handleSyncRequestFromWebview(
- syncRequestId,
- () => this.getManifestForWebview(),
- command,
- );
+ void this.serveManifest(syncRequestId);
break;
default:
super.handleCommand(message);
@@ -285,14 +417,57 @@ export class WhatsNewPanel extends AltimateWebviewProvider {
}
}
- private async getManifestForWebview(): Promise {
- const manifest = await this.fetchManifest();
+ /**
+ * Answers the page's manifest request. Handled here rather than through
+ * handleSyncRequestFromWebview because an unreachable feed is the network's
+ * condition, not a failure of ours: fetchManifest reports it as
+ * feed-unavailable, and the page renders its offline state from the failed
+ * response.
+ */
+ private async serveManifest(
+ syncRequestId: string | undefined,
+ ): Promise {
+ let manifest: WhatsNewManifest;
+ const shared = this.pending;
+ if (shared) {
+ this.pendingTaken = true;
+ }
+ try {
+ manifest = await (shared ?? this.fetchManifest(this.currentTrigger));
+ } catch (error) {
+ this.sendResponseToWebview({
+ command: "response",
+ syncRequestId,
+ error: error instanceof Error ? error.message : String(error),
+ status: false,
+ });
+ return;
+ } finally {
+ if (shared && this.pending === shared) {
+ this.pending = undefined;
+ this.pendingTaken = false;
+ }
+ }
+ this.dbtTerminal.debug(
+ "whatsNew:serveManifest",
+ `Serving ${manifest.items.length} entries to the page (${shared ? "fetched before opening" : "fetched on request"})`,
+ );
this.telemetry.sendTelemetryEvent(
TelemetryEvents["WhatsNew/PageOpened"],
- { version: manifest.version, trigger: this.currentTrigger },
+ // The running version, not the manifest's: a cached manifest carries
+ // the version it was fetched under.
+ {
+ version: this.dbtProjectContainer.extensionVersion,
+ trigger: this.currentTrigger,
+ },
{ items_shown: manifest.items.length },
);
- return manifest;
+ this.sendResponseToWebview({
+ command: "response",
+ syncRequestId,
+ status: true,
+ data: manifest,
+ });
}
/** Map the changelog feed to this product's entries, newest first. */
@@ -332,7 +507,9 @@ export class WhatsNewPanel extends AltimateWebviewProvider {
};
}
- private async fetchManifest(): Promise {
+ private async fetchManifest(
+ trigger: WhatsNewTrigger,
+ ): Promise {
const version = this.dbtProjectContainer.extensionVersion;
const url = WHATS_NEW_FEED_URL;
@@ -360,15 +537,21 @@ export class WhatsNewPanel extends AltimateWebviewProvider {
return manifest;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
- this.dbtTerminal.warn(
- "whatsNew:fetchManifest",
- `Failed to fetch What's New feed from ${url}: ${detail}`,
- );
- // Offline / endpoint down: fall back to the last successfully fetched
- // manifest so the panel still renders something useful.
+ const reason = feedFailureReason(error);
+ // Offline / blocked / endpoint down: fall back to the last successfully
+ // fetched manifest so the panel still renders something useful.
const cached = this.dbtProjectContainer.getFromGlobalState(
MANIFEST_CACHE_KEY,
) as WhatsNewManifest | undefined;
+ this.dbtTerminal.warn(
+ "whatsNew:fetchManifest",
+ `Failed to fetch What's New feed from ${url} (${reason}): ${detail}` +
+ (cached ? "; showing the cached manifest" : ""),
+ );
+ this.telemetry.sendTelemetryEvent(
+ TelemetryEvents["WhatsNew/FeedUnavailable"],
+ { reason, trigger, fallback: cached ? "cache" : "none" },
+ );
if (cached) {
return cached;
}