Skip to content

Commit 6e45db9

Browse files
committed
Add visual and e2e tests
1 parent ff5349f commit 6e45db9

5 files changed

Lines changed: 192 additions & 5 deletions

File tree

app/components/TimeSeriesChart.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -591,16 +591,16 @@ function ChartLegend({
591591
theme: ChartTheme
592592
}) {
593593
return (
594-
<div className="mt-2 flex max-h-24 flex-wrap gap-x-4 gap-y-1.5 overflow-y-auto pl-5">
594+
<ul className="mt-2 flex max-h-24 flex-wrap gap-x-4 gap-y-1.5 overflow-y-auto pl-5">
595595
{Array.from({ length: count }, (_, i) => (
596-
<div key={i} className="text-mono-xs text-secondary flex items-center gap-2">
596+
<li key={i} className="text-mono-xs text-secondary flex items-center gap-2">
597597
<span
598598
className="h-0.5 w-3 shrink-0 rounded-full"
599599
style={{ backgroundColor: seriesColor(i, theme) }}
600600
/>
601601
{seriesLabel(title, i, seriesLabels)}
602-
</div>
602+
</li>
603603
))}
604-
</div>
604+
</ul>
605605
)
606606
}

app/components/form/fields/OxqlField.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,14 @@ export function OxqlField<
1717
>(
1818
props: Omit<TextFieldProps<TFieldValues, TName>, 'validate'> & Omit<TextAreaProps, 'as'>
1919
) {
20-
return <TextField as="textarea" fieldClassName="font-mono!" {...props} />
20+
return (
21+
<TextField
22+
as="textarea"
23+
fieldClassName="font-mono!"
24+
validate={(value) =>
25+
typeof value === 'string' && value.trim() ? undefined : 'Enter a query'
26+
}
27+
{...props}
28+
/>
29+
)
2130
}

test/e2e/oxql-queries.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// OxQL Explorer: exercise queries with distinct data shapes (unaligned gauges,
2+
// grouped/aligned, joined). We set the query text directly in the textarea
3+
//
4+
// rather than using the page's prefill buttons, which are temporary. Only a
5+
// figure renders once data loads — the pending state is a div skeleton — so
6+
// waiting on `figure` reliably lands past the loading state.
7+
//
8+
//
9+
export const oxqlQueries = {
10+
basicTctl: `get hardware_component:amd_cpu_tctl
11+
| filter timestamp > @now() - 1m`,
12+
unalignedTables: `{
13+
get hardware_component:temperature;
14+
get hardware_component:sensor_error_count
15+
}
16+
| filter timestamp > @now() - 1m`,
17+
multiJoinedTables: `{
18+
{
19+
get sled_data_link:bytes_sent;
20+
get sled_data_link:errors_sent
21+
}
22+
| align mean_within(20s)
23+
| join;
24+
{
25+
get sled_data_link:bytes_received;
26+
get sled_data_link:errors_received
27+
}
28+
| align mean_within(20s)
29+
| join
30+
}
31+
| filter kind == 'vnic'
32+
| filter timestamp > @now() - 10m`,
33+
bytesSentAndReceived: `{
34+
get sled_data_link:bytes_sent
35+
| align mean_within(5s)
36+
| group_by [sled_serial, link_name, kind];
37+
get sled_data_link:bytes_received
38+
| align mean_within(5s)
39+
| group_by [sled_serial, link_name, kind]
40+
}
41+
| filter timestamp > @now() - 10m
42+
| filter kind == 'vnic'
43+
| filter link_name == 'oxControlService20'`,
44+
}

