SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

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)

Simon-Pierre Boucher committed 20 days ago (Sep 4, 2026) parent 521ea43

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