From 835bb947d9dd20c56a99402329b5577114136b4b Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 22 Jul 2026 23:19:50 +0800 Subject: [PATCH] test: add regression and browser contracts --- package-lock.json | 76 +++++++++++++++++++ package.json | 10 +++ playwright.config.js | 21 ++++++ tests/e2e/app-shell.spec.js | 125 ++++++++++++++++++++++++++++++++ tests/test_frontend_contract.py | 49 +++++++++++++ 5 files changed, 281 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 tests/e2e/app-shell.spec.js create mode 100644 tests/test_frontend_contract.py diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b7c6245 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "xiaobai-review-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xiaobai-review-web", + "devDependencies": { + "@playwright/test": "^1.54.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f700277 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "xiaobai-review-web", + "private": true, + "scripts": { + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.54.1" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..a5541d1 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,21 @@ +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./tests/e2e", + timeout: 30_000, + fullyParallel: false, + reporter: "line", + use: { + baseURL: "http://127.0.0.1:8876", + channel: "msedge", + headless: true, + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + webServer: { + command: "python -m http.server 8876 --bind 127.0.0.1 --directory static", + url: "http://127.0.0.1:8876/index.html", + reuseExistingServer: true, + timeout: 15_000, + }, +}); diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js new file mode 100644 index 0000000..6d1fbba --- /dev/null +++ b/tests/e2e/app-shell.spec.js @@ -0,0 +1,125 @@ +const { test, expect } = require("@playwright/test"); + +const dashboard = { + meta: { + trade_date: "2026-07-22", + requested_date: "2026-07-22", + source: "tushare", + realtime: false, + cached: true, + market_status: "closed", + updated_at: "2026-07-22T15:00:00+08:00", + }, + overview: { + up_count: 2100, + down_count: 2800, + limit_up_count: 42, + limit_down_count: 8, + broken_count: 17, + seal_rate: 71.2, + amount_billion: 12600, + sentiment_score: 48, + }, + limits: [], + broken: [], + down_limits: [], + yesterday_limits: [], + limit_performance: [], + ladders: [], + sectors: [], + sector_rotation: [], +}; + +function session(role = "admin", subscribed = true) { + return { + authenticated: true, + csrf_token: "test-csrf", + user: { + id: role === "admin" ? 1 : 2, + username: role === "admin" ? "admin_user" : "normal_user", + role, + membership: { + active: role === "admin" || subscribed, + subscribed, + is_admin: role === "admin", + }, + }, + }; +} + +async function mockApplication(page, authSession = session()) { + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + let payload = { ok: true }; + if (url.pathname === "/api/auth/me") payload = authSession; + else if (url.pathname === "/api/dashboard") payload = dashboard; + else if (url.pathname === "/api/watchlist" || url.pathname === "/api/notes") payload = { items: [] }; + else if (url.pathname === "/api/search") payload = { groups: { stocks: [], sectors: [], themes: [], indices: [] } }; + else if (url.pathname === "/api/dragon-tiger") { + payload = { + meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" }, + summary: {}, + traders: [], + unclassified_seats: [], + }; + } else if (url.pathname === "/api/sentiment/history") payload = { rows: [], components: [] }; + else if (url.pathname === "/api/rotation/history") payload = { days: [], rows: [], sectors: [] }; + else if (url.pathname === "/api/screener/setup") { + payload = { + trade_date: "20260722", + regime: { id: "repair", label: "修复", confidence: 70, reason: "测试", evidence: [] }, + regimes: [{ id: "repair", label: "修复" }], + factor_data: { ready: false, date_count: 0 }, + llm: { configured: true }, + strategies: [], + }; + } else if (url.pathname === "/api/mentors/setup") payload = { trade_date: "20260722", mentors: [] }; + else if (url.pathname === "/api/heaven/setup") { + await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "测试环境不加载问天数据" }) }); + return; + } + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload) }); + }); +} + +test("admin shell opens every primary workspace and global search", async ({ page }) => { + await mockApplication(page); + await page.goto("/index.html"); + await expect(page.locator("#authGate")).toBeHidden(); + await expect(page.locator("#settingsButton")).toBeVisible(); + await expect(page.locator("#syncButton")).toBeVisible(); + + const views = [ + "sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView", + "performanceView", "ladderView", "rotationView", "dragonView", "screenerView", + "mentorView", "heavenView", "reviewWorkspaceView", + ]; + for (const view of views) { + await page.locator(`[data-view="${view}"]`).first().click(); + await expect(page.locator(`#${view}`)).toHaveClass(/active-view/); + } + + await page.keyboard.press("Control+K"); + await expect(page.locator("#globalSearchDialog")).toBeVisible(); + await expect(page.locator("#globalSearchInput")).toBeFocused(); +}); + +test("regular account cannot see admin controls and member features are gated", async ({ page }) => { + await mockApplication(page, session("user", false)); + await page.goto("/index.html"); + await expect(page.locator("#settingsButton")).toBeHidden(); + await expect(page.locator("#syncButton")).toBeHidden(); + await expect(page.locator("#accountVipLabel")).toHaveText("非会员"); + await page.locator('[data-view="screenerView"]').first().click(); + await expect(page.locator("#screenerView .member-gate")).toBeVisible(); + await expect(page.locator("#screenerRunButton")).toBeDisabled(); +}); + +test("mobile shell stays within the viewport", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await mockApplication(page, session("user", true)); + await page.goto("/index.html"); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(1); + await expect(page.locator("#globalSearchButton")).toBeVisible(); +}); diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py new file mode 100644 index 0000000..9925340 --- /dev/null +++ b/tests/test_frontend_contract.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import re +import unittest +from html.parser import HTMLParser +from pathlib import Path + + +STATIC_DIR = Path(__file__).resolve().parents[1] / "static" + + +class IdCollector(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.ids: list[str] = [] + + def handle_starttag(self, tag, attrs): + self.ids.extend(value for key, value in attrs if key == "id" and value) + + +class FrontendContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8") + cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8") + collector = IdCollector() + collector.feed(cls.html) + cls.ids = collector.ids + + def test_html_ids_are_unique(self): + duplicates = sorted({item for item in self.ids if self.ids.count(item) > 1}) + self.assertEqual(duplicates, []) + + def test_literal_id_selectors_exist_in_html(self): + selectors = set(re.findall(r'querySelector\("#([A-Za-z][A-Za-z0-9_-]*)"\)', self.script)) + selectors.update(re.findall(r'getElementById\("([A-Za-z][A-Za-z0-9_-]*)"\)', self.script)) + selectors.update(re.findall(r'setText\("([A-Za-z][A-Za-z0-9_-]*)"', self.script)) + missing = sorted(selectors - set(self.ids)) + self.assertEqual(missing, []) + + def test_all_primary_views_have_navigation_entries(self): + views = set(re.findall(r'id="([A-Za-z][A-Za-z0-9_-]*View|limitPool)" class="workspace-view', self.html)) + navigation = set(re.findall(r'data-view="([A-Za-z][A-Za-z0-9_-]*)"', self.html)) + self.assertEqual(views, navigation) + self.assertEqual(len(views), 13) + + +if __name__ == "__main__": + unittest.main()