mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 09:56:02 +03:00
Add testing framework (#4440)
* Add testing framework * Test query period picking behaviour
This commit is contained in:
parent
4d4c9a8a21
commit
8af3f7332a
1
.github/workflows/node.yml
vendored
1
.github/workflows/node.yml
vendored
@ -27,4 +27,5 @@ jobs:
|
||||
- run: npm install --prefix ./tracker
|
||||
- run: npm run lint --prefix ./assets
|
||||
- run: npm run check-format --prefix ./assets
|
||||
- run: npm run test --prefix ./assets
|
||||
- run: npm run deploy --prefix ./tracker
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -35,6 +35,9 @@ npm-debug.log
|
||||
/assets/node_modules/
|
||||
/tracker/node_modules/
|
||||
|
||||
# test coverage directory
|
||||
/assets/coverage
|
||||
|
||||
# Since we are building assets from assets/,
|
||||
# we ignore priv/static. You may want to comment
|
||||
# this depending on your deployment strategy.
|
||||
|
@ -16,6 +16,8 @@ All notable changes to this project will be documented in this file.
|
||||
- Add search and pagination functionality into Google Keywords > Details modal
|
||||
- ClickHouse system.query_log table log_comment column now contains information about source of queries. Useful for debugging
|
||||
- New /debug/clickhouse route for super admins which shows information on clickhouse queries executed by user
|
||||
- Typescript support for `/assets`
|
||||
- Testing framework for `/assets`
|
||||
|
||||
### Removed
|
||||
- Deprecate `ECTO_IPV6` and `ECTO_CH_IPV6` env vars in CE plausible/analytics#4245
|
||||
|
@ -2,12 +2,14 @@
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
"es6": true,
|
||||
"jest/globals": true
|
||||
},
|
||||
"plugins": ["import"],
|
||||
"plugins": ["import", "jest"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:jest/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
|
20
assets/jest.config.json
Normal file
20
assets/jest.config.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"clearMocks": true,
|
||||
"coverageDirectory": "coverage",
|
||||
"coverageProvider": "v8",
|
||||
"testEnvironment": "jsdom",
|
||||
"globals": {
|
||||
"BUILD_EXTRA": true
|
||||
},
|
||||
"setupFiles": ["<rootDir>/test-utils/set-fixed-timezone.ts"],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/test-utils/extend-expect.ts",
|
||||
"<rootDir>/test-utils/reset-state.ts"
|
||||
],
|
||||
"transform": {
|
||||
"^.+.[tj]sx?$": ["ts-jest", {}]
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"d3": "<rootDir>/node_modules/d3/dist/d3.min.js"
|
||||
}
|
||||
}
|
@ -1,14 +1,14 @@
|
||||
import React, { Fragment, useState, useRef, useEffect } from 'react'
|
||||
import { useAppNavigate } from './navigation/use-app-navigate.js'
|
||||
import { useAppNavigate } from './navigation/use-app-navigate'
|
||||
import { navigateToQuery } from './query'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import classNames from 'classnames'
|
||||
import * as storage from './util/storage'
|
||||
import Flatpickr from 'react-flatpickr'
|
||||
import { parseNaiveDate, formatISO, formatDateRange } from './util/date.js'
|
||||
import { useQueryContext } from './query-context.js'
|
||||
import { useSiteContext } from './site-context.js'
|
||||
import { parseNaiveDate, formatISO, formatDateRange } from './util/date'
|
||||
import { useQueryContext } from './query-context'
|
||||
import { useSiteContext } from './site-context'
|
||||
|
||||
const COMPARISON_MODES = {
|
||||
'off': 'Disable comparison',
|
||||
|
@ -26,11 +26,11 @@ import {
|
||||
isSameDate
|
||||
} from "./util/date";
|
||||
import { navigateToQuery, QueryLink, QueryButton } from "./query";
|
||||
import { shouldIgnoreKeypress } from "./keybinding.js";
|
||||
import { COMPARISON_DISABLED_PERIODS, toggleComparisons, isComparisonEnabled } from "../dashboard/comparison-input.js";
|
||||
import { shouldIgnoreKeypress } from "./keybinding";
|
||||
import { COMPARISON_DISABLED_PERIODS, toggleComparisons, isComparisonEnabled } from "../dashboard/comparison-input";
|
||||
import classNames from "classnames";
|
||||
import { useQueryContext } from "./query-context.js";
|
||||
import { useSiteContext } from "./site-context.js";
|
||||
import { useQueryContext } from "./query-context";
|
||||
import { useSiteContext } from "./site-context";
|
||||
|
||||
function KeyBindHint({children}) {
|
||||
return (
|
||||
@ -178,7 +178,7 @@ function DatePicker() {
|
||||
|
||||
const handleKeydown = useCallback((e) => {
|
||||
if (shouldIgnoreKeypress(e)) return true
|
||||
|
||||
|
||||
const newSearch = {
|
||||
period: null,
|
||||
from: null,
|
||||
@ -326,6 +326,7 @@ function DatePicker() {
|
||||
if (mode === "menu") {
|
||||
return (
|
||||
<div
|
||||
data-testid="datemenu"
|
||||
id="datemenu"
|
||||
className="absolute w-full left-0 right-0 md:w-56 md:absolute md:top-auto md:left-auto md:right-0 mt-2 origin-top-right z-10"
|
||||
>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import FlipMove from 'react-flip-move';
|
||||
import Chart from 'chart.js/auto';
|
||||
import FunnelTooltip from './funnel-tooltip.js';
|
||||
import FunnelTooltip from './funnel-tooltip';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import numberFormatter from '../util/number-formatter';
|
||||
import Bar from '../stats/bar';
|
||||
@ -10,8 +10,8 @@ import RocketIcon from '../stats/modals/rocket-icon';
|
||||
|
||||
import * as api from '../api';
|
||||
import LazyLoader from '../components/lazy-loader';
|
||||
import { useQueryContext } from '../query-context.js';
|
||||
import { useSiteContext } from '../site-context.js';
|
||||
import { useQueryContext } from '../query-context';
|
||||
import { useSiteContext } from '../site-context';
|
||||
|
||||
|
||||
export default function Funnel({ funnelName, tabs }) {
|
||||
|
182
assets/js/dashboard/query-dates.test.tsx
Normal file
182
assets/js/dashboard/query-dates.test.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import DatePicker from './datepicker'
|
||||
import { TestContextProviders } from '../../test-utils/app-context-providers'
|
||||
import { stringifySearch } from './util/url'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getRouterBasepath } from './router'
|
||||
|
||||
const domain = 'picking-query-dates.test'
|
||||
const periodStorageKey = `period__${domain}`
|
||||
|
||||
test('if no period is stored, loads with default value of "Last 30 days", all expected options are present', async () => {
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe(null)
|
||||
render(<DatePicker />, {
|
||||
wrapper: (props) => (
|
||||
<TestContextProviders siteOptions={{ domain }} {...props} />
|
||||
)
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('Last 30 days'))
|
||||
|
||||
expect(screen.getByTestId('datemenu')).toBeVisible()
|
||||
expect(screen.getAllByRole('link').map((el) => el.textContent)).toEqual(
|
||||
[
|
||||
['Today', 'D'],
|
||||
['Yesterday', 'E'],
|
||||
['Realtime', 'R'],
|
||||
['Last 7 Days', 'W'],
|
||||
['Last 30 Days', 'T'],
|
||||
['Month to Date', 'M'],
|
||||
['Last Month', ''],
|
||||
['Year to Date', 'Y'],
|
||||
['Last 12 months', 'L'],
|
||||
['All time', 'A']
|
||||
].map((a) => a.join(''))
|
||||
)
|
||||
expect(screen.getByText('Custom Range').textContent).toEqual(
|
||||
['Custom Range', 'C'].join('')
|
||||
)
|
||||
expect(screen.getByText('Compare').textContent).toEqual(
|
||||
['Compare', 'X'].join('')
|
||||
)
|
||||
})
|
||||
|
||||
test('user can select a new period and its value is stored', async () => {
|
||||
render(<DatePicker />, {
|
||||
wrapper: (props) => (
|
||||
<TestContextProviders siteOptions={{ domain }} {...props} />
|
||||
)
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('Last 30 days'))
|
||||
expect(screen.getByTestId('datemenu')).toBeVisible()
|
||||
await userEvent.click(screen.getByText('All time'))
|
||||
expect(screen.queryByTestId('datemenu')).toBeNull()
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe('all')
|
||||
})
|
||||
|
||||
test('stored period "all" is respected, and Compare option is not present for it in menu', async () => {
|
||||
localStorage.setItem(periodStorageKey, 'all')
|
||||
|
||||
render(<DatePicker />, {
|
||||
wrapper: (props) => (
|
||||
<TestContextProviders siteOptions={{ domain }} {...props} />
|
||||
)
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('All time'))
|
||||
expect(screen.getByTestId('datemenu')).toBeVisible()
|
||||
expect(screen.queryByText('Compare')).toBeNull()
|
||||
})
|
||||
|
||||
test.each([
|
||||
[{ period: 'all' }, 'All time'],
|
||||
[{ period: 'month' }, 'Month to Date'],
|
||||
[{ period: 'year' }, 'Year to Date']
|
||||
])(
|
||||
'the query period from search %p is respected and stored',
|
||||
async (searchRecord, buttonText) => {
|
||||
const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch(searchRecord)}`
|
||||
|
||||
render(<DatePicker />, {
|
||||
wrapper: (props) => (
|
||||
<TestContextProviders
|
||||
siteOptions={{ domain }}
|
||||
routerProps={{ initialEntries: [startUrl] }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(screen.getByText(buttonText)).toBeVisible()
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe(searchRecord.period)
|
||||
}
|
||||
)
|
||||
|
||||
test.each([
|
||||
[
|
||||
{ period: 'custom', from: '2024-08-10', to: '2024-08-20' },
|
||||
'10 Aug - 20 Aug'
|
||||
],
|
||||
[{ period: 'realtime' }, 'Realtime']
|
||||
])(
|
||||
'the query period from search %p is respected but not stored',
|
||||
async (searchRecord, buttonText) => {
|
||||
const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch(searchRecord)}`
|
||||
|
||||
render(<DatePicker />, {
|
||||
wrapper: (props) => (
|
||||
<TestContextProviders
|
||||
siteOptions={{ domain }}
|
||||
routerProps={{ initialEntries: [startUrl] }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
expect(screen.getByText(buttonText)).toBeVisible()
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe(null)
|
||||
}
|
||||
)
|
||||
|
||||
test.each([
|
||||
['all', '7d', 'Last 7 days'],
|
||||
['30d', 'month', 'Month to Date']
|
||||
])(
|
||||
'if the stored period is %p but query period is %p, query is respected and the stored period is overwritten',
|
||||
async (storedPeriod, queryPeriod, buttonText) => {
|
||||
localStorage.setItem(periodStorageKey, storedPeriod)
|
||||
const startUrl = `${getRouterBasepath({ domain, shared: false })}${stringifySearch({ period: queryPeriod })}`
|
||||
|
||||
render(<DatePicker />, {
|
||||
wrapper: (props) => (
|
||||
<TestContextProviders
|
||||
siteOptions={{ domain, shared: false }}
|
||||
routerProps={{
|
||||
initialEntries: [startUrl]
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText(buttonText))
|
||||
expect(screen.getByTestId('datemenu')).toBeVisible()
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe(queryPeriod)
|
||||
}
|
||||
)
|
||||
|
||||
test('going back resets the stored query period to previous value', async () => {
|
||||
const BrowserBackButton = () => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<button data-testid="browser-back" onClick={() => navigate(-1)}></button>
|
||||
)
|
||||
}
|
||||
render(
|
||||
<>
|
||||
<DatePicker />
|
||||
<BrowserBackButton />
|
||||
</>,
|
||||
{
|
||||
wrapper: (props) => (
|
||||
<TestContextProviders siteOptions={{ domain }} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Last 30 days'))
|
||||
await userEvent.click(screen.getByText('Year to Date'))
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe('year')
|
||||
|
||||
await userEvent.click(screen.getByText('Year to Date'))
|
||||
await userEvent.click(screen.getByText('Month to Date'))
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe('month')
|
||||
|
||||
await userEvent.click(screen.getByTestId('browser-back'))
|
||||
expect(screen.getByText('Year to Date')).toBeVisible()
|
||||
expect(localStorage.getItem(periodStorageKey)).toBe('year')
|
||||
})
|
@ -156,11 +156,15 @@ export const filterRoute = {
|
||||
element: <FilterModal />
|
||||
}
|
||||
|
||||
export function createAppRouter(site) {
|
||||
export function getRouterBasepath(site) {
|
||||
const basepath = site.shared
|
||||
? `/share/${encodeURIComponent(site.domain)}`
|
||||
: `/${encodeURIComponent(site.domain)}`
|
||||
return basepath
|
||||
}
|
||||
|
||||
export function createAppRouter(site) {
|
||||
const basepath = getRouterBasepath(site)
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { METRIC_FORMATTER, METRIC_LABELS } from './graph-util.js'
|
||||
import dateFormatter from './date-formatter.js'
|
||||
import { METRIC_FORMATTER, METRIC_LABELS } from './graph-util'
|
||||
import dateFormatter from './date-formatter'
|
||||
|
||||
const renderBucketLabel = function(query, graphData, label, comparison = false) {
|
||||
let isPeriodFull = graphData.full_intervals?.[label]
|
||||
|
@ -3,9 +3,9 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||
import React, { Fragment, useCallback, useEffect } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as storage from '../../util/storage';
|
||||
import { isKeyPressed } from '../../keybinding.js';
|
||||
import { useQueryContext } from '../../query-context.js';
|
||||
import { useSiteContext } from '../../site-context.js';
|
||||
import { isKeyPressed } from '../../keybinding';
|
||||
import { useQueryContext } from '../../query-context';
|
||||
import { useSiteContext } from '../../site-context';
|
||||
|
||||
const INTERVAL_LABELS = {
|
||||
'minute': 'Minutes',
|
||||
|
@ -4,10 +4,10 @@ import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load';
|
||||
import classNames from "classnames";
|
||||
import numberFormatter, { durationFormatter } from '../../util/number-formatter';
|
||||
import * as storage from '../../util/storage';
|
||||
import { formatDateRange } from '../../util/date.js';
|
||||
import { getGraphableMetrics } from "./graph-util.js";
|
||||
import { useQueryContext } from "../../query-context.js";
|
||||
import { useSiteContext } from "../../site-context.js";
|
||||
import { formatDateRange } from '../../util/date';
|
||||
import { getGraphableMetrics } from "./graph-util";
|
||||
import { useQueryContext } from "../../query-context";
|
||||
import { useSiteContext } from "../../site-context";
|
||||
|
||||
function Maybe({ condition, children }) {
|
||||
if (condition) {
|
||||
|
@ -2,28 +2,20 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import classNames from 'classnames'
|
||||
// @ts-expect-error untyped
|
||||
import * as api from '../../api'
|
||||
// @ts-expect-error untyped
|
||||
import { navigateToQuery } from '../../query'
|
||||
// @ts-expect-error untyped
|
||||
import { replaceFilterByPrefix, cleanLabels } from '../../util/filters'
|
||||
import { useAppNavigate } from '../../navigation/use-app-navigate'
|
||||
// @ts-expect-error untyped
|
||||
import numberFormatter from '../../util/number-formatter'
|
||||
import * as topojson from 'topojson-client'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
// @ts-expect-error untyped
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import worldJson from 'visionscarto-world-atlas/world/110m.json'
|
||||
import { UIMode, useTheme } from '../../theme-context'
|
||||
import { apiPath } from '../../util/url'
|
||||
// @ts-expect-error untyped
|
||||
import MoreLink from '../more-link'
|
||||
// @ts-expect-error untyped
|
||||
import { countriesRoute } from '../../router'
|
||||
// @ts-expect-error untyped
|
||||
import { MIN_HEIGHT } from '../reports/list'
|
||||
import { MapTooltip } from './map-tooltip'
|
||||
import { GeolocationNotice } from './geolocation-notice'
|
||||
@ -49,7 +41,8 @@ const WorldMap = ({
|
||||
const navigate = useAppNavigate()
|
||||
const { mode } = useTheme()
|
||||
const site = useSiteContext()
|
||||
const { query } = useQueryContext()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { query } = useQueryContext() as { query: any }
|
||||
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number
|
||||
@ -193,9 +186,10 @@ const WorldMap = ({
|
||||
list={data?.results ?? []}
|
||||
linkProps={{
|
||||
path: countriesRoute.path,
|
||||
// @ts-expect-error MoreLink not typed yet
|
||||
search: (search) => search
|
||||
search: (search: Record<string, unknown>) => search
|
||||
}}
|
||||
className={undefined}
|
||||
onClick={undefined}
|
||||
/>
|
||||
{site.isDbip && <GeolocationNotice />}
|
||||
</div>
|
||||
|
244
assets/js/dashboard/util/url-search-params.test.ts
Normal file
244
assets/js/dashboard/util/url-search-params.test.ts
Normal file
@ -0,0 +1,244 @@
|
||||
/** @format */
|
||||
|
||||
import JsonURL from '@jsonurl/jsonurl'
|
||||
import {
|
||||
encodeSearchParamEntry,
|
||||
encodeURIComponentPermissive,
|
||||
isSearchEntryDefined,
|
||||
parseSearch,
|
||||
parseSearchFragment,
|
||||
stringifySearch,
|
||||
stringifySearchEntry
|
||||
} from './url'
|
||||
|
||||
describe('using json URL parsing with URLSearchParams intermediate', () => {
|
||||
it.each([['#'], ['&'], ['=']])('throws on special symbol %p', (s) => {
|
||||
const searchString = `?param=${encodeURIComponent(s)}`
|
||||
expect(() =>
|
||||
JsonURL.parse(new URLSearchParams(searchString).get('param')!)
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe(`${encodeURIComponentPermissive.name}`, () => {
|
||||
it.each<[string, string]>([
|
||||
['10.00.00/1', '10.00.00/1'],
|
||||
['#hashtag', '%23hashtag'],
|
||||
['100$ coupon', '100%24%20coupon'],
|
||||
['Visit /any/page', 'Visit%20/any/page'],
|
||||
['A,B,C', 'A,B,C'],
|
||||
['props:colon/forward/slash/signs', 'props:colon/forward/slash/signs'],
|
||||
['https://example.com/path', 'https://example.com/path']
|
||||
])(
|
||||
'when input is %p, returns %s and decodes back to input',
|
||||
(input, expected) => {
|
||||
const result = encodeURIComponentPermissive(input)
|
||||
expect(result).toBe(expected)
|
||||
expect(decodeURIComponent(result)).toBe(input)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe(`${isSearchEntryDefined.name}`, () => {
|
||||
it.each<[[string, string | undefined], boolean]>([
|
||||
[['key', undefined], false],
|
||||
[['key', 'value'], true],
|
||||
[['key', ''], true],
|
||||
[['anotherKey', 'undefined'], true]
|
||||
])('when entry is %p, returns %s', (entry, expected) => {
|
||||
const result = isSearchEntryDefined(entry)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`${stringifySearchEntry.name}`, () => {
|
||||
it.each<[[string, unknown], [string, string | undefined]]>([
|
||||
[
|
||||
['any-key', {}],
|
||||
['any-key', undefined]
|
||||
],
|
||||
[
|
||||
['any-key', []],
|
||||
['any-key', undefined]
|
||||
],
|
||||
[
|
||||
['any-key', null],
|
||||
['any-key', undefined]
|
||||
],
|
||||
[
|
||||
['period', 'realtime'],
|
||||
['period', 'realtime']
|
||||
],
|
||||
[
|
||||
['page', 10],
|
||||
['page', '10']
|
||||
],
|
||||
[
|
||||
['labels', { US: 'United States', 3448439: 'São Paulo' }],
|
||||
['labels', '(3448439:S%C3%A3o+Paulo,US:United+States)']
|
||||
],
|
||||
[
|
||||
['filters', [['is', 'props:foo:bar', ['one', 'two']]]],
|
||||
['filters', "((is,'props:foo:bar',(one,two)))"]
|
||||
]
|
||||
])('when input is %p, returns %p', (input, expected) => {
|
||||
const result = stringifySearchEntry(input)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`${encodeSearchParamEntry.name}`, () => {
|
||||
it.each<[[string, string], string]>([
|
||||
[
|
||||
['labels', '(3448439:S%C3%A3o+Paulo,US:United+States)'],
|
||||
'labels=(3448439:S%25C3%25A3o%2BPaulo,US:United%2BStates)'
|
||||
]
|
||||
])('when input is %p, returns %s', (input, expected) => {
|
||||
const result = encodeSearchParamEntry(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`${parseSearchFragment.name}`, () => {
|
||||
it.each([
|
||||
['', null],
|
||||
['("foo":)', null],
|
||||
['(invalid', null],
|
||||
['null', null],
|
||||
|
||||
['123', 123],
|
||||
['string', 'string'],
|
||||
['item=#', 'item=#'],
|
||||
['item%3D%23', 'item=#'],
|
||||
|
||||
['(any:(number:1))', { any: { number: 1 } }],
|
||||
['(any:(number:1.001))', { any: { number: 1.001 } }],
|
||||
["(any:(string:'1.001'))", { any: { string: '1.001' } }],
|
||||
|
||||
// Non-JSON strings that should return as string
|
||||
['undefined', 'undefined'],
|
||||
['not_json', 'not_json'],
|
||||
['plainstring', 'plainstring']
|
||||
])(
|
||||
'when searchStringFragment is %p, returns %p',
|
||||
(searchStringFragment, expected) => {
|
||||
const result = parseSearchFragment(searchStringFragment)
|
||||
expect(result).toEqual(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe(`${parseSearch.name}`, () => {
|
||||
it.each([
|
||||
['', {}],
|
||||
['?', {}],
|
||||
[
|
||||
'?arr=(1,2)',
|
||||
{
|
||||
arr: [1, 2]
|
||||
}
|
||||
],
|
||||
['?key1=value1&key2=', { key1: 'value1', key2: null }],
|
||||
['?key1=value1&key2=value2', { key1: 'value1', key2: 'value2' }],
|
||||
[
|
||||
'?key1=(foo:bar)&filters=((is,screen,(Mobile,Desktop)))',
|
||||
{
|
||||
key1: { foo: 'bar' },
|
||||
filters: [['is', 'screen', ['Mobile', 'Desktop']]]
|
||||
}
|
||||
],
|
||||
[
|
||||
'?filters=((is,country,(US)))&labels=(US:United%2BStates)',
|
||||
{
|
||||
filters: [['is', 'country', ['US']]],
|
||||
labels: {
|
||||
US: 'United States'
|
||||
}
|
||||
}
|
||||
]
|
||||
])('when searchString is %p, returns %p', (searchString, expected) => {
|
||||
const result = parseSearch(searchString)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`${stringifySearch.name} and ${parseSearch.name} are inverses of each other`, () => {
|
||||
it.each([
|
||||
["?filters=((is,'props:browser_language',(en-US)))"],
|
||||
[
|
||||
'?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(/open-source-website-analytics)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10'
|
||||
],
|
||||
[
|
||||
"?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)"
|
||||
],
|
||||
[
|
||||
'?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D'
|
||||
]
|
||||
])(
|
||||
`input %p is returned for ${stringifySearch.name}(${parseSearch.name}(input))`,
|
||||
(searchString) => {
|
||||
const searchRecord = parseSearch(searchString)
|
||||
const reStringifiedSearch = stringifySearch(searchRecord)
|
||||
expect(reStringifiedSearch).toEqual(searchString)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
// Corresponding test cases for objects parsed from realistic URLs
|
||||
|
||||
[
|
||||
{
|
||||
filters: [['is', 'props:browser_language', ['en-US']]]
|
||||
},
|
||||
"?filters=((is,'props:browser_language',(en-US)))"
|
||||
],
|
||||
[
|
||||
{
|
||||
filters: [
|
||||
['contains', 'utm_term', ['_']],
|
||||
['is', 'screen', ['Desktop', 'Tablet']],
|
||||
['is', 'page', ['/open-source/analytics/encoded-hash%23']]
|
||||
],
|
||||
period: 'custom',
|
||||
keybindHint: 'A',
|
||||
comparison: 'previous_period',
|
||||
match_day_of_week: false,
|
||||
from: '2024-08-08',
|
||||
to: '2024-08-10'
|
||||
},
|
||||
'?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(%252Fopen-source%252Fanalytics%252Fencoded-hash%252523)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10'
|
||||
],
|
||||
[
|
||||
{
|
||||
filters: [
|
||||
['is', 'props:browser_language', ['en-US']],
|
||||
['is', 'country', ['US']],
|
||||
['is', 'os', ['iOS']],
|
||||
['is', 'os_version', ['17.3']],
|
||||
['is', 'page', ['/:dashboard/settings/general']]
|
||||
],
|
||||
labels: { US: 'United States' }
|
||||
},
|
||||
"?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)"
|
||||
],
|
||||
[
|
||||
{
|
||||
filters: [
|
||||
['is', 'utm_source', ['hackernewsletter']],
|
||||
['is', 'utm_campaign', ['profile']]
|
||||
],
|
||||
period: 'day',
|
||||
keybindHint: 'D'
|
||||
},
|
||||
'?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D'
|
||||
]
|
||||
])(
|
||||
`for input %p, ${stringifySearch.name}(input) returns %p and ${parseSearch.name}(${stringifySearch.name}(input)) returns the original input`,
|
||||
(searchRecord, expected) => {
|
||||
const searchString = stringifySearch(searchRecord)
|
||||
const parsedSearchRecord = parseSearch(searchString)
|
||||
expect(parsedSearchRecord).toEqual(searchRecord)
|
||||
expect(searchString).toEqual(expected)
|
||||
}
|
||||
)
|
||||
})
|
97
assets/js/dashboard/util/url.test.ts
Normal file
97
assets/js/dashboard/util/url.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/** @format */
|
||||
|
||||
import { apiPath, externalLinkForPage, isValidHttpUrl, trimURL } from './url'
|
||||
|
||||
describe('apiPath', () => {
|
||||
it.each([
|
||||
['example.com', undefined, '/api/stats/example.com/'],
|
||||
['example.com', '', '/api/stats/example.com/'],
|
||||
['example.com', '/test', '/api/stats/example.com/test/'],
|
||||
[
|
||||
'example.com/path/is-really/deep',
|
||||
'',
|
||||
'/api/stats/example.com%2Fpath%2Fis-really%2Fdeep/'
|
||||
]
|
||||
])(
|
||||
'when site.domain is %p and path is %s, should return %s',
|
||||
(domain, path, expected) => {
|
||||
const result = apiPath({ domain }, path)
|
||||
expect(result).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('externalLinkForPage', () => {
|
||||
it.each([
|
||||
['example.com', '/about', 'https://example.com/about'],
|
||||
['sub.example.com', '/contact', 'https://sub.example.com/contact'],
|
||||
[
|
||||
'example.com',
|
||||
'/search?q=test#section',
|
||||
'https://example.com/search?q=test#section'
|
||||
],
|
||||
['example.com', '/', 'https://example.com/']
|
||||
])(
|
||||
'when domain is %s and page is %s, it should return %s',
|
||||
(domain, page, expected) => {
|
||||
const result = externalLinkForPage(domain, page)
|
||||
expect(result).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('isValidHttpUrl', () => {
|
||||
it.each([
|
||||
// Valid HTTP and HTTPS URLs
|
||||
['http://example.com', true],
|
||||
['https://example.com', true],
|
||||
['http://www.example.com', true],
|
||||
['https://sub.domain.com', true],
|
||||
['https://example.com/path?query=1#fragment', true],
|
||||
|
||||
// Invalid URLs (invalid protocol)
|
||||
['ftp://example.com', false],
|
||||
['mailto:someone@example.com', false],
|
||||
['file:///C:/path/to/file', false],
|
||||
['data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', false],
|
||||
|
||||
// Invalid URLs (malformed or non-URL strings)
|
||||
['//example.com', false],
|
||||
['example.com', false],
|
||||
['just-a-string', false],
|
||||
['', false],
|
||||
['https//:example.com', false],
|
||||
|
||||
// Edge cases
|
||||
['http:/example.com', true],
|
||||
['http://localhost', true],
|
||||
['https://127.0.0.1', true],
|
||||
['https://[::1]', true], // IPv6 URL
|
||||
['http://user:pass@127.0.0.1', true],
|
||||
['https://example.com:8080', true]
|
||||
])('for input %s returns %s', (input, expected) => {
|
||||
const result = isValidHttpUrl(input)
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trimURL', () => {
|
||||
it.each([
|
||||
// Test cases where URL length is less than or equal to maxLength
|
||||
['https://example.com', 20, 'https://example.com'],
|
||||
['http://example.com', 50, 'http://example.com'],
|
||||
|
||||
// Test cases where host itself is too long
|
||||
[
|
||||
'https://a-very-long-domain-name.com',
|
||||
20,
|
||||
'https://a-very-long-dom...domain-name.com'
|
||||
]
|
||||
])(
|
||||
'when url is %s and maxLength is %d, should return %s',
|
||||
(url, maxLength, expected) => {
|
||||
const result = trimURL(url, maxLength)
|
||||
expect(result).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
@ -2,7 +2,10 @@
|
||||
import JsonURL from '@jsonurl/jsonurl'
|
||||
import { PlausibleSite } from '../site-context'
|
||||
|
||||
export function apiPath(site: PlausibleSite, path = ''): string {
|
||||
export function apiPath(
|
||||
site: Pick<PlausibleSite, 'domain'>,
|
||||
path = ''
|
||||
): string {
|
||||
return `/api/stats/${encodeURIComponent(site.domain)}${path}/`
|
||||
}
|
||||
|
||||
@ -79,7 +82,7 @@ export function encodeURIComponentPermissive(input: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function encodeSearchParamEntries([k, v]: [string, string]): string {
|
||||
export function encodeSearchParamEntry([k, v]: [string, string]): string {
|
||||
return `${encodeURIComponentPermissive(k)}=${encodeURIComponentPermissive(v)}`
|
||||
}
|
||||
|
||||
@ -92,15 +95,11 @@ export function isSearchEntryDefined(
|
||||
export function stringifySearch(
|
||||
searchRecord: Record<string, unknown>
|
||||
): '' | string {
|
||||
const definedSearchEntries = Object.entries(
|
||||
searchRecord || ({} as Record<string, unknown>)
|
||||
)
|
||||
const definedSearchEntries = Object.entries(searchRecord || {})
|
||||
.map(stringifySearchEntry)
|
||||
.filter(isSearchEntryDefined)
|
||||
|
||||
const encodedSearchEntries = definedSearchEntries.map(
|
||||
encodeSearchParamEntries
|
||||
)
|
||||
const encodedSearchEntries = definedSearchEntries.map(encodeSearchParamEntry)
|
||||
|
||||
return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : ''
|
||||
}
|
||||
@ -126,6 +125,8 @@ export function parseSearchFragment(
|
||||
if (searchStringFragment === '') {
|
||||
return null
|
||||
}
|
||||
// tricky: the search string fragment is already decoded due to URLSearchParams intermediate (see tests),
|
||||
// and these symbols are unparseable
|
||||
const fragmentWithReEncodedSymbols = searchStringFragment
|
||||
/* @ts-expect-error API supposedly not present in compilation target */
|
||||
.replaceAll('=', encodeURIComponent('='))
|
||||
|
5418
assets/package-lock.json
generated
5418
assets/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@
|
||||
"version": "1.4.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"format": "prettier --write",
|
||||
"check-format": "prettier --check **/*.{js,css,ts,tsx} --require-pragma",
|
||||
"eslint": "eslint js/**",
|
||||
@ -44,8 +45,13 @@
|
||||
"visionscarto-world-atlas": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/classnames": "^2.3.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/topojson-client": "^3.1.4",
|
||||
@ -55,12 +61,16 @@
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jest": "^28.8.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"prettier": "^3.3.3",
|
||||
"stylelint": "^16.8.1",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"ts-jest": "^29.2.4",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"name": "assets"
|
||||
|
75
assets/test-utils/app-context-providers.tsx
Normal file
75
assets/test-utils/app-context-providers.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
/** @format */
|
||||
|
||||
import React, { ReactNode } from 'react'
|
||||
import SiteContextProvider, {
|
||||
PlausibleSite
|
||||
} from '../js/dashboard/site-context'
|
||||
import UserContextProvider, { Role } from '../js/dashboard/user-context'
|
||||
import { MemoryRouter, MemoryRouterProps } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import QueryContextProvider from '../js/dashboard/query-context'
|
||||
import { getRouterBasepath } from '../js/dashboard/router'
|
||||
|
||||
type TestContextProvidersProps = {
|
||||
children: ReactNode
|
||||
routerProps?: Pick<MemoryRouterProps, 'initialEntries'>
|
||||
siteOptions?: Partial<PlausibleSite>
|
||||
}
|
||||
|
||||
export const TestContextProviders = ({
|
||||
children,
|
||||
routerProps,
|
||||
siteOptions
|
||||
}: TestContextProvidersProps) => {
|
||||
const defaultSite: PlausibleSite = {
|
||||
domain: 'plausible.io/unit',
|
||||
offset: '0',
|
||||
hasGoals: false,
|
||||
hasProps: false,
|
||||
funnelsAvailable: false,
|
||||
propsAvailable: false,
|
||||
conversionsOptedOut: false,
|
||||
funnelsOptedOut: false,
|
||||
propsOptedOut: false,
|
||||
revenueGoals: [],
|
||||
funnels: [],
|
||||
statsBegin: '',
|
||||
nativeStatsBegin: '',
|
||||
embedded: '',
|
||||
background: '',
|
||||
isDbip: false,
|
||||
flags: {},
|
||||
validIntervalsByPeriod: {},
|
||||
shared: false
|
||||
}
|
||||
|
||||
const site = { ...defaultSite, ...siteOptions }
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const defaultInitialEntries = [getRouterBasepath(site)]
|
||||
|
||||
return (
|
||||
// <ThemeContextProvider> not interactive component, default value is suitable
|
||||
<SiteContextProvider site={site}>
|
||||
<UserContextProvider role={Role.admin} loggedIn={true}>
|
||||
<MemoryRouter
|
||||
basename={getRouterBasepath(site)}
|
||||
initialEntries={defaultInitialEntries}
|
||||
{...routerProps}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryContextProvider>{children}</QueryContextProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
</UserContextProvider>
|
||||
</SiteContextProvider>
|
||||
// </ThemeContextProvider>
|
||||
)
|
||||
}
|
1
assets/test-utils/extend-expect.ts
Normal file
1
assets/test-utils/extend-expect.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
13
assets/test-utils/reset-state.ts
Normal file
13
assets/test-utils/reset-state.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/** @format */
|
||||
|
||||
/**
|
||||
* @returns clears the state that the app stores,
|
||||
* to avoid individual tests impacting each other
|
||||
*/
|
||||
function clearStoredAppState() {
|
||||
localStorage.clear()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearStoredAppState()
|
||||
})
|
13
assets/test-utils/set-fixed-timezone.ts
Normal file
13
assets/test-utils/set-fixed-timezone.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @format
|
||||
*/
|
||||
|
||||
/**
|
||||
* @returns sets a fixed timezone for the test process,
|
||||
* otherwise test runs on different servers and machines may be inconsistent
|
||||
*/
|
||||
function setFixedTimezone() {
|
||||
process.env.TZ = 'UTC'
|
||||
}
|
||||
|
||||
setFixedTimezone()
|
@ -3,6 +3,7 @@
|
||||
"jsx": "react",
|
||||
"target": "es2017",
|
||||
"module": "commonjs",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
Loading…
Reference in New Issue
Block a user