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 { await page.route(newsRoutePattern, async (route: Route) => { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); }); } async function mockOverview(page: Page): Promise { 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 { 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 { 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: "mock article" }); }); 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"); }); });