Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/dbtPowerUserExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Observe failures from the async activation check

On a minor upgrade where the feed resolves but show("auto") throws—for example, a test can stub show to throw—showWhenFeedAvailable does not catch that exception, so checkAndShowOnActivation() rejects. This fire-and-forget call has no rejection handler, producing an unhandled rejection instead of the former synchronous exception being caught by activate's surrounding try; this directly violates C6. Await the promise or attach a catch that reports the failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ea41bb0. checkAndShowOnActivation wraps its whole body (state read/write, setting, fetch, show) in try/catch and logs through dbtTerminal.error, so the fire-and-forget call at activation cannot reject; a show() failure also drops the pending fetch. Tests: "a page that fails to open is logged, and activation still settles" and "a failing state read is logged, and activation still settles".

await this.dbtProjectContainer.detectDBT();
await this.dbtProjectContainer.initializeDBTProjects();
await this.statusBars.initialize();
Expand Down
5 changes: 5 additions & 0 deletions src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ 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. 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",
Expand Down
241 changes: 241 additions & 0 deletions src/test/suite/whatsNewFeed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
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 = `<?xml version="1.0"?><rss><channel>
<lastBuildDate>Tue, 14 Oct 2026 10:00:00 GMT</lastBuildDate>
<item><title>[Feature] Fetch before opening</title><category>dbt-power-user</category>
<link>https://altimate.ai/changelog#fetch</link><pubDate>Mon, 13 Oct 2026 10:00:00 GMT</pubDate>
<description>desc</description></item>
</channel></rss>`;

/**
* 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<string, unknown>();
const responses: Record<string, unknown>[] = [];
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<void>;
fetchManifest: (trigger: "auto" | "manual") => Promise<unknown>;
};
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<string, unknown>) => {
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<unknown>
>;
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 says so", 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.65.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();
});

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" },
);
});
});
Loading
Loading