summaryrefslogtreecommitdiff
path: root/frontend/e2e/news-tab.spec.ts
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/e2e/news-tab.spec.ts')
-rw-r--r--frontend/e2e/news-tab.spec.ts229
1 files changed, 229 insertions, 0 deletions
diff --git a/frontend/e2e/news-tab.spec.ts b/frontend/e2e/news-tab.spec.ts
new file mode 100644
index 0000000..3155cd6
--- /dev/null
+++ b/frontend/e2e/news-tab.spec.ts
@@ -0,0 +1,229 @@
+import { expect, test, type Page, type Route } from "@playwright/test";
+import { fmpFallback, happyPath, nullAggregate, overviewFixture, serverError, upstreamEmpty } from "./fixtures/news.fixtures";
+import type { NewsResponse } from "../types/api";
+
+const newsRoutePattern = "**/api/tickers/*/news";
+const overviewRoutePattern = "**/api/tickers/*/overview";
+const newsPage = "[data-testid='news-page']";
+const newsLoading = "[data-testid='news-loading']";
+const newsError = "[data-testid='news-error']";
+const newsEmpty = "[data-testid='news-empty']";
+const newsFilterEmpty = "[data-testid='news-filter-empty']";
+const newsLede = "[data-testid='news-lede']";
+const newsLedeAggregate = "[data-testid='news-lede-aggregate']";
+const newsRangeFilter = "[data-testid='news-filter-range']";
+const newsSourceFilter = "[data-testid='news-filter-source']";
+const newsSourceMore = "[data-testid='news-filter-source-more']";
+const newsSourceDropdown = "[data-testid='news-source-dropdown']";
+const newsSentimentFilter = "[data-testid='news-filter-sentiment']";
+const newsReadout = "[data-testid='news-readout']";
+const newsFeed = "[data-testid='news-feed']";
+const newsCard = "[data-testid='news-card']";
+const newsCardTitle = "[data-testid='news-card-title']";
+const newsCardSentiment = "[data-testid='news-card-sentiment']";
+const newsCardSource = "[data-testid='news-card-source']";
+
+type MockNews = NewsResponse | typeof serverError;
+
+async function mockNews(page: Page, body: MockNews, status = 200): Promise<void> {
+ await page.route(newsRoutePattern, async (route: Route) => {
+ await route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify(body)
+ });
+ });
+}
+
+async function mockOverview(page: Page): Promise<void> {
+ await page.route(overviewRoutePattern, async (route: Route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(overviewFixture)
+ });
+ });
+}
+
+async function openNews(page: Page): Promise<void> {
+ await page.clock.install();
+ await page.clock.setFixedTime(new Date("2026-06-22T12:00:00.000Z"));
+ await page.goto("/?ticker=AMD&tab=news");
+ await expect(page.locator(newsPage)).toBeVisible();
+}
+
+async function screenshot(page: Page, scenario: string): Promise<void> {
+ await page.screenshot({ path: `./e2e/screenshots/news-${scenario}.png`, fullPage: true });
+}
+
+function overflowNews(): NewsResponse {
+ return {
+ ...happyPath,
+ items: [
+ ...happyPath.items,
+ {
+ id: "amd-tail-source",
+ title: "TailSource flags AMD embedded design wins",
+ summary: "Embedded wins gave a smaller publication a distinct AMD headline for overflow testing.",
+ source: "TailSource",
+ url: "https://example.test/news/amd-tail-source",
+ sentiment: "neutral",
+ published_at: "2026-06-22T10:45:00.000Z"
+ },
+ ...["Bloomberg", "Barrons", "InvestorPlace", "The Information"].map((source, index) => ({
+ id: `amd-overflow-${index}`,
+ title: `${source} tracks AMD catalyst ${index + 1}`,
+ summary: `${source} adds a deterministic overflow source for the News tab filter menu.`,
+ source,
+ url: `https://example.test/news/amd-overflow-${index}`,
+ sentiment: "bullish" as const,
+ published_at: "2026-06-22T09:00:00.000Z"
+ }))
+ ]
+ };
+}
+
+test.describe("News tab", () => {
+ test.beforeEach(async ({ page }) => {
+ await mockOverview(page);
+ });
+
+ test("S1 Happy path shows cards, readout, and aggregate lede", async ({ page }) => {
+ await mockNews(page, happyPath);
+ await openNews(page);
+
+ await expect(page.locator(newsLoading)).toHaveCount(0);
+ await expect(page.locator(newsFeed)).toBeVisible();
+ await expect(page.locator(newsCard).first()).toBeVisible();
+ await expect(page.locator(newsCardTitle).first()).toContainText("AMD AI chip ramp");
+ await expect(page.locator(newsCard)).toHaveCount(happyPath.items.length);
+ await expect(page.locator(newsReadout)).toContainText(/\d+ articles?/i);
+ await expect(page.locator(newsLedeAggregate)).toBeVisible();
+ await screenshot(page, "s1-happy-path");
+ });
+
+ test("S2 Sentiment filter keeps only bullish cards and updates readout", async ({ page }) => {
+ await mockNews(page, happyPath);
+ await openNews(page);
+
+ await page.locator(newsSentimentFilter).getByRole("button", { name: /bull/i }).click();
+
+ await expect(page.locator(newsCard)).toHaveCount(2);
+ await expect(page.locator(newsCardSentiment)).toHaveText([/bull/i, /bull/i]);
+ await expect(page.locator(newsReadout)).toContainText(/2 articles?/i);
+ await screenshot(page, "s2-sentiment-filter");
+ });
+
+ test("S3 Range filter shrinks to 24H results and updates readout", async ({ page }) => {
+ await mockNews(page, happyPath);
+ await openNews(page);
+
+ await page.locator(newsRangeFilter).getByRole("button", { name: /24h/i }).click();
+
+ await expect(page.locator(newsCard)).toHaveCount(3);
+ await expect(page.locator(newsReadout)).toContainText(/3 articles?/i);
+ await screenshot(page, "s3-range-filter");
+ });
+
+ test("S4 Source overflow filters a tail source from More disclosure", async ({ page }) => {
+ await mockNews(page, overflowNews());
+ await openNews(page);
+
+ await expect(page.locator(newsSourceMore)).toContainText(/more/i);
+ await page.locator(newsSourceMore).click();
+ await expect(page.locator(newsSourceDropdown)).toBeVisible();
+ await page.locator(newsSourceDropdown).getByRole("button", { name: /tailsource/i }).click();
+
+ await expect(page.locator(newsCard)).toHaveCount(1);
+ await expect(page.locator(newsCardSource)).toHaveText("TailSource");
+ await expect(page.locator(newsSourceFilter)).toContainText("TailSource");
+ await screenshot(page, "s4-source-overflow");
+ });
+
+ test("S5 FMP fallback shows provider caption", async ({ page }) => {
+ await mockNews(page, fmpFallback);
+ await openNews(page);
+
+ await expect(page.locator(newsLede)).toContainText(/ยท fmp/i);
+ await screenshot(page, "s5-fmp-fallback");
+ });
+
+ test("S6 Article click opens the mocked URL in a new tab", async ({ page }) => {
+ await page.context().route("https://example.test/**", async (route: Route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "text/html",
+ body: "<html><body>mock article</body></html>"
+ });
+ });
+ await mockNews(page, happyPath);
+ await openNews(page);
+
+ await expect(page.locator(newsCard).first()).toHaveAttribute("href", happyPath.items[0].url);
+
+ const [popup] = await Promise.all([
+ page.waitForEvent("popup"),
+ page.locator(newsCard).first().click()
+ ]);
+
+ await expect(popup).toHaveURL(happyPath.items[0].url);
+ await screenshot(page, "s6-article-click");
+ });
+
+ test("S7 No ticker shows the global workbench placeholder", async ({ page }) => {
+ await page.goto("/");
+
+ await expect(page.getByText("Choose a ticker to enter the workbench")).toBeVisible();
+ await screenshot(page, "s7-no-ticker");
+ });
+
+ test("S8 Backend error shows the news error card", async ({ page }) => {
+ await mockNews(page, serverError, 500);
+ await openNews(page);
+
+ await expect(page.locator(newsError)).toBeVisible();
+ await expect(page.locator(newsError)).toContainText("Failed to load news");
+ await screenshot(page, "s8-backend-error");
+ });
+
+ test("S9 Hover resolves the card border to champagne", async ({ page }) => {
+ await mockNews(page, happyPath);
+ await openNews(page);
+
+ await page.locator(newsCard).first().hover();
+
+ await expect(page.locator(newsCard).first()).toHaveCSS("border-color", "rgb(194, 170, 122)");
+ await screenshot(page, "s9-hover");
+ });
+
+ test("S10 Upstream-empty shows the no recent news card", async ({ page }) => {
+ await mockNews(page, upstreamEmpty);
+ await openNews(page);
+
+ await expect(page.locator(newsEmpty)).toBeVisible();
+ await expect(page.locator(newsEmpty)).toContainText("No recent news for AMD");
+ await screenshot(page, "s10-upstream-empty");
+ });
+
+ test("S11 Filter-empty shows no matches after incompatible filters", async ({ page }) => {
+ await mockNews(page, happyPath);
+ await openNews(page);
+
+ await page.locator(newsSentimentFilter).getByRole("button", { name: /bull/i }).click();
+ await page.locator(newsSourceFilter).getByRole("button", { name: /reuters/i }).click();
+
+ await expect(page.locator(newsFilterEmpty)).toBeVisible();
+ await expect(page.locator(newsFilterEmpty)).toContainText("No matches");
+ await screenshot(page, "s11-filter-empty");
+ });
+
+ test("S12 Null aggregate renders lede without aggregate chips or errors", async ({ page }) => {
+ await mockNews(page, nullAggregate);
+ await openNews(page);
+
+ await expect(page.locator(newsLede)).toBeVisible();
+ await expect(page.locator(newsLedeAggregate)).toHaveCount(0);
+ await expect(page.locator(newsError)).toHaveCount(0);
+ await screenshot(page, "s12-null-aggregate");
+ });
+});