summaryrefslogtreecommitdiff
path: root/frontend/e2e
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/e2e')
-rw-r--r--frontend/e2e/fixtures/news.fixtures.ts137
-rw-r--r--frontend/e2e/news-tab.spec.ts229
2 files changed, 366 insertions, 0 deletions
diff --git a/frontend/e2e/fixtures/news.fixtures.ts b/frontend/e2e/fixtures/news.fixtures.ts
new file mode 100644
index 0000000..e5a0baf
--- /dev/null
+++ b/frontend/e2e/fixtures/news.fixtures.ts
@@ -0,0 +1,137 @@
+import type { NewsResponse, TickerOverview } from "../../types/api";
+
+export const fetchedAt = "2026-06-22T12:00:00.000Z";
+
+export const happyPath: NewsResponse = {
+ items: [
+ {
+ id: "amd-ai-chip-ramp",
+ title: "AMD AI chip ramp lifts analyst outlook",
+ summary: "Analysts raised their revenue outlook as MI-series demand broadened across hyperscale buyers.",
+ source: "TechWire",
+ url: "https://example.test/news/amd-ai-chip-ramp",
+ sentiment: "bullish",
+ published_at: "2026-06-22T08:00:00.000Z"
+ },
+ {
+ id: "amd-supply-chain-check",
+ title: "Supply checks show steady AMD server demand",
+ summary: "Channel checks pointed to stable enterprise orders and balanced inventory across distributors.",
+ source: "MarketWatch",
+ url: "https://example.test/news/amd-supply-chain-check",
+ sentiment: "neutral",
+ published_at: "2026-06-22T06:30:00.000Z"
+ },
+ {
+ id: "amd-margin-pressure",
+ title: "AMD margin debate weighs on chip peers",
+ summary: "Investors weighed higher accelerator costs against a stronger data center backlog.",
+ source: "Reuters",
+ url: "https://example.test/news/amd-margin-pressure",
+ sentiment: "bearish",
+ published_at: "2026-06-22T04:15:00.000Z"
+ },
+ {
+ id: "amd-gaming-cycle",
+ title: "Console cycle adds a slower but positive AMD catalyst",
+ summary: "A refreshed gaming cycle added a longer-dated revenue contributor outside the AI segment.",
+ source: "CNBC",
+ url: "https://example.test/news/amd-gaming-cycle",
+ sentiment: "bullish",
+ published_at: "2026-06-18T15:45:00.000Z"
+ }
+ ],
+ aggregate: {
+ buzz_articles_last_week: 42,
+ bullish_pct: 0.5,
+ neutral_pct: 0.25,
+ bearish_pct: 0.25
+ },
+ provider: "finnhub",
+ fetched_at: fetchedAt
+};
+
+export const fmpFallback: NewsResponse = {
+ ...happyPath,
+ provider: "fmp",
+ fetched_at: "2026-06-22T12:05:00.000Z"
+};
+
+export const serverError = {
+ detail: "news unavailable"
+} as const;
+
+export const upstreamEmpty: NewsResponse = {
+ items: [],
+ aggregate: null,
+ provider: null,
+ fetched_at: "2026-06-22T12:10:00.000Z"
+};
+
+export const nullAggregate: NewsResponse = {
+ items: happyPath.items.slice(0, 3),
+ aggregate: null,
+ provider: "finnhub",
+ fetched_at: "2026-06-22T12:15:00.000Z"
+};
+
+export const overviewFixture: TickerOverview = {
+ profile: {
+ symbol: "AMD",
+ name: "Advanced Micro Devices, Inc.",
+ sector: "Technology",
+ industry: "Semiconductors",
+ exchange: "NASDAQ",
+ website: "https://www.amd.com",
+ summary: "AMD designs and sells microprocessors and related technologies.",
+ },
+ quote: {
+ price: 145.5,
+ prev_close: 142.3,
+ change: 3.2,
+ change_pct: 0.0225,
+ },
+ signals: [],
+ stats: {
+ market_cap: 235000000000,
+ trailing_pe: 35.4,
+ trailing_eps: 4.11,
+ volume: 45000000,
+ average_volume: 52000000,
+ beta: 1.65,
+ },
+ ratios: {
+ price_to_book: 3.2,
+ price_to_sales: 7.1,
+ ev_to_sales: 6.8,
+ ev_to_ebitda: 28.5,
+ gross_margin_ttm: 0.48,
+ operating_margin_ttm: 0.08,
+ net_margin_ttm: 0.06,
+ roe_ttm: 0.04,
+ roa_ttm: 0.02,
+ roic_ttm: 0.03,
+ debt_to_equity: 0.15,
+ current_ratio: 2.4,
+ dividend_yield_ttm: null,
+ dividend_payout_ratio_ttm: null,
+ },
+ range_52w: {
+ low: 100.0,
+ high: 200.0,
+ price: 145.5,
+ },
+ short_interest: {
+ short_percent_of_float: 0.02,
+ short_ratio: 1.5,
+ shares_short: 15000000,
+ shares_short_prior_month: 14000000,
+ shares_short_delta_pct: 0.07,
+ },
+ meta: {
+ status: "complete",
+ is_partial: false,
+ field_availability: {},
+ sources: { "quote.price": "yahoo_finance" },
+ },
+};
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");
+ });
+});