web: tests E2E Playwright hors ligne (playground, auth, dashboard/admin — API simulée via page.route), config preview :4173, doc PLAYGROUND.md (schéma du catalogue, contrat des liens profonds)
7 changed files +602 −0
modified
.gitignore
+2 −0
@@ -18,6 +18,8 @@ __pycache__/ | ||
| 18 | 18 | *.pyc |
| 19 | 19 | node_modules/ |
| 20 | 20 | hfmarketdata/web/dist/ |
| 21 | +hfmarketdata/web/test-results/ | |
| 22 | +hfmarketdata/web/playwright-report/ | |
| 21 | 23 | hfmarketdata/venv/ |
| 22 | 24 | |
| 23 | 25 | # OS / editor |
added
hfmarketdata/web/PLAYGROUND.md
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +# Playground — catalog, deep links, how to extend | |
| 2 | + | |
| 3 | +The playground (`/playground`, also embedded in `/dashboard/playground`) is a single reusable component: | |
| 4 | + | |
| 5 | +```jsx | |
| 6 | +import Playground from './src/playground/Playground.jsx' | |
| 7 | +<Playground apiKey={sessionKey} embedded /> // apiKey optional → keyless mode | |
| 8 | +``` | |
| 9 | + | |
| 10 | +`apiKey` is sent as `Authorization: Bearer …` on every run and on the WebSocket URL (`?api_key=`). It lives in React state only — never localStorage, sessionStorage, cookies or the address bar. Code exports always print `$HFMD_API_KEY`. | |
| 11 | + | |
| 12 | +## Files | |
| 13 | + | |
| 14 | +| File | Role | | |
| 15 | +|---|---| | |
| 16 | +| `src/playground/catalog.js` | The request types (data only) + resolution/validation helpers | | |
| 17 | +| `src/playground/Playground.jsx` | The component: catalog nav, form, URL preview, run, result views, quota panel, code export, presets | | |
| 18 | +| `src/playground/RequestForm.jsx` | Form generated from `entry.params` | | |
| 19 | +| `src/playground/SymbolInput.jsx` | Debounced autocomplete (`/v1/{asset}/tickers?search=`, `/v1/futures/roots`, `/v1/options/tickers`) | | |
| 20 | +| `src/playground/views/{JsonView,TableView,ChartView}.jsx` | Collapsible JSON · virtualised sortable table (50 000 rows) · lightweight-charts (candles / line, roll markers) | | |
| 21 | +| `src/playground/RateLimitPanel.jsx` | `X-RateLimit-*` bars, reset countdown, 429 `Retry-After` countdown | | |
| 22 | +| `src/playground/WebSocketTab.jsx` | `/v1/stream` client (disabled without key) | | |
| 23 | +| `src/playground/codegen.js` | curl / Python (requests + pandas) / JavaScript (fetch) generators | | |
| 24 | +| `src/playground/presets.js` | One-click examples | | |
| 25 | +| `src/components/Pg{Tabs,CodeBlock,Callout,CopyButton}.jsx` | Local UI primitives (swap for web-core's shared ones when available) | | |
| 26 | + | |
| 27 | +## Catalog schema | |
| 28 | + | |
| 29 | +```js | |
| 30 | +{ | |
| 31 | + id: 'futures-continuous', // stable, URL-safe — used in deep links and tests | |
| 32 | + group: 'Futures', // one of GROUPS: Bars · Futures · Options · Fundamentals · Symbols · Stream | |
| 33 | + title: 'Futures continuous (v2)', | |
| 34 | + method: 'GET', // or 'WS' for the stream tab | |
| 35 | + path: '/v1/futures/{root}/continuous', | |
| 36 | + operationIds: ['futures_continuous_v1_futures__root__continuous_get'], // OpenAPI ids that map here | |
| 37 | + description: 'Markdown-free one-liner shown above the form', | |
| 38 | + fixed: { asset: 'crypto' }, // optional: values injected in the path/query but hidden from the form | |
| 39 | + requiresKey: false, // stream only | |
| 40 | + params: [ | |
| 41 | + { name: 'root', in: 'path', type: 'symbol', required: true, source: 'futures_root', default: 'ES', placeholder: 'ES', help: '…' }, | |
| 42 | + { name: 'roll', in: 'query', type: 'enum', enum: ['', 'volume', 'open_interest', 'calendar'], default: 'volume' }, | |
| 43 | + { name: 'depth', in: 'query', type: 'number', default: 1, min: 1, max: 12 }, | |
| 44 | + { name: 'from', in: 'query', type: 'date' }, // native <input type=date> | |
| 45 | + { name: 'start', in: 'query', type: 'datetime' }, // native datetime-local, sent as "YYYY-MM-DD HH:MM:SS" | |
| 46 | + { name: 'adjustment', in: 'query', type: 'enum', enumBy: { asset: { stock: [...], futures: [...] } }, enum: [''] }, // dependent enum | |
| 47 | + { name: 'symbol', in: 'path', type: 'text', pattern: '^[A-Z]{1,3}[FGHJKMNQUVXZ]\\d{2}$' }, | |
| 48 | + ], | |
| 49 | + result: { | |
| 50 | + kind: 'bars' | 'series' | 'table' | 'list' | 'object' | 'ws' | 'auto', | |
| 51 | + timeKey: 'datetime', // x axis hint for the chart | |
| 52 | + valueKeys: ['close', 'settle'], // preferred y column(s) for line charts | |
| 53 | + listKey: 'tickers', // for `list` kinds: JSON key holding a flat array | |
| 54 | + envelope: 'v2', // rows live in `data`, extras in `meta` (roll_dates → chart markers) | |
| 55 | + }, | |
| 56 | +} | |
| 57 | +``` | |
| 58 | + | |
| 59 | +Param types → controls: `symbol` (autocomplete), `enum` (select; `''` = server default), `number`, `date`, `datetime`, `text` (optional `pattern`), `bool`. Validation (`validate()`) enforces required, numeric bounds, date formats, patterns and enum membership; Run is disabled until the form is valid. | |
| 60 | + | |
| 61 | +Result routing after a run: `bars`/`series` with a detectable time column → Chart tab first; any rows → Table; otherwise JSON. `format=csv` parses the CSV for the table and offers a download; `format=parquet` offers the download only. | |
| 62 | + | |
| 63 | +## Deep-link contract | |
| 64 | + | |
| 65 | +``` | |
| 66 | +/playground?ep=<ref>&<param>=<value>&… | |
| 67 | +``` | |
| 68 | + | |
| 69 | +- `ref` is either a **catalog id** (`stock-bars`, `futures-chain`, …) or an **OpenAPI `operationId`** (`bars_v1_bars__asset___ticker__get`, `opt_chain_v1_options_chain__ticker__get`, …). FastAPI's default naming is `<function>_<path with / and {} → _>_<method>`. | |
| 70 | +- Every other query parameter is a form value (path or query param of the entry). Unknown names are ignored for curated entries and kept for spec-generated ones. | |
| 71 | +- Resolution order (`resolveEntry()` in `Playground.jsx`): | |
| 72 | + 1. catalog `id` match; | |
| 73 | + 2. entry whose `operationIds` contains `ref` — when several entries share an operation (e.g. `bars_v1_bars__asset___ticker__get` → Stock/ETF, Crypto, FX, Index, legacy futures) the one whose `fixed` values match the supplied params wins (`asset=crypto` → Crypto bars); | |
| 74 | + 3. otherwise `/openapi.json` is fetched, `ref` is looked up, and the entry with the same **method + path template** is used; | |
| 75 | + 4. otherwise a **generic form is generated from the spec** (path/query parameters, enums from `schema.enum` or `^(a|b)$` patterns, defaults, descriptions) — a notice says so; | |
| 76 | + 5. otherwise a warning is shown and the default entry (`stock-bars`) is loaded. | |
| 77 | +- The address bar is rewritten (`history.replace`) to `?ep=<catalog id>&<non-default values>` so links copied from the browser are short and stable. Spec-generated entries keep `ep=<operationId>`. | |
| 78 | +- Docs "Try it" buttons should therefore link to `/playground?ep=${operation.operationId}&${exampleParams}`. No coordination with the catalog is needed: unknown operations still get a working form. | |
| 79 | +- The dashboard mounts the same component under `/dashboard/playground` — the same query contract applies there. | |
| 80 | + | |
| 81 | +## Presets (`presets.js`) | |
| 82 | + | |
| 83 | +| Label | Entry | Values | | |
| 84 | +|---|---|---| | |
| 85 | +| AAPL 1-min today | `stock-bars` | `ticker=AAPL timeframe=1min start=<today>` | | |
| 86 | +| CL chain | `futures-chain` | `root=CL` | | |
| 87 | +| ES continuous back-adjusted 2015-2025 | `futures-continuous` | `root=ES roll=volume adjust=back_adjusted depth=1 interval=1d from=2015-01-01 to=2025-12-31` | | |
| 88 | +| NG term structure | `futures-term-structure` | `root=NG` | | |
| 89 | +| AAPL income statements (quarterly) | `fundamentals-statements` | `ticker=AAPL statement=income period=quarterly` | | |
| 90 | +| Screener: PE<15 & ROE>15% | `fundamentals-screener` | `filter=pe<15,roe>0.15 sort=-market_cap` | | |
| 91 | + | |
| 92 | +## Adding a request type | |
| 93 | + | |
| 94 | +1. Append an entry to `CATALOG` in `catalog.js` (copy the closest one). Put the FastAPI `operationId` in `operationIds` — check it in `/openapi.json` once the route exists. | |
| 95 | +2. If it belongs to a new area, add the group name to `GROUPS` (ordering of the left column). | |
| 96 | +3. Set `result` so the right view opens first (`bars` for OHLC, `series` + `timeKey`/`valueKeys` for one line, `list` + `listKey` for flat arrays). | |
| 97 | +4. Optional: a preset in `presets.js`, a symbol `source` in `SymbolInput.jsx` if a new autocomplete endpoint is needed. | |
| 98 | +5. Add an assertion in `e2e/playground.spec.js` (deep link → URL preview) and run `npm run test:e2e`. | |
| 99 | + | |
| 100 | +Errors: any non-2xx JSON envelope `{"error":{code,message,docs}}` is displayed with a Docs button; a bare FastAPI `{"detail":"Not Found"}` 404 is rendered as "Endpoint not available yet — coming soon" (the v2 modules are deployed independently). | |
| 101 | + | |
| 102 | +## Tests | |
| 103 | + | |
| 104 | +`npm run test:e2e` (Playwright, Chromium). The config builds the site and serves `vite preview` on `:4173`; specs mock every `/v1/*` and `/openapi.json` call with `page.route`, so the suite runs offline. | |
added
hfmarketdata/web/e2e/auth.spec.js
+112 −0
@@ -0,0 +1,112 @@ | ||
| 1 | +import { expect, test } from '@playwright/test' | |
| 2 | +import { USER, json, mockSession } from './mocks.js' | |
| 3 | + | |
| 4 | +const err = (code, message, status) => json({ error: { code, message, docs: `https://www.hfmarketdata.io/docs/errors#${code}` }, detail: message }, status) | |
| 5 | + | |
| 6 | +test('sign in: inline validation, wrong credentials, then success redirects to ?next', async ({ page }) => { | |
| 7 | + const state = { user: null } | |
| 8 | + await mockSession(page, state, async (url, route, req) => { | |
| 9 | + if (url.pathname === '/v1/auth/login') { | |
| 10 | + const body = req.postDataJSON() | |
| 11 | + if (body.password !== 'correct-horse-battery') return err('INVALID_CREDENTIALS', 'Bad credentials', 401) | |
| 12 | + state.user = USER | |
| 13 | + return json({ data: USER }) | |
| 14 | + } | |
| 15 | + if (url.pathname === '/v1/me/keys') return json({ data: [] }) | |
| 16 | + if (url.pathname === '/v1/me/usage') return json({ data: { series: [], totals: { requests: 0, rows: 0 } } }) | |
| 17 | + return null | |
| 18 | + }) | |
| 19 | + await page.goto('/signin?next=/dashboard/keys') | |
| 20 | + await page.getByRole('button', { name: 'Sign in' }).click() | |
| 21 | + await expect(page.getByText('Enter a valid e-mail address.')).toBeVisible() | |
| 22 | + await expect(page.getByText('Enter your password.')).toBeVisible() | |
| 23 | + await page.getByLabel('E-mail').fill('ada@example.com') | |
| 24 | + await page.getByLabel('Password').fill('nope-nope-nope') | |
| 25 | + await page.getByRole('button', { name: 'Sign in' }).click() | |
| 26 | + await expect(page.getByText('Incorrect e-mail or password.')).toBeVisible() | |
| 27 | + await page.getByLabel('Password').fill('correct-horse-battery') | |
| 28 | + await page.getByRole('button', { name: 'Sign in' }).click() | |
| 29 | + await expect(page).toHaveURL(/\/dashboard\/keys$/) | |
| 30 | + await expect(page.getByRole('heading', { name: 'API keys' })).toBeVisible() | |
| 31 | +}) | |
| 32 | + | |
| 33 | +test('sign up: validation, EMAIL_TAKEN envelope, then 202 success state', async ({ page }) => { | |
| 34 | + let calls = 0 | |
| 35 | + await mockSession(page, { user: null }, async url => { | |
| 36 | + if (url.pathname === '/v1/auth/signup') { calls++; return calls === 1 ? err('EMAIL_TAKEN', 'taken', 409) : { status: 202, contentType: 'application/json', body: '{}' } } | |
| 37 | + return null | |
| 38 | + }) | |
| 39 | + await page.goto('/signup') | |
| 40 | + await page.getByRole('button', { name: 'Create free account' }).click() | |
| 41 | + await expect(page.getByText('Enter your name.')).toBeVisible() | |
| 42 | + await page.getByLabel('Name').fill('Ada Lovelace') | |
| 43 | + await page.getByLabel('E-mail').fill('ada@example.com') | |
| 44 | + await page.getByLabel('Password').fill('short') | |
| 45 | + await page.getByRole('button', { name: 'Create free account' }).click() | |
| 46 | + await expect(page.getByText('Use at least 10 characters.')).toBeVisible() | |
| 47 | + await page.getByLabel('Password').fill('a-long-enough-password') | |
| 48 | + await page.getByRole('button', { name: 'Create free account' }).click() | |
| 49 | + await expect(page.getByText(/An account already exists for this e-mail/)).toBeVisible() | |
| 50 | + await page.getByRole('button', { name: 'Create free account' }).click() | |
| 51 | + await expect(page.getByTestId('signup-success')).toContainText('Check your inbox') | |
| 52 | + await expect(page.getByTestId('signup-success')).toContainText('ada@example.com') | |
| 53 | +}) | |
| 54 | + | |
| 55 | +test('verify: with token → success; without token → missing; invalid → INVALID_TOKEN', async ({ page }) => { | |
| 56 | + await mockSession(page, { user: null }, async url => { | |
| 57 | + if (url.pathname === '/v1/auth/verify') return url.searchParams.get('token') === 'good' ? json({ ok: true }) : err('INVALID_TOKEN', 'expired', 400) | |
| 58 | + return null | |
| 59 | + }) | |
| 60 | + await page.goto('/verify?token=good') | |
| 61 | + await expect(page.getByTestId('verify-success')).toContainText('E-mail verified') | |
| 62 | + await page.goto('/verify') | |
| 63 | + await expect(page.getByText('Missing token')).toBeVisible() | |
| 64 | + await page.goto('/verify?token=bad') | |
| 65 | + await expect(page.getByText('This link is invalid or has expired. Request a new one.')).toBeVisible() | |
| 66 | +}) | |
| 67 | + | |
| 68 | +test('reset: request link, then set a new password via ?token=', async ({ page }) => { | |
| 69 | + const posted = [] | |
| 70 | + await mockSession(page, { user: null }, async (url, route, req) => { | |
| 71 | + if (url.pathname === '/v1/auth/forgot' || url.pathname === '/v1/auth/reset') { posted.push([url.pathname, req.postDataJSON()]); return json({ ok: true }) } | |
| 72 | + return null | |
| 73 | + }) | |
| 74 | + await page.goto('/reset') | |
| 75 | + await page.getByLabel('E-mail').fill('ada@example.com') | |
| 76 | + await page.getByRole('button', { name: 'Send reset link' }).click() | |
| 77 | + await expect(page.getByTestId('reset-requested')).toContainText('Check your inbox') | |
| 78 | + await page.goto('/reset?token=tok123') | |
| 79 | + await page.getByLabel('New password').fill('brand-new-password') | |
| 80 | + await page.getByLabel('Confirm password').fill('brand-new-passwor') | |
| 81 | + await page.getByRole('button', { name: 'Set password' }).click() | |
| 82 | + await expect(page.getByText('Passwords do not match.')).toBeVisible() | |
| 83 | + await page.getByLabel('Confirm password').fill('brand-new-password') | |
| 84 | + await page.getByRole('button', { name: 'Set password' }).click() | |
| 85 | + await expect(page.getByTestId('reset-done')).toContainText('Password updated') | |
| 86 | + expect(posted).toEqual([['/v1/auth/forgot', { email: 'ada@example.com' }], ['/v1/auth/reset', { token: 'tok123', password: 'brand-new-password' }]]) | |
| 87 | +}) | |
| 88 | + | |
| 89 | +test('invite: set password → accept-invite → lands on the dashboard signed in', async ({ page }) => { | |
| 90 | + const state = { user: null } | |
| 91 | + await mockSession(page, state, async url => { | |
| 92 | + if (url.pathname === '/v1/auth/accept-invite') { state.user = USER; return json({ data: USER }) } | |
| 93 | + if (url.pathname.startsWith('/v1/me/')) return json({ data: [] }) | |
| 94 | + return null | |
| 95 | + }) | |
| 96 | + await page.goto('/invite?token=inv42') | |
| 97 | + await expect(page.getByText(/You were invited to HF Market Data/)).toBeVisible() | |
| 98 | + await page.getByLabel('New password').fill('welcome-aboard-2026') | |
| 99 | + await page.getByLabel('Confirm password').fill('welcome-aboard-2026') | |
| 100 | + await page.getByRole('button', { name: 'Set password' }).click() | |
| 101 | + await expect(page).toHaveURL(/\/dashboard$/) | |
| 102 | + await expect(page.getByTestId('dashboard')).toContainText('ada@example.com') | |
| 103 | +}) | |
| 104 | + | |
| 105 | +test('accounts not deployed yet (404) is explained, not crashed', async ({ page }) => { | |
| 106 | + await mockSession(page, { user: null }) | |
| 107 | + await page.goto('/signin') | |
| 108 | + await page.getByLabel('E-mail').fill('ada@example.com') | |
| 109 | + await page.getByLabel('Password').fill('whatever-password') | |
| 110 | + await page.getByRole('button', { name: 'Sign in' }).click() | |
| 111 | + await expect(page.getByText(/Accounts are not enabled on this server yet/)).toBeVisible() | |
| 112 | +}) | |
added
hfmarketdata/web/e2e/dashboard.spec.js
+177 −0
@@ -0,0 +1,177 @@ | ||
| 1 | +import { expect, test } from '@playwright/test' | |
| 2 | +import { ADMIN, USER, bars, json, mockSession } from './mocks.js' | |
| 3 | + | |
| 4 | +const now = Date.now() | |
| 5 | +const series = Array.from({ length: 24 }, (_, i) => ({ ts: new Date(now - (23 - i) * 3600_000).toISOString().slice(0, 16) + ':00Z', requests: 10 + i, rows: 1000 * (i + 1), status_429: i === 5 ? 2 : 0 })) | |
| 6 | +const KEY = 'hfmd_live_ab12cd34EFGH5678ijkl9012MNOP3456' | |
| 7 | + | |
| 8 | +function meHandler(state) { | |
| 9 | + return async (url, route, req) => { | |
| 10 | + const m = req.method() | |
| 11 | + if (url.pathname === '/v1/me/usage') return json({ data: { series, totals: { requests: series.reduce((a, s) => a + s.requests, 0), rows: series.reduce((a, s) => a + s.rows, 0) }, tier: 'free', limits: USER.limits } }) | |
| 12 | + if (url.pathname === '/v1/me/keys' && m === 'GET') return json({ data: state.keys }) | |
| 13 | + if (url.pathname === '/v1/me/keys' && m === 'POST') { | |
| 14 | + const k = { id: state.keys.length + 1, name: req.postDataJSON().name, prefix: 'hfmd_live_ab12cd34', status: 'active', created_at: new Date().toISOString(), last_used_at: null } | |
| 15 | + state.keys.push(k); state.posts.push(['POST', url.pathname, req.postDataJSON()]) | |
| 16 | + return json({ data: { ...k, key: KEY } }) | |
| 17 | + } | |
| 18 | + const del = url.pathname.match(/^\/v1\/me\/keys\/(\d+)$/) | |
| 19 | + if (del && m === 'DELETE') { state.keys = state.keys.map(k => (k.id === Number(del[1]) ? { ...k, status: 'revoked' } : k)); state.posts.push(['DELETE', url.pathname]); return { status: 204, body: '' } } | |
| 20 | + const rot = url.pathname.match(/^\/v1\/me\/keys\/(\d+)\/rotate$/) | |
| 21 | + if (rot && m === 'POST') { state.posts.push(['POST', url.pathname]); return json({ data: { id: 99, name: 'rotated', prefix: 'hfmd_live_zz99', status: 'active', key: 'hfmd_live_zz99ROTATEDKEYzz99ROTATEDKEYzz' } }) } | |
| 22 | + if (url.pathname === '/v1/bars/stock/AAPL') { state.auth = req.headers().authorization || null; return json({ count: 3, data: bars(3) }) } | |
| 23 | + if (url.pathname === '/v1/auth/forgot') { state.posts.push(['POST', url.pathname, req.postDataJSON()]); return json({ ok: true }) } | |
| 24 | + return null | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +test('route guard redirects anonymous visitors to /signin?next=', async ({ page }) => { | |
| 29 | + await mockSession(page, { user: null }) | |
| 30 | + await page.goto('/dashboard/usage') | |
| 31 | + await expect(page).toHaveURL(/\/signin\?next=%2Fdashboard%2Fusage$/) | |
| 32 | + await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible() | |
| 33 | +}) | |
| 34 | + | |
| 35 | +test('overview: tier, limits, today totals, last request', async ({ page }) => { | |
| 36 | + const state = { user: USER, keys: [{ id: 1, name: 'laptop', prefix: 'hfmd_live_ab12cd34', status: 'active', created_at: '2026-09-01T10:00:00Z', last_used_at: new Date(now - 120_000).toISOString() }], posts: [] } | |
| 37 | + await mockSession(page, state, meHandler(state)) | |
| 38 | + await page.goto('/dashboard') | |
| 39 | + const dash = page.getByTestId('dashboard') | |
| 40 | + await expect(dash).toContainText('Tier: free') | |
| 41 | + await expect(dash).toContainText('120 req / minute') | |
| 42 | + await expect(dash).toContainText('Requests today') | |
| 43 | + await expect(dash).toContainText('1 active key') | |
| 44 | + await expect(dash).toContainText(/Last request/) | |
| 45 | +}) | |
| 46 | + | |
| 47 | +test('API keys: create with one-time reveal, use in playground (Bearer injected, masked), revoke with confirm', async ({ page }) => { | |
| 48 | + const state = { user: USER, keys: [], posts: [], auth: null } | |
| 49 | + await mockSession(page, state, meHandler(state)) | |
| 50 | + await page.goto('/dashboard/keys') | |
| 51 | + await expect(page.getByTestId('keys-table')).toContainText('No key yet') | |
| 52 | + await page.getByLabel('New key name').fill('research laptop') | |
| 53 | + await page.getByTestId('create-key').click() | |
| 54 | + const reveal = page.getByTestId('key-reveal') | |
| 55 | + await expect(reveal).toContainText('Store it now') | |
| 56 | + await expect(page.getByTestId('key-value')).toHaveText(KEY) | |
| 57 | + await expect(page.getByTestId('keys-table')).toContainText('research laptop') | |
| 58 | + await expect(page.getByTestId('keys-table')).toContainText('hfmd_live_ab12cd34…') | |
| 59 | + // Use it in the playground for the session → Authorization: Bearer on requests; the code export never shows the key | |
| 60 | + await reveal.getByRole('button', { name: /Use in playground/ }).click() | |
| 61 | + await expect(page).toHaveURL(/\/dashboard\/playground/) | |
| 62 | + await expect(page.getByTestId('session-key')).toContainText('hfmd_live_ab12…3456') | |
| 63 | + await expect(page.getByTestId('pg-authbar')).toContainText('Authenticated as ada@example.com · tier free') | |
| 64 | + await page.getByTestId('pg-run').click() | |
| 65 | + await expect(page.getByTestId('pg-status')).toContainText('200 OK') | |
| 66 | + expect(state.auth).toBe(`Bearer ${KEY}`) | |
| 67 | + await expect(page.locator('.pg-codex')).toContainText('Authorization: Bearer $HFMD_API_KEY') | |
| 68 | + await expect(page.locator('.pg-codex')).not.toContainText(KEY) | |
| 69 | + // never persisted | |
| 70 | + const stored = await page.evaluate(() => JSON.stringify({ l: { ...localStorage }, s: { ...sessionStorage } })) | |
| 71 | + expect(stored).not.toContain('hfmd_live_') | |
| 72 | + // revoke with confirmation | |
| 73 | + await page.getByRole('link', { name: 'API keys' }).click() | |
| 74 | + await page.getByRole('button', { name: 'Revoke' }).click() | |
| 75 | + await expect(page.getByRole('alertdialog')).toContainText('Revoke permanently?') | |
| 76 | + await page.getByTestId('confirm-revoke').click() | |
| 77 | + await expect(page.getByTestId('keys-table')).toContainText('revoked') | |
| 78 | + expect(state.posts).toContainEqual(['DELETE', '/v1/me/keys/1']) | |
| 79 | + // rotate reveals a new key | |
| 80 | + state.keys.push({ id: 2, name: 'second', prefix: 'hfmd_live_ff00', status: 'active', created_at: '2026-09-02T00:00:00Z' }) | |
| 81 | + await page.reload() | |
| 82 | + await page.getByRole('button', { name: 'Rotate' }).click() | |
| 83 | + await page.getByTestId('confirm-rotate').click() | |
| 84 | + await expect(page.getByTestId('key-reveal')).toContainText('Key rotated') | |
| 85 | + await expect(page.getByTestId('key-value')).toContainText('hfmd_live_zz99') | |
| 86 | +}) | |
| 87 | + | |
| 88 | +test('usage: range toggle, per-day table, CSV export', async ({ page }) => { | |
| 89 | + const state = { user: USER, keys: [], posts: [] } | |
| 90 | + const ranges = [] | |
| 91 | + await mockSession(page, state, async (url, route, req) => { if (url.pathname === '/v1/me/usage') ranges.push(url.searchParams.get('range')); return meHandler(state)(url, route, req) }) | |
| 92 | + await page.goto('/dashboard/usage') | |
| 93 | + await expect(page.getByTestId('usage-table')).toContainText(new Date(now).toISOString().slice(0, 10)) | |
| 94 | + const dayRows = await page.getByTestId('usage-table').locator('tbody tr').count() // 24 hourly points → 1 or 2 UTC days | |
| 95 | + expect(dayRows).toBeGreaterThanOrEqual(1) | |
| 96 | + expect(dayRows).toBeLessThanOrEqual(2) | |
| 97 | + await expect(page.getByRole('link', { name: 'Export CSV' })).toHaveAttribute('download', 'hfmd-usage-7d.csv') | |
| 98 | + await page.getByRole('radio', { name: '30d' }).click() | |
| 99 | + await expect(page.getByRole('link', { name: 'Export CSV' })).toHaveAttribute('download', 'hfmd-usage-30d.csv') | |
| 100 | + expect(ranges).toEqual(['7d', '30d']) | |
| 101 | + await expect(page.getByRole('img', { name: /Requests per interval/ })).toBeVisible() | |
| 102 | +}) | |
| 103 | + | |
| 104 | +test('playground tab: paste a key (validated) for the session, forget it', async ({ page }) => { | |
| 105 | + const state = { user: USER, keys: [], posts: [] } | |
| 106 | + await mockSession(page, state, meHandler(state)) | |
| 107 | + await page.goto('/dashboard/playground') | |
| 108 | + await expect(page.getByText('Choose the key to use for this session')).toBeVisible() | |
| 109 | + await page.getByTestId('paste-key').fill('not-a-key') | |
| 110 | + await page.getByRole('button', { name: 'Use this key' }).click() | |
| 111 | + await expect(page.getByText(/does not look like an HF Market Data key/)).toBeVisible() | |
| 112 | + await page.getByTestId('paste-key').fill(KEY) | |
| 113 | + await page.getByRole('button', { name: 'Use this key' }).click() | |
| 114 | + await expect(page.getByTestId('session-key')).toContainText('hfmd_live_ab12…3456') | |
| 115 | + await page.getByRole('button', { name: 'Forget' }).click() | |
| 116 | + await expect(page.getByText('Choose the key to use for this session')).toBeVisible() | |
| 117 | +}) | |
| 118 | + | |
| 119 | +test('account: password reset link, tier and mailto', async ({ page }) => { | |
| 120 | + const state = { user: USER, keys: [], posts: [] } | |
| 121 | + await mockSession(page, state, meHandler(state)) | |
| 122 | + await page.goto('/dashboard/account') | |
| 123 | + await expect(page.getByText('ada@example.com').first()).toBeVisible() | |
| 124 | + await page.getByTestId('change-password').click() | |
| 125 | + await expect(page.getByText(/Reset link sent to/)).toBeVisible() | |
| 126 | + expect(state.posts).toContainEqual(['POST', '/v1/auth/forgot', { email: 'ada@example.com' }]) | |
| 127 | + await expect(page.locator('.dash-main').getByRole('link', { name: /Need more\? contact@spboucher.ai/ })).toHaveAttribute('href', /^mailto:contact@spboucher.ai/) | |
| 128 | +}) | |
| 129 | + | |
| 130 | +test('admin: forbidden for users, users table with inline edit + invite for admins', async ({ page }) => { | |
| 131 | + // plain user → 403 | |
| 132 | + await mockSession(page, { user: USER, keys: [], posts: [] }) | |
| 133 | + await page.goto('/admin') | |
| 134 | + await expect(page.getByText(/requires the admin role/)).toBeVisible() | |
| 135 | + // admin | |
| 136 | + const state = { user: ADMIN, patches: [] } | |
| 137 | + const users = [ | |
| 138 | + { id: 1, email: 'ada@example.com', name: 'Ada', role: 'user', tier: 'free', status: 'active', created_at: '2026-09-01T00:00:00Z', last_login_at: '2026-09-04T09:00:00Z' }, | |
| 139 | + { id: 3, email: 'bob@example.com', name: 'Bob', role: 'user', tier: 'free', status: 'invited', created_at: '2026-09-03T00:00:00Z' }, | |
| 140 | + ] | |
| 141 | + await page.unrouteAll({ behavior: 'ignoreErrors' }) | |
| 142 | + await mockSession(page, state, async (url, route, req) => { | |
| 143 | + if (url.pathname === '/v1/admin/users' && req.method() === 'GET') return json({ data: users }) | |
| 144 | + if (url.pathname === '/v1/admin/users' && req.method() === 'POST') { | |
| 145 | + const b = req.postDataJSON() | |
| 146 | + users.push({ id: 4, email: b.email, name: b.name, role: 'user', tier: 'free', status: 'invited', created_at: new Date().toISOString() }) | |
| 147 | + return json({ data: { user: users[users.length - 1], invite_url: 'https://www.hfmarketdata.io/invite?token=INV-abc', key: 'hfmd_live_NEWUSERKEY0000000000000000' } }) | |
| 148 | + } | |
| 149 | + const m = url.pathname.match(/^\/v1\/admin\/users\/(\d+)$/) | |
| 150 | + if (m && req.method() === 'PATCH') { state.patches.push([Number(m[1]), req.postDataJSON()]); return json({ data: { ...users.find(u => u.id === Number(m[1])), ...req.postDataJSON() } }) } | |
| 151 | + if (url.pathname === '/v1/admin/usage') return json({ data: { totals: { requests: 12345, rows: 6789000, status_429: 3 }, top: [{ principal: 'key:1', email: 'ada@example.com', tier: 'free', requests: 9000, rows: 5000000, status_429: 3 }], series } }) | |
| 152 | + if (url.pathname === '/v1/admin/audit') return json({ data: [{ ts: '2026-09-04T10:00:00Z', actor: 'root@example.com', action: 'user.tier', target: 'ada@example.com', meta: { tier: 'high_usage' } }] }) | |
| 153 | + return null | |
| 154 | + }) | |
| 155 | + await page.goto('/admin') | |
| 156 | + const table = page.getByTestId('users-table') | |
| 157 | + await expect(table).toContainText('ada@example.com') | |
| 158 | + await page.getByLabel('Tier of ada@example.com').selectOption('high_usage') | |
| 159 | + await expect.poll(() => state.patches).toEqual([[1, { tier: 'high_usage' }]]) | |
| 160 | + await page.getByLabel('Search users').fill('bob') | |
| 161 | + await expect(table).not.toContainText('ada@example.com') | |
| 162 | + await expect(table).toContainText('bob@example.com') | |
| 163 | + await page.getByLabel('Search users').fill('') | |
| 164 | + // invite | |
| 165 | + await page.getByLabel('Name').fill('Carol') | |
| 166 | + await page.getByLabel('E-mail').fill('carol@example.com') | |
| 167 | + await page.getByTestId('invite-submit').click() | |
| 168 | + await expect(page.getByTestId('invite-link')).toHaveText('https://www.hfmarketdata.io/invite?token=INV-abc') | |
| 169 | + await expect(page.getByTestId('invite-result')).toContainText('Initial API key') | |
| 170 | + await expect(table).toContainText('carol@example.com') | |
| 171 | + // global usage + audit | |
| 172 | + await page.getByRole('link', { name: 'Global usage' }).click() | |
| 173 | + await expect(page.getByTestId('top-principals')).toContainText('key:1') | |
| 174 | + await expect(page.getByTestId('admin')).toContainText('12,345') | |
| 175 | + await page.getByRole('link', { name: 'Audit log' }).click() | |
| 176 | + await expect(page.getByTestId('audit-table')).toContainText('user.tier') | |
| 177 | +}) | |
added
hfmarketdata/web/e2e/mocks.js
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +// Shared offline mocks for the E2E specs (page.route). Nothing here talks to the network. | |
| 2 | +export const RATE = { | |
| 3 | + 'X-RateLimit-Limit-Requests': '30', 'X-RateLimit-Remaining-Requests': '29', | |
| 4 | + 'X-RateLimit-Limit-Rows': '100000', 'X-RateLimit-Remaining-Rows': '99500', | |
| 5 | + 'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 1800), | |
| 6 | +} | |
| 7 | + | |
| 8 | +export const USER = { id: 1, email: 'ada@example.com', name: 'Ada', role: 'user', tier: 'free', limits: { window: 'minute', requests: 120, rows: 1_000_000, max_rows: 50_000 }, created_at: '2026-09-01T10:00:00Z' } | |
| 9 | +export const ADMIN = { ...USER, id: 2, email: 'root@example.com', name: 'Root', role: 'admin' } | |
| 10 | + | |
| 11 | +export function bars(n = 500, start = Date.UTC(2024, 5, 3, 13, 30)) { | |
| 12 | + const out = [] | |
| 13 | + let px = 190 | |
| 14 | + for (let i = 0; i < n; i++) { | |
| 15 | + const o = px, c = px + (Math.sin(i / 7) * 0.4), h = Math.max(o, c) + 0.2, l = Math.min(o, c) - 0.2 | |
| 16 | + out.push({ ticker: 'AAPL', datetime: new Date(start + i * 60_000).toISOString().slice(0, 19).replace('T', ' '), open: +o.toFixed(2), high: +h.toFixed(2), low: +l.toFixed(2), close: +c.toFixed(2), volume: 1000 + i }) | |
| 17 | + px = c | |
| 18 | + } | |
| 19 | + return out | |
| 20 | +} | |
| 21 | + | |
| 22 | +export const json = (body, status = 200, headers = {}) => ({ status, contentType: 'application/json', headers: { ...RATE, 'X-Row-Count': String(Array.isArray(body?.data) ? body.data.length : 0), ...headers }, body: JSON.stringify(body) }) | |
| 23 | + | |
| 24 | +export async function blockExternal(page) { | |
| 25 | + await page.route(/fonts\.(googleapis|gstatic)\.com/, r => r.abort()) | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** Anonymous visitor: /v1/me → 401, everything else under /v1 answered by `handler(url, route)` or 404. */ | |
| 29 | +export async function mockAnon(page, handler) { | |
| 30 | + await blockExternal(page) | |
| 31 | + await page.route('**/v1/**', async route => { | |
| 32 | + const url = new URL(route.request().url()) | |
| 33 | + if (url.pathname === '/v1/me') return route.fulfill(json({ error: { code: 'AUTH_REQUIRED', message: 'Sign in' } }, 401)) | |
| 34 | + if (handler) { const r = await handler(url, route); if (r) return route.fulfill(r) } | |
| 35 | + return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ detail: 'Not Found' }) }) | |
| 36 | + }) | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** Signed-in session. `state` is mutable so specs can flip login or grow the keys list. */ | |
| 40 | +export async function mockSession(page, state, handler) { | |
| 41 | + await blockExternal(page) | |
| 42 | + await page.route('**/v1/**', async route => { | |
| 43 | + const url = new URL(route.request().url()) | |
| 44 | + const req = route.request() | |
| 45 | + if (url.pathname === '/v1/me' && req.method() === 'GET') { | |
| 46 | + return state.user ? route.fulfill(json({ data: state.user })) : route.fulfill(json({ error: { code: 'AUTH_REQUIRED', message: 'Sign in' } }, 401)) | |
| 47 | + } | |
| 48 | + if (url.pathname === '/v1/auth/logout') { state.user = null; return route.fulfill(json({ ok: true })) } | |
| 49 | + if (handler) { const r = await handler(url, route, req); if (r) return route.fulfill(r) } | |
| 50 | + return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ detail: 'Not Found' }) }) | |
| 51 | + }) | |
| 52 | +} | |
added
hfmarketdata/web/e2e/playground.spec.js
+137 −0
@@ -0,0 +1,137 @@ | ||
| 1 | +import { expect, test } from '@playwright/test' | |
| 2 | +import { bars, json, mockAnon } from './mocks.js' | |
| 3 | + | |
| 4 | +const SPEC = { | |
| 5 | + openapi: '3.1.0', paths: { | |
| 6 | + '/v1/options/chain/{ticker}': { get: { operationId: 'opt_chain_v1_options_chain__ticker__get', parameters: [] } }, | |
| 7 | + '/v1/foo/{x}': { get: { operationId: 'foo_v1_foo__x__get', summary: 'Foo thing', tags: ['meta'], parameters: [ | |
| 8 | + { name: 'x', in: 'path', required: true, schema: { type: 'string' } }, | |
| 9 | + { name: 'mode', in: 'query', schema: { type: 'string', enum: ['a', 'b'], default: 'a' } }, | |
| 10 | + { name: 'limit', in: 'query', schema: { type: 'integer', default: 10 } }, | |
| 11 | + ] } }, | |
| 12 | + }, | |
| 13 | +} | |
| 14 | + | |
| 15 | +test.beforeEach(async ({ page }) => { | |
| 16 | + await page.route('**/openapi.json', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SPEC) })) | |
| 17 | +}) | |
| 18 | + | |
| 19 | +test('keyless banner, default request and live URL preview', async ({ page }) => { | |
| 20 | + await mockAnon(page) | |
| 21 | + await page.goto('/playground') | |
| 22 | + await expect(page.getByText('Keyless mode:')).toBeVisible() | |
| 23 | + await expect(page.getByRole('link', { name: 'Create free account' }).first()).toBeVisible() | |
| 24 | + const url = page.getByTestId('pg-url') | |
| 25 | + await expect(url).toContainText('https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1day') | |
| 26 | + await page.getByLabel(/^ticker/).fill('MSFT') | |
| 27 | + await expect(url).toContainText('/v1/bars/stock/MSFT') | |
| 28 | + await expect(page).toHaveURL(/ep=stock-bars/) | |
| 29 | + await expect(page).toHaveURL(/ticker=MSFT/) | |
| 30 | + await page.getByLabel(/^asset/).selectOption('etf') | |
| 31 | + await expect(url).toContainText('/v1/bars/etf/MSFT') | |
| 32 | +}) | |
| 33 | + | |
| 34 | +test('run → status, latency, rows, table / chart / json views and rate-limit panel', async ({ page }) => { | |
| 35 | + const rows = bars(120) | |
| 36 | + await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL' ? json({ count: rows.length, data: rows }) : null)) | |
| 37 | + await page.goto('/playground?ep=stock-bars&timeframe=1min') | |
| 38 | + await page.getByTestId('pg-run').click() | |
| 39 | + const status = page.getByTestId('pg-status') | |
| 40 | + await expect(status).toContainText('200 OK') | |
| 41 | + await expect(status).toContainText('120 rows') | |
| 42 | + await expect(status).toContainText(/\d+ ms/) | |
| 43 | + // bars → chart by default; table + json available | |
| 44 | + await expect(page.getByRole('tab', { name: 'Chart' })).toHaveAttribute('aria-selected', 'true') | |
| 45 | + await expect(page.getByRole('img', { name: /Candlestick chart of 120 rows/ })).toBeVisible() | |
| 46 | + await page.getByRole('tab', { name: /^Table/ }).click() | |
| 47 | + await expect(page.getByRole('region', { name: 'Results table' })).toBeVisible() | |
| 48 | + await expect(page.getByRole('columnheader', { name: /^close/ })).toBeVisible() | |
| 49 | + await page.getByRole('button', { name: /^close/ }).click() | |
| 50 | + await expect(page.getByText(/sorted by close asc/)).toBeVisible() | |
| 51 | + await page.getByRole('tab', { name: 'JSON' }).click() | |
| 52 | + await expect(page.getByText('120 rows in data[]')).toBeVisible() | |
| 53 | + // rate-limit panel from headers | |
| 54 | + const rl = page.getByRole('region', { name: 'Rate limit' }) | |
| 55 | + await expect(rl).toContainText('29 / 30 left') | |
| 56 | + await expect(rl).toContainText('99,500 / 100,000 left') | |
| 57 | + await expect(rl).toContainText('Window resets in') | |
| 58 | + // code export without key → no Authorization header | |
| 59 | + await expect(page.locator('.pg-codex')).toContainText('curl "https://www.hfmarketdata.io/v1/bars/stock/AAPL') | |
| 60 | + await expect(page.locator('.pg-codex')).not.toContainText('Authorization') | |
| 61 | + await page.getByRole('tab', { name: /Python/ }).click() | |
| 62 | + await expect(page.locator('.pg-codex')).toContainText('pd.DataFrame(payload["data"])') | |
| 63 | +}) | |
| 64 | + | |
| 65 | +test('deep link by operationId selects the catalog entry and fills params', async ({ page }) => { | |
| 66 | + await mockAnon(page) | |
| 67 | + await page.goto('/playground?ep=opt_chain_v1_options_chain__ticker__get&ticker=TSLA&trade_date=2024-06-21&call_put=p') | |
| 68 | + await expect(page.getByRole('button', { name: /Options chain \(Greeks\)/ })).toHaveAttribute('aria-current', 'true') | |
| 69 | + await expect(page.getByTestId('pg-url')).toContainText('/v1/options/chain/TSLA?trade_date=2024-06-21') | |
| 70 | + await expect(page.getByTestId('pg-url')).toContainText('call_put=p') | |
| 71 | + // URL is normalised to the catalog id | |
| 72 | + await expect(page).toHaveURL(/ep=options-chain/) | |
| 73 | +}) | |
| 74 | + | |
| 75 | +test('deep link with fixed asset picks the crypto entry', async ({ page }) => { | |
| 76 | + await mockAnon(page) | |
| 77 | + await page.goto('/playground?ep=bars_v1_bars__asset___ticker__get&asset=crypto&ticker=ETHUSD') | |
| 78 | + await expect(page.getByRole('button', { name: /Crypto bars/ })).toHaveAttribute('aria-current', 'true') | |
| 79 | + await expect(page.getByTestId('pg-url')).toContainText('/v1/bars/crypto/ETHUSD') | |
| 80 | +}) | |
| 81 | + | |
| 82 | +test('unknown operationId falls back to a generic form built from /openapi.json', async ({ page }) => { | |
| 83 | + await mockAnon(page) | |
| 84 | + await page.goto('/playground?ep=foo_v1_foo__x__get&x=bar') | |
| 85 | + await expect(page.getByText(/form generated from the OpenAPI spec/)).toBeVisible() | |
| 86 | + await expect(page.getByTestId('pg-url')).toContainText('/v1/foo/bar?mode=a&limit=10') | |
| 87 | + await expect(page.getByLabel(/^mode/)).toBeVisible() | |
| 88 | +}) | |
| 89 | + | |
| 90 | +test('presets: CL chain and ES continuous', async ({ page }) => { | |
| 91 | + await mockAnon(page) | |
| 92 | + await page.goto('/playground') | |
| 93 | + await page.getByRole('button', { name: 'CL chain' }).click() | |
| 94 | + await expect(page.getByTestId('pg-url')).toContainText('/v1/futures/CL/chain') | |
| 95 | + await page.getByRole('button', { name: 'ES continuous back-adjusted 2015-2025' }).click() | |
| 96 | + await expect(page.getByTestId('pg-url')).toContainText('/v1/futures/ES/continuous?roll=volume&adjust=back_adjusted&depth=1&interval=1d&from=2015-01-01&to=2025-12-31') | |
| 97 | +}) | |
| 98 | + | |
| 99 | +test('429 shows a Retry-After countdown; missing route explains "coming soon"', async ({ page }) => { | |
| 100 | + await mockAnon(page, url => { | |
| 101 | + if (url.pathname === '/v1/bars/stock/AAPL') return json({ error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests', docs: 'https://www.hfmarketdata.io/docs/errors#RATE_LIMIT_EXCEEDED' }, detail: 'Too many requests' }, 429, { 'Retry-After': '90', 'X-RateLimit-Remaining-Requests': '0' }) | |
| 102 | + return null | |
| 103 | + }) | |
| 104 | + await page.goto('/playground?ep=stock-bars') | |
| 105 | + await page.getByTestId('pg-run').click() | |
| 106 | + await expect(page.getByTestId('pg-status')).toContainText('429') | |
| 107 | + await expect(page.getByText('RATE_LIMIT_EXCEEDED · HTTP 429')).toBeVisible() | |
| 108 | + await expect(page.getByRole('region', { name: 'Rate limit' })).toContainText(/Retry in 1 min \d+ s/) | |
| 109 | + // v2 endpoint not deployed → coming soon | |
| 110 | + await page.getByRole('button', { name: 'NG term structure' }).click() | |
| 111 | + await page.getByTestId('pg-run').click() | |
| 112 | + await expect(page.getByText('Endpoint not available yet')).toBeVisible() | |
| 113 | + await expect(page.getByText(/coming soon/)).toBeVisible() | |
| 114 | +}) | |
| 115 | + | |
| 116 | +test('validation blocks Run; WebSocket tab is disabled without a key', async ({ page }) => { | |
| 117 | + await mockAnon(page) | |
| 118 | + await page.goto('/playground?ep=options-history') | |
| 119 | + await page.getByLabel(/^strike/).fill('') | |
| 120 | + await expect(page.getByTestId('pg-run')).toBeDisabled() | |
| 121 | + await expect(page.getByText('Required')).toBeVisible() | |
| 122 | + await page.getByRole('button', { name: /WebSocket stream/ }).click() | |
| 123 | + await expect(page.getByText('WebSocket streaming needs an API key')).toBeVisible() | |
| 124 | + await expect(page.getByRole('link', { name: 'Create free account' }).last()).toBeVisible() | |
| 125 | +}) | |
| 126 | + | |
| 127 | +test('CSV format gives a download and a parsed table', async ({ page }) => { | |
| 128 | + await mockAnon(page, url => (url.pathname === '/v1/bars/stock/AAPL' && url.searchParams.get('format') === 'csv' | |
| 129 | + ? { status: 200, contentType: 'text/csv', headers: { 'X-Row-Count': '2' }, body: 'ticker,datetime,open,high,low,close,volume\nAAPL,2024-06-03,1,2,0.5,1.5,100\nAAPL,2024-06-04,1.5,2.5,1,2,200\n' } : null)) | |
| 130 | + await page.goto('/playground?ep=stock-bars&format=csv') | |
| 131 | + await page.getByTestId('pg-run').click() | |
| 132 | + await expect(page.getByTestId('pg-status')).toContainText('2 rows') | |
| 133 | + await expect(page.getByRole('link', { name: 'Download CSV' })).toBeVisible() | |
| 134 | + await expect(page.locator('.pg-codex')).toContainText('-o data.csv') | |
| 135 | + await page.getByRole('tab', { name: /Python/ }).click() | |
| 136 | + await expect(page.locator('.pg-codex')).toContainText('pd.read_csv') | |
| 137 | +}) | |
added
hfmarketdata/web/playwright.config.js
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +// Playwright E2E — runs offline against `vite preview` (API mocked with page.route in each spec). | |
| 2 | +import { defineConfig } from '@playwright/test' | |
| 3 | + | |
| 4 | +export default defineConfig({ | |
| 5 | + testDir: './e2e', | |
| 6 | + timeout: 30_000, | |
| 7 | + fullyParallel: true, | |
| 8 | + retries: process.env.CI ? 1 : 0, | |
| 9 | + reporter: [['list']], | |
| 10 | + use: { baseURL: 'http://localhost:4173', trace: 'retain-on-failure', viewport: { width: 1280, height: 900 } }, | |
| 11 | + webServer: { | |
| 12 | + command: 'npm run build && npm run preview -- --port 4173 --strictPort', | |
| 13 | + url: 'http://localhost:4173', | |
| 14 | + reuseExistingServer: !process.env.CI, | |
| 15 | + timeout: 120_000, | |
| 16 | + }, | |
| 17 | + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], | |
| 18 | +}) | |
| 19 | ||