diff options
Diffstat (limited to 'frontend/e2e/news-tab.spec.ts')
| -rw-r--r-- | frontend/e2e/news-tab.spec.ts | 229 |
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"); + }); +}); |
