2026-10-25 16:35:47 +01:00
|
|
|
import { expect, type Page, test } from '../fixtures/api.js';
|
|
|
|
|
import { expectNoViolations } from '../fixtures/axe.js';
|
|
|
|
|
|
|
|
|
|
function card(page: Page, title: string) {
|
|
|
|
|
return page
|
|
|
|
|
.locator('ovd-analytics-card')
|
|
|
|
|
.filter({ has: page.getByRole('heading', { level: 2, name: title, exact: true }) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('a deep link drives the selectors and every request', async ({ page, api }) => {
|
|
|
|
|
await page.goto('/analytics?from=now-7d&to=now&tz=UTC&granularity=1h');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1, name: 'Analytics' })).toBeVisible();
|
|
|
|
|
await expect(page.getByRole('button', { name: /Time range: Last 7 days/ })).toBeVisible();
|
|
|
|
|
await expect(page.locator('.granularity-select')).toContainText('1 h');
|
|
|
|
|
await expect(card(page, 'Rooms').locator('ovd-time-series-chart')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const analytics = api.requests.filter((request) => request.path.startsWith('analytics/'));
|
|
|
|
|
|
|
|
|
|
expect(analytics.map((request) => request.path).sort()).toEqual([
|
|
|
|
|
'analytics/breakdowns',
|
|
|
|
|
'analytics/series',
|
|
|
|
|
'analytics/summary'
|
|
|
|
|
]);
|
|
|
|
|
expect(analytics.every((request) => request.params.get('tz') === 'UTC')).toBe(true);
|
|
|
|
|
expect(analytics.find((request) => request.path === 'analytics/series')?.params.get('granularity')).toBe('1h');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the granularity selector offers what the range accepts', async ({ page }) => {
|
|
|
|
|
await page.goto('/analytics?from=now-7d&to=now&tz=UTC');
|
|
|
|
|
await expect(page.locator('.granularity-select')).toContainText('auto (1 h)');
|
|
|
|
|
await page.locator('.granularity-select mat-select').click();
|
|
|
|
|
await expect(page.getByRole('option')).toHaveText(['auto (1 h)', '1 h', '6 h', '1 d']);
|
|
|
|
|
await page.getByRole('option', { name: '6 h' }).click();
|
|
|
|
|
await expect(page).toHaveURL(/granularity=6h/);
|
|
|
|
|
|
|
|
|
|
await page.goto('/analytics?from=now-24h&to=now&tz=UTC');
|
|
|
|
|
await page.locator('.granularity-select mat-select').click();
|
|
|
|
|
await expect(page.getByRole('option')).toHaveText(['auto (5 min)', '5 min', '15 min', '1 h', '6 h', '1 d']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('tiles, the six sections, the breakdown table and the page scroll over a chart', async ({ page }) => {
|
|
|
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
|
|
|
await page.goto('/analytics?from=now-24h&to=now&tz=UTC');
|
|
|
|
|
|
|
|
|
|
const tiles = page.getByRole('region', { name: 'Summary' });
|
|
|
|
|
|
|
|
|
|
await expect(tiles).toContainText('1,203');
|
|
|
|
|
await expect(tiles).toContainText('↑ 1.2 TB');
|
|
|
|
|
await expect(tiles).toContainText('Unique identities (sum of 7 UTC days)');
|
|
|
|
|
|
|
|
|
|
for (const title of ['Rooms', 'Participants', 'Client breakdowns', 'Data transferred', 'Egress', 'Ingress']) {
|
|
|
|
|
await expect(card(page, title).locator('ovd-time-series-chart, ovd-breakdown-chart').first()).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await expect(card(page, 'Rooms')).toContainText('4 participants');
|
|
|
|
|
await expect(card(page, 'Egress').locator('.chart-heading + .chart-note')).toHaveText([
|
|
|
|
|
'Five-minute buckets · UTC',
|
|
|
|
|
'Hourly · UTC'
|
|
|
|
|
]);
|
|
|
|
|
await expect(page.locator('main [title]')).toHaveCount(0);
|
|
|
|
|
await expect(card(page, 'Participants')).toContainText('Unique identities are available at daily granularity');
|
|
|
|
|
await expect(card(page, 'Ingress').locator('ovd-breakdown-chart')).toContainText('Ingress input type');
|
|
|
|
|
|
|
|
|
|
const sdk = card(page, 'Client breakdowns').locator('ovd-breakdown-chart').first();
|
|
|
|
|
|
|
|
|
|
await sdk.getByRole('button', { name: 'Show table' }).click();
|
|
|
|
|
await expect(sdk.locator('tbody tr')).toHaveCount(3);
|
|
|
|
|
await expect(sdk.locator('tbody tr').first()).toContainText('60.0 %');
|
|
|
|
|
|
|
|
|
|
const chart = card(page, 'Data transferred').locator('.chart');
|
|
|
|
|
|
|
|
|
|
await chart.scrollIntoViewIfNeeded();
|
|
|
|
|
|
|
|
|
|
const before = await page.evaluate(() => document.querySelector('mat-sidenav-content')?.scrollTop ?? 0);
|
|
|
|
|
const box = await chart.boundingBox();
|
|
|
|
|
|
|
|
|
|
await page.mouse.move((box?.x ?? 0) + 50, (box?.y ?? 0) + 50);
|
|
|
|
|
await page.mouse.wheel(0, 400);
|
|
|
|
|
await expect
|
|
|
|
|
.poll(() => page.evaluate(() => document.querySelector('mat-sidenav-content')?.scrollTop ?? 0))
|
|
|
|
|
.toBeGreaterThan(before);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the daily override shows unique identities per UTC day', async ({ page, api }) => {
|
|
|
|
|
await page.goto('/analytics?from=now-7d&to=now&tz=UTC');
|
|
|
|
|
await card(page, 'Participants').getByRole('button', { name: 'Switch to 1 d' }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(/granularity=1d/);
|
|
|
|
|
await expect(card(page, 'Participants').locator('ovd-time-series-chart')).toHaveCount(3);
|
|
|
|
|
await expect(
|
|
|
|
|
card(page, 'Participants')
|
|
|
|
|
.locator('ovd-time-series-chart')
|
|
|
|
|
.filter({ hasText: 'Unique identities' })
|
|
|
|
|
.locator('.chart-note')
|
|
|
|
|
).toHaveText('per UTC day');
|
|
|
|
|
await expect
|
|
|
|
|
.poll(() =>
|
|
|
|
|
api.requests.some(
|
|
|
|
|
(request) =>
|
|
|
|
|
request.path === 'analytics/series' &&
|
|
|
|
|
request.params.get('metrics')?.includes('uniqueIdentities') === true
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the PRO strip is locked on CE and sends no PRO request', async ({ page, api }) => {
|
|
|
|
|
await page.goto('/analytics?from=now-24h&to=now&tz=UTC');
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('ovd-pro-lock-overlay')).toHaveCount(3);
|
openvidu-dashboard: media node labels, and nothing about nodes in CE
The contract gains mediaNode on nodes, rooms and participants, stoppedAt on nodes and the
partial mediaNode index that replaces the nodeId one; the scenario's two servers are
labelled. The status reports the writer without node counts and with stoppedAt when the
server stopped, the rooms list filters by mediaNode, list items carry it, the overview
groups by it for editions and a node scope's media statistics coverage follows it.
CE renders nothing about nodes without per-node-breakdown: no nodes line, By node card,
Node select, scope selector or Node columns, not even locked; the Overview reads "Server
stopped at" for a stopped server. The generator gains --media-nodes, which node rollups
now require.
2026-11-04 12:13:58 +01:00
|
|
|
// Nothing per node on CE: no scope selector, lock or text about nodes.
|
|
|
|
|
await expect(page.locator('.scope-select')).toHaveCount(0);
|
|
|
|
|
await expect(page.getByText('All nodes')).toHaveCount(0);
|
2026-10-25 16:35:47 +01:00
|
|
|
await expect(card(page, 'Quality')).toContainText('Quality analytics is available in OpenVidu PRO');
|
|
|
|
|
await expect(card(page, 'Top rooms')).toContainText('Top rooms is available in OpenVidu PRO');
|
|
|
|
|
await expect(card(page, 'Quality').getByRole('button', { name: 'Export CSV' })).toBeDisabled();
|
|
|
|
|
await expect(card(page, 'Rooms').locator('ovd-time-series-chart')).toBeVisible();
|
|
|
|
|
expect(api.calls()).not.toContain('GET analytics/top');
|
|
|
|
|
expect(api.calls()).not.toContain('GET analytics/scopes');
|
|
|
|
|
|
|
|
|
|
const metrics = api.requests
|
|
|
|
|
.filter((request) => request.path === 'analytics/series')
|
|
|
|
|
.flatMap((request) => request.params.get('metrics')?.split(',') ?? []);
|
|
|
|
|
|
|
|
|
|
expect(metrics).not.toContain('scoreAvg');
|
|
|
|
|
expect(metrics).toContain('ingressSessions');
|
|
|
|
|
await expectNoViolations(page);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a card exports its CSV under the server file name', async ({ page, api }) => {
|
|
|
|
|
await page.goto('/analytics?from=now-7d&to=now&tz=UTC');
|
|
|
|
|
await expect(card(page, 'Data transferred').locator('ovd-time-series-chart')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const download = page.waitForEvent('download');
|
|
|
|
|
|
|
|
|
|
await card(page, 'Data transferred').getByRole('button', { name: 'Export CSV' }).click();
|
|
|
|
|
|
|
|
|
|
const file = await download;
|
|
|
|
|
const request = api.requests.find((item) => item.path === 'analytics/export.csv');
|
|
|
|
|
|
|
|
|
|
expect(request?.params.get('kind')).toBe('series');
|
|
|
|
|
expect(request?.params.get('metrics')).toBe('bytesUp,bytesDown');
|
|
|
|
|
expect(request?.authorization).toMatch(/^Bearer /);
|
|
|
|
|
expect(file.suggestedFilename()).toBe(
|
|
|
|
|
`analytics-series-${request?.params.get('from') ?? ''}-${request?.params.get('to') ?? ''}.csv`.replaceAll(
|
|
|
|
|
':',
|
|
|
|
|
''
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('a failed request', () => {
|
|
|
|
|
test.use({ apiOptions: { analyticsFail: ['breakdowns', 'export.csv'] } });
|
|
|
|
|
|
|
|
|
|
test('fails its cards only, with retry, and an export failure toasts', async ({ page, api }) => {
|
|
|
|
|
await page.goto('/analytics?from=now-24h&to=now&tz=UTC');
|
|
|
|
|
|
|
|
|
|
const clients = card(page, 'Client breakdowns');
|
|
|
|
|
|
|
|
|
|
await expect(clients.getByRole('alert')).toContainText('Request e2e-breakdowns');
|
|
|
|
|
await expect(card(page, 'Rooms').locator('ovd-time-series-chart')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
api.options.analyticsFail = ['export.csv'];
|
|
|
|
|
await clients.getByRole('button', { name: 'Retry' }).click();
|
|
|
|
|
await expect(clients.locator('ovd-breakdown-chart').first()).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await card(page, 'Rooms').getByRole('button', { name: 'Export CSV' }).click();
|
|
|
|
|
await expect(page.getByText(/Export failed: .*Request e2e-export\.csv/)).toBeVisible();
|
|
|
|
|
await expect(card(page, 'Rooms').getByRole('button', { name: 'Export CSV' })).toBeEnabled();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-11-02 20:39:42 +01:00
|
|
|
|
|
|
|
|
test.describe('a range whose servers report no media statistics', () => {
|
|
|
|
|
test.use({ apiOptions: { analyticsSummary: { mediaStats: 'none' } } });
|
|
|
|
|
|
|
|
|
|
test('the Data up / down tile and the Bandwidth section say so', async ({ page }) => {
|
|
|
|
|
await page.goto('/analytics?from=now-7d&to=now&tz=UTC');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('region', { name: 'Summary' })).toContainText(
|
|
|
|
|
'Not reported by the servers of this range'
|
|
|
|
|
);
|
|
|
|
|
await expect(card(page, 'Data transferred')).toContainText(
|
|
|
|
|
'The servers of this range do not report media statistics'
|
|
|
|
|
);
|
|
|
|
|
await expect(card(page, 'Data transferred').locator('ovd-time-series-chart')).toHaveCount(0);
|
|
|
|
|
await expectNoViolations(page);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('a range where only some servers report media statistics', () => {
|
|
|
|
|
test.use({ apiOptions: { analyticsSummary: { mediaStats: 'partial' } } });
|
|
|
|
|
|
|
|
|
|
test('the Data up / down tile and the Bandwidth chart carry the note', async ({ page }) => {
|
|
|
|
|
await page.goto('/analytics?from=now-7d&to=now&tz=UTC');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('region', { name: 'Summary' })).toContainText(
|
|
|
|
|
'Only from the servers that report media statistics'
|
|
|
|
|
);
|
|
|
|
|
await expect(card(page, 'Data transferred')).toContainText(
|
|
|
|
|
'Only from the servers that report media statistics'
|
|
|
|
|
);
|
|
|
|
|
await expect(card(page, 'Data transferred').locator('ovd-time-series-chart')).toHaveCount(1);
|
|
|
|
|
});
|
|
|
|
|
});
|