|
1 |
+// /charts — offline E2E against synthetic OHLC fixtures (e2e/fixtures/charts.js). The chart object is exposed as |
|
2 |
+// window.__hfmdChart only with ?debug=1 (or in dev), which the pagination test uses to move the viewport. |
|
3 |
+import { expect, test } from '@playwright/test' |
|
4 |
+import { mockAnon } from './mocks.js' |
|
5 |
+import { chartsHandler } from './fixtures/charts.js' |
|
6 |
+ |
|
7 |
+async function setup(page, state = {}) { |
|
8 |
+ const handler = chartsHandler(state) |
|
9 |
+ await mockAnon(page, url => handler(url)) |
|
10 |
+ return state |
|
11 |
+} |
|
12 |
+const legend = page => page.getByTestId('ch-legend') |
|
13 |
+const barsCalls = (state, pred = () => true) => state.calls.filter(pred) |
|
14 |
+ |
|
15 |
+test.describe('charts', () => { |
|
16 |
+ test('loads AAPL 1D by default: one bars request, legend, status, URL', async ({ page }) => { |
|
17 |
+ const state = await setup(page) |
|
18 |
+ await page.goto('/charts') |
|
19 |
+ await expect(legend(page).locator('.ch-legend-sym')).toHaveText('AAPL') |
|
20 |
+ await expect(page.getByTestId('ch-legend-ohlc')).toContainText('O') |
|
21 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/1,500 bars/) |
|
22 |
+ await expect(page).toHaveURL(/\/charts\?s=AAPL$/) |
|
23 |
+ expect(barsCalls(state)).toHaveLength(1) |
|
24 |
+ expect(state.calls[0]).toMatchObject({ asset: 'stock', ticker: 'AAPL', tf: '1day' }) |
|
25 |
+ expect(state.calls[0].search).toContain('order=desc') |
|
26 |
+ expect(state.calls[0].search).toContain('limit=1500') |
|
27 |
+ await expect(page.getByTestId('ch-status-quota')).toContainText('req left') |
|
28 |
+ await expect(page.getByTestId('ch-status-quota').getByRole('link', { name: /Sign in/ })).toBeVisible() |
|
29 |
+ await expect(page).toHaveTitle(/AAPL 1D · Charts/) |
|
30 |
+ await page.screenshot({ path: '/tmp/hfmd-shots/charts-desktop.png' }) |
|
31 |
+ }) |
|
32 |
+ |
|
33 |
+ test('timeframe switch keeps the previous render (no skeleton), updates URL, legend and request', async ({ page }) => { |
|
34 |
+ const state = await setup(page) |
|
35 |
+ await page.goto('/charts') |
|
36 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) |
|
37 |
+ await page.getByTestId('ch-tf-1min').click() |
|
38 |
+ await expect(page.getByTestId('ch-skeleton')).toHaveCount(0) |
|
39 |
+ await expect(page.getByTestId('ch-tf-1min')).toHaveAttribute('aria-pressed', 'true') |
|
40 |
+ await expect(page).toHaveURL(/tf=1min/) |
|
41 |
+ await expect(legend(page).locator('.ch-legend-tf')).toHaveText('1m') |
|
42 |
+ await expect.poll(() => barsCalls(state, c => c.tf === '1min').length).toBe(1) |
|
43 |
+ expect(state.calls[1].search).toContain('limit=3000') |
|
44 |
+ await expect(page.getByTestId('ch-legend-ohlc')).toContainText('ET') |
|
45 |
+ }) |
|
46 |
+ |
|
47 |
+ test('URL state restores symbol, timeframe, type, indicators, scale and volume', async ({ page }) => { |
|
48 |
+ await setup(page) |
|
49 |
+ await page.goto('/charts?s=MSFT&tf=1hour&type=line&ind=sma:20,rsi:14&scale=log&vol=0&debug=1') |
|
50 |
+ await expect(legend(page).locator('.ch-legend-sym')).toHaveText('MSFT') |
|
51 |
+ await expect(legend(page).locator('.ch-legend-tf')).toHaveText('1h') |
|
52 |
+ await expect(page.getByTestId('ch-legend-ind')).toHaveCount(2) |
|
53 |
+ await expect(page.getByTestId('ch-legend-ind').nth(0)).toContainText('SMA 20') |
|
54 |
+ await expect(page.getByTestId('ch-legend-ind').nth(1)).toContainText('RSI 14') |
|
55 |
+ await expect(page.getByTestId('ch-type-menu')).toContainText('Line') |
|
56 |
+ await expect(page.getByTestId('ch-scale-menu')).toContainText('Log') |
|
57 |
+ await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators().map(i => `${i.type}:${i.params.length}:${i.pane}`))).toEqual(['sma:20:main', 'rsi:14:new']) |
|
58 |
+ await expect(page).toHaveTitle(/MSFT 1h · Charts/) |
|
59 |
+ }) |
|
60 |
+ |
|
61 |
+ test('indicators: add from the searchable menu, edit a parameter inline, remove', async ({ page }) => { |
|
62 |
+ await setup(page) |
|
63 |
+ await page.goto('/charts?debug=1') |
|
64 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) |
|
65 |
+ await page.getByTestId('ch-ind-menu').click() |
|
66 |
+ await page.getByTestId('ch-ind-search').fill('expo') |
|
67 |
+ await expect(page.getByTestId('ch-ind-ema')).toBeVisible() |
|
68 |
+ await expect(page.getByTestId('ch-ind-sma')).toHaveCount(0) |
|
69 |
+ await page.getByTestId('ch-ind-ema').click() |
|
70 |
+ await expect(page.getByTestId('ch-legend-ind')).toHaveCount(1) |
|
71 |
+ await expect(page.getByTestId('ch-legend-ind')).toContainText('EMA 20') |
|
72 |
+ await expect(page).toHaveURL(/ind=ema:20/) |
|
73 |
+ await page.getByTestId('ch-legend-ind').getByRole('button', { name: 'EMA 20', exact: true }).click() |
|
74 |
+ const editor = page.getByTestId('ch-ind-editor') |
|
75 |
+ await editor.getByRole('spinbutton').fill('50') |
|
76 |
+ await expect(page.getByTestId('ch-legend-ind')).toContainText('EMA 50') |
|
77 |
+ await expect(page).toHaveURL(/ind=ema:50/) |
|
78 |
+ await expect.poll(() => page.evaluate(() => window.__hfmdChart.getIndicators()[0].params.length)).toBe(50) |
|
79 |
+ await editor.getByRole('button', { name: 'Done' }).click() |
|
80 |
+ await page.getByRole('button', { name: 'Remove EMA 50' }).click() |
|
81 |
+ await expect(page.getByTestId('ch-legend-ind')).toHaveCount(0) |
|
82 |
+ await expect(page).not.toHaveURL(/ind=/) |
|
83 |
+ }) |
|
84 |
+ |
|
85 |
+ test('compare: adds a % overlay, chip + legend row, one request, URL cmp=', async ({ page }) => { |
|
86 |
+ const state = await setup(page) |
|
87 |
+ await page.goto('/charts') |
|
88 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) |
|
89 |
+ await page.getByTestId('ch-cmp-menu').click() |
|
90 |
+ const input = page.locator('#ch-compare-input') |
|
91 |
+ await input.fill('MSF') |
|
92 |
+ await page.getByTestId('ch-symbol-list').getByRole('option', { name: /MSFT/ }).click() |
|
93 |
+ await expect(page.getByTestId('ch-cmp-chip')).toHaveText(/MSFT/) |
|
94 |
+ await page.keyboard.press('Escape') |
|
95 |
+ await expect(page.getByTestId('ch-legend-cmp')).toContainText('MSFT') |
|
96 |
+ await expect(page).toHaveURL(/cmp=MSFT/) |
|
97 |
+ await expect.poll(() => barsCalls(state, c => c.ticker === 'MSFT').length).toBe(1) |
|
98 |
+ await expect(page.getByTestId('ch-scale-menu')).toContainText('%') |
|
99 |
+ await page.getByRole('button', { name: 'Remove comparison MSFT' }).click() |
|
100 |
+ await expect(page.getByTestId('ch-legend-cmp')).toHaveCount(0) |
|
101 |
+ }) |
|
102 |
+ |
|
103 |
+ test('infinite history: needMoreLeft loads older bars once, then "start of history"', async ({ page }) => { |
|
104 |
+ const state = await setup(page, { total: 2000 }) |
|
105 |
+ await page.goto('/charts?debug=1') |
|
106 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/1,500 bars/) |
|
107 |
+ await page.evaluate(() => window.__hfmdChart.setVisibleRange({ fromIndex: 0, toIndex: 120 })) |
|
108 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/2,000 bars/) |
|
109 |
+ await expect(page.getByTestId('ch-status-start')).toHaveText('start of history') |
|
110 |
+ expect(barsCalls(state)).toHaveLength(2) |
|
111 |
+ expect(state.calls[1].search).toMatch(/end=\d{4}-\d{2}-\d{2}/) |
|
112 |
+ const data = await page.evaluate(() => { const d = window.__hfmdChart.getData(); return { n: d.length, sorted: d.every((b, i) => i === 0 || d[i - 1].t < b.t) } }) |
|
113 |
+ expect(data).toEqual({ n: 2000, sorted: true }) |
|
114 |
+ // no further request once the start is reached |
|
115 |
+ await page.evaluate(() => window.__hfmdChart.setVisibleRange({ fromIndex: 0, toIndex: 80 })) |
|
116 |
+ await page.waitForTimeout(300) |
|
117 |
+ expect(barsCalls(state)).toHaveLength(2) |
|
118 |
+ }) |
|
119 |
+ |
|
120 |
+ test('429 shows "rate limited — not down" with countdown and free-account CTA', async ({ page }) => { |
|
121 |
+ await setup(page, { rateLimit: true }) |
|
122 |
+ await page.goto('/charts') |
|
123 |
+ const err = page.getByTestId('ch-error').locator('.error-state.is-429') |
|
124 |
+ await expect(err).toBeVisible() |
|
125 |
+ await expect(err).toContainText('Rate limited — not down') |
|
126 |
+ await expect(err.locator('.countdown')).toContainText(/min|s/) |
|
127 |
+ await expect(err.getByRole('link', { name: /Create free account/ })).toBeVisible() |
|
128 |
+ await page.screenshot({ path: '/tmp/hfmd-shots/charts-429.png' }) |
|
129 |
+ }) |
|
130 |
+ |
|
131 |
+ test('unknown symbol: 404 state with suggestions, picking one recovers', async ({ page }) => { |
|
132 |
+ await setup(page) |
|
133 |
+ await page.goto('/charts?s=AAPX') |
|
134 |
+ const err = page.getByTestId('ch-error') |
|
135 |
+ await expect(err).toContainText('AAPX not found') |
|
136 |
+ await expect(page.getByTestId('ch-suggestions')).toContainText('AAPL') |
|
137 |
+ await page.getByTestId('ch-suggestions').getByRole('button', { name: /^AAPL/ }).click() |
|
138 |
+ await expect(legend(page).locator('.ch-legend-sym')).toHaveText('AAPL') |
|
139 |
+ await expect(page.getByTestId('ch-error')).toHaveCount(0) |
|
140 |
+ }) |
|
141 |
+ |
|
142 |
+ test('symbol search: groups by asset, keyboard pick, recents; `/` focuses it', async ({ page }) => { |
|
143 |
+ await setup(page) |
|
144 |
+ await page.goto('/charts') |
|
145 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) |
|
146 |
+ await page.keyboard.press('/') |
|
147 |
+ await expect(page.getByTestId('ch-symbol-input')).toBeFocused() |
|
148 |
+ await expect(page.getByTestId('search-dialog')).toHaveCount(0) |
|
149 |
+ await page.keyboard.type('ES') |
|
150 |
+ const list = page.getByTestId('ch-symbol-list') |
|
151 |
+ await expect(list).toContainText('Futures (continuous)') |
|
152 |
+ await expect(list).toContainText('E-mini S&P 500') |
|
153 |
+ await list.getByRole('option', { name: /^ES E-mini/ }).click() |
|
154 |
+ await expect(legend(page).locator('.ch-legend-sym')).toHaveText('ES') |
|
155 |
+ await expect(page).toHaveURL(/s=ES&asset=futures/) |
|
156 |
+ await expect(page.getByTestId('ch-adj-menu')).toBeVisible() |
|
157 |
+ await page.getByTestId('ch-symbol-input').focus() |
|
158 |
+ await expect(list).toContainText('Recent') |
|
159 |
+ }) |
|
160 |
+ |
|
161 |
+ test('drawing tools: keyboard shortcuts toggle tools, table view lists visible bars with CSV export', async ({ page }) => { |
|
162 |
+ await setup(page) |
|
163 |
+ await page.goto('/charts') |
|
164 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) |
|
165 |
+ await page.keyboard.press('t') |
|
166 |
+ await expect(page.getByTestId('ch-drawbar').locator('[data-tool="trendline"]')).toHaveAttribute('aria-pressed', 'true') |
|
167 |
+ await page.keyboard.press('Escape') |
|
168 |
+ await expect(page.getByTestId('ch-drawbar').locator('[data-tool="trendline"]')).toHaveAttribute('aria-pressed', 'false') |
|
169 |
+ await page.getByTestId('ch-drawbar').getByRole('button', { name: 'Fibonacci retracement' }).click() |
|
170 |
+ await expect(page.getByTestId('ch-drawbar').locator('[data-tool="fib"]')).toHaveAttribute('aria-pressed', 'true') |
|
171 |
+ await page.getByTestId('ch-table-btn').click() |
|
172 |
+ const table = page.getByTestId('ch-table') |
|
173 |
+ await expect(table).toBeVisible() |
|
174 |
+ await expect(table.locator('tbody tr').first()).toContainText('2024-06-28') |
|
175 |
+ await expect(table.getByTestId('ch-table-csv')).toBeEnabled() |
|
176 |
+ await expect(page.getByTestId('ch-legend-ohlc')).toBeVisible() |
|
177 |
+ await page.getByRole('button', { name: 'Close table' }).click() |
|
178 |
+ await expect(table).toHaveCount(0) |
|
179 |
+ }) |
|
180 |
+ |
|
181 |
+ test('share copies the current URL; screenshot produces a PNG', async ({ page }) => { |
|
182 |
+ await setup(page) |
|
183 |
+ await page.goto('/charts?s=MSFT&tf=1hour') |
|
184 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) |
|
185 |
+ const blobSize = await page.evaluate(async () => { const b = await window.__hfmdChart?.toPNG?.(); return b ? b.size : -1 }) |
|
186 |
+ expect(blobSize).toBe(-1) // not exposed without ?debug=1 |
|
187 |
+ await page.getByTestId('ch-share').click() |
|
188 |
+ await expect(page.getByTestId('ch-share')).toHaveAttribute('aria-label', 'Link copied') |
|
189 |
+ }) |
|
190 |
+}) |
|
191 |
+ |
|
192 |
+test.describe('charts mobile', () => { |
|
193 |
+ test.use({ viewport: { width: 393, height: 851 }, hasTouch: true, isMobile: true, deviceScaleFactor: 2 }) |
|
194 |
+ |
|
195 |
+ test('condensed toolbar, bottom sheet with the rest, no horizontal overflow, 44 px targets', async ({ page }) => { |
|
196 |
+ await setup(page) |
|
197 |
+ await page.goto('/charts') |
|
198 |
+ await expect(page.getByTestId('ch-status-count')).toContainText(/bars/) |
|
199 |
+ await expect(page.getByTestId('ch-type-menu')).toHaveCount(0) |
|
200 |
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) |
|
201 |
+ const small = await page.evaluate(() => [...document.querySelectorAll('[data-testid="ch-toolbar"] button, [data-testid="ch-toolbar"] input')].filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 44 }).length) |
|
202 |
+ expect(small).toBe(0) |
|
203 |
+ const pageBox = await page.getByTestId('ch-page').boundingBox() |
|
204 |
+ expect(pageBox.height).toBeLessThanOrEqual(851 - 56 + 1) |
|
205 |
+ await page.getByTestId('ch-more').click() |
|
206 |
+ const sheet = page.getByTestId('ch-sheet') |
|
207 |
+ await expect(sheet).toBeVisible() |
|
208 |
+ await sheet.getByTestId('ch-sheet-indicators').click() |
|
209 |
+ await page.getByTestId('ch-ind-rsi').click() |
|
210 |
+ await expect(page.getByTestId('ch-legend-ind')).toContainText('RSI 14') |
|
211 |
+ await expect(page.getByTestId('ch-sheet')).toHaveCount(0) |
|
212 |
+ await page.screenshot({ path: '/tmp/hfmd-shots/charts-mobile.png' }) |
|
213 |
+ }) |
|
214 |
+}) |