diff options
| -rw-r--r-- | frontend/e2e/fixtures/news.fixtures.ts | 137 | ||||
| -rw-r--r-- | frontend/e2e/news-tab.spec.ts | 229 | ||||
| -rw-r--r-- | frontend/package-lock.json | 64 | ||||
| -rw-r--r-- | frontend/package.json | 2 | ||||
| -rw-r--r-- | frontend/playwright.config.ts | 29 |
5 files changed, 461 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"); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e581d0..cd1af87 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "recharts": "^3.8.1" }, "devDependencies": { + "@playwright/test": "^1.56.1", "@types/node": "^22.10.5", "@types/plotly.js": "^2.35.1", "@types/react": "^19.0.2", @@ -24,6 +25,7 @@ "@types/react-plotly.js": "^2.6.4", "eslint": "^9.17.0", "eslint-config-next": "^16.2.6", + "playwright": "^1.56.1", "typescript": "^5.7.2" } }, @@ -1447,6 +1449,22 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@plotly/d3": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.2.tgz", @@ -5057,6 +5075,20 @@ "readable-stream": "^2.0.0" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7403,6 +7435,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/plotly.js": { "version": "2.35.3", "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-2.35.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index fdd6d96..00904de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "recharts": "^3.8.1" }, "devDependencies": { + "@playwright/test": "^1.56.1", "@types/node": "^22.10.5", "@types/plotly.js": "^2.35.1", "@types/react": "^19.0.2", @@ -25,6 +26,7 @@ "@types/react-plotly.js": "^2.6.4", "eslint": "^9.17.0", "eslint-config-next": "^16.2.6", + "playwright": "^1.56.1", "typescript": "^5.7.2" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..6272c6e --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + outputDir: "./e2e/test-results", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: "list", + use: { + baseURL: "http://127.0.0.1:3001", + trace: "on-first-retry" + }, + webServer: { + command: "NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8001 npm run dev -- --hostname 127.0.0.1 --port 3001", + url: "http://127.0.0.1:3001", + reuseExistingServer: !process.env.CI, + timeout: 120_000 + }, + projects: [ + { + name: "chromium-desktop", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 720 } + } + } + ] +}); |