test/e2e/oxql.e2e.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { expect, test, type Page, type Locator } from '@playwright/test'
10+
11+
import { oxqlQueries } from './oxql-queries'
12+
13+
const runQuery = async (page: Page, query?: string) => {
14+
if (query !== undefined) await page.getByRole('textbox').fill(query)
15+
await page.getByRole('button', { name: 'Run query' }).click()
16+
17+
const loading = page.getByLabel('Chart loading')
18+
await expect(loading).toBeVisible()
19+
await expect(loading).toBeHidden()
20+
await expect(page.getByText('Query failed')).toBeHidden()
21+
}
22+
23+
test.beforeEach(async ({ page }) => {
24+
await page.goto('/system/oxql')
25+
await expect(page.getByRole('heading', { name: 'OxQL Explorer' })).toBeVisible()
26+
})
27+
28+
test('unaligned multi-table query renders a chart per series', async ({ page }) => {
29+
await runQuery(page, oxqlQueries.unalignedTables)
30+
31+
// Unaligned queries get you a chart for every series in the result, splitting
32+
// up tables (since each list of values isn't aligned with the others!)
33+
await expect(page.getByRole('figure')).toHaveCount(4) // product of table count and fields-per-table
34+
await expect(
35+
page.getByRole('figure', { name: 'hardware_component:temperature' })
36+
).toHaveCount(2)
37+
await expect(
38+
page.getByRole('figure', { name: 'hardware_component:sensor_error_count' })
39+
).toHaveCount(2)
40+
})
41+
42+
const getLegendText = async (locator: Locator): Promise<string[]> =>
43+
locator.getByRole('listitem').allTextContents()
44+
45+
test('aligned multi-table query renders a chart per table', async ({ page }) => {
46+
await runQuery(page, oxqlQueries.bytesSentAndReceived)
47+
48+
const figures = page.getByRole('figure')
49+
// Aligned tab
50+
await expect(figures).toHaveCount(2) // number of tables in query
51+
const first = figures.first()
52+
53+
// On aligned queries, there's one chart per table queried, and one line (and
54+
// legend item) per field combination. The legend item depends on mock data,
55+
// so we just snapshot
56+
const firstLegendText = await getLegendText(first)
57+
expect(firstLegendText).toEqual([
58+
// depends on whatever mock data returns
59+
'instance_id: 935499b3-fd96-432a-9c21-83a3dc1eece4',
60+
'instance_id: b5946edc-5bed-4597-88ab-9a8beb9d32a4',
61+
])
62+
63+
const all = await figures.all()
64+
for (let i = 1; i < all.length; i += 1) {
65+
// Every chart should have the same sequence of fields, even if the actual
66+
// combinations are dynamic
67+
expect(await getLegendText(all[i])).toEqual(firstLegendText)
68+
}
69+
})
70+
71+
test('joined query renders a chart per instance with a legend line per metric', async ({
72+
page,
73+
}) => {
74+
await runQuery(page, oxqlQueries.multiJoinedTables)
75+
76+
const figures = page.getByRole('figure')
77+
// Joined queries are an inversion of aligned queries: they have one chart per
78+
// _field combination,_ and one line/legend item per table in the join
79+
await expect(figures).toHaveCount(3) // depends on mock data
80+
const first = figures.first()
81+
await expect(first.getByRole('listitem')).toHaveText([
82+
'sled_data_link:bytes_sent',
83+
'sled_data_link:errors_sent',
84+
'sled_data_link:bytes_received',
85+
'sled_data_link:errors_received',
86+
])
87+
})
88+
89+
test('"Drop first point" appears only for cumulative-derived charts', async ({ page }) => {
90+
const dropFirst = page.getByLabel('Drop first point')
91+
92+
// a plain gauge is never cumulative, so there's no giant first point to drop
93+
await runQuery(page, oxqlQueries.basicTctl)
94+
await expect(dropFirst).toBeHidden()
95+
96+
// joined/aligned tables may derive from cumulatives, so the option shows up
97+
// TODO: if you know the schemas, you can check which tables are cumulative!
98+
await runQuery(page, oxqlQueries.multiJoinedTables)
99+
await expect(dropFirst).toBeChecked()
100+
101+
await dropFirst.uncheck()
102+
await expect(page.getByRole('figure')).toHaveCount(3)
103+
})
104+
105+
test('empty query is blocked by client-side validation', async ({ page }) => {
106+
const textbox = page.getByRole('textbox')
107+
await textbox.fill('')
108+
await page.getByRole('button', { name: 'Run query' }).click()
109+
110+
await expect(textbox).toHaveAttribute('aria-invalid', 'true')
111+
await expect(page.getByText('Enter a query').first()).toBeVisible()
112+
await expect(page.getByRole('figure')).toHaveCount(0)
113+
})
114+
115+
test('a query the backend rejects surfaces an error instead of a chart', async ({
116+
page,
117+
}) => {
118+
await page.getByRole('textbox').fill('junk junk junk!')
119+
await page.getByRole('button', { name: 'Run query' }).click()
120+
121+
await expect(page.getByText('Query failed')).toBeVisible()
122+
await expect(page.getByRole('figure')).toHaveCount(0)
123+
})

test/visual/regression.e2e.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* CSS frameworks, or making broad styling changes.
1515
*/
1616

17+
import { oxqlQueries } from '../e2e/oxql-queries'
1718
import { expect, test } from '../e2e/utils'
1819

1920
// set a fixed time to avoid diffs due to irrelevant time differences
@@ -256,4 +257,14 @@ test.describe('Visual Regression', { tag: '@visual' }, () => {
256257
maskColor: '#0b0e14',
257258
})
258259
})
260+
261+
for (const [name, query] of Object.entries(oxqlQueries)) {
262+
test(`oxql ${name}`, async ({ page }) => {
263+
await page.goto('/system/oxql', { waitUntil: 'networkidle' })
264+
await page.getByRole('textbox').fill(query)
265+
await page.getByRole('button', { name: 'Run query' }).click()
266+
await expect(page.locator('figure').first()).toBeVisible()
267+
await expect(page).toHaveScreenshot(`oxql-${name}.png`, fullPage)
268+
})
269+
}
259270
})

0 commit comments

Comments
 (0)