Add testing framework (#4440)

* Add testing framework

* Test query period picking behaviour
This commit is contained in:
Artur Pata 2024-08-15 12:27:22 +03:00 committed by GitHub
parent 4d4c9a8a21
commit 8af3f7332a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 6096 additions and 72 deletions

View File

@ -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
View File

@ -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.

View File

@ -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

View File

@ -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
View 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"
}
}

View File

@ -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',

View File

@ -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"
>

View File

@ -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 }) {

View 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')
})

View File

@ -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(
[
{

View File

@ -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]

View File

@ -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',

View File

@ -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) {

View File

@ -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>

View 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)
}
)
})

View 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)
}
)
})

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View 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>
)
}

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

View 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()
})

View 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()

View File

@ -3,6 +3,7 @@
"jsx": "react",
"target": "es2017",
"module": "commonjs",
"allowJs": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,