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; }