fix(tracker): fix breaking q sender test

This commit is contained in:
nick-delirium 2023-05-31 12:52:51 +02:00
parent 5ecd7b86f8
commit 4e3332304e
5 changed files with 41 additions and 667 deletions

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 20182020 Anton Medvedev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,132 +0,0 @@
![finder](https://medv.io/assets/finder.png)
# finder
[![npm](https://img.shields.io/npm/v/@medv/finder?color=grightgreen)](https://www.npmjs.com/package/@medv/finder)
[![Build status](https://img.shields.io/travis/antonmedv/finder)](https://travis-ci.org/antonmedv/finder)
[![npm bundle size](https://img.shields.io/bundlephobia/minzip/@medv/finder?label=size)](https://bundlephobia.com/result?p=@medv/finder)
> CSS Selector Generator
## Features
* Generates **shortest** selectors
* **Unique** selectors per page
* Stable and **robust** selectors
* **2.1 kB** gzip and minify size
## Install
```bash
npm install @medv/finder
```
Finder can be used via modules:
```html
<script type="module">
import {finder} from 'https://medv.io/finder/finder.js'
</script>
```
## Usage
```js
import {finder} from '@medv/finder'
document.addEventListener('click', event => {
const selector = finder(event.target)
console.log(selector)
})
```
## Example
Example of generated selector:
```css
.blog > article:nth-child(3) .add-comment
```
## Configuration
`finder` takes configuration object as second parameters. Here is example of all params with default values:
```js
const selector = finder(event.target, {
root: document.body,
className: (name) => true,
tagName: (name) => true,
attr: (name, value) => false,
seedMinLength: 1,
optimizedMinLength: 2,
threshold: 1000,
maxNumberOfTries: 10_000,
})
```
#### `root: Element`
Root of search, defaults to `document.body`.
#### `idName: (name: string) => boolean`
Check if this ID can be used. For example you can restrict using framework specific IDs:
```js
const selector = finder(event.target, {
idName: name => !name.startsWith('ember')
})
```
#### `className: (name: string) => boolean`
Check if this class name can be used. For example you can restrict using _is-*_ class names:
```js
const selector = finder(event.target, {
className: name => !name.startsWith('is-')
})
```
#### `tagName: (name: string) => boolean`
Check if tag name can be used, same as `className`.
#### `attr: (name: string, value: string) => boolean`
Check if attr name can be used.
#### `seedMinLength: number`
Minimum length of levels in fining selector. Starts from `1`.
For more robust selectors give this param value around 4-5 depending on depth of you DOM tree.
If `finder` hits `root` this param is ignored.
#### `optimizedMinLength: number`
Minimum length for optimising selector. Starts from `2`.
For example selector `body > div > div > p` can be optimized to `body p`.
#### `threshold: number`
Max number of selectors to check before falling into `nth-child` usage.
Checking for uniqueness of selector is very costs operation, if you have DOM tree depth of 5, with 5 classes on each level,
that gives you more than 3k selectors to check.
`finder` uses two step approach so it's reaching this threshold in some cases twice.
Default `1000` is good enough in most cases.
#### `maxNumberOfTries: number`
Max number of tries when we do the optimization. It is a trade-off between optimization and efficiency.
Default `10_000` is good enough in most cases.
### Google Chrome Extension
![Chrome Extension](https://user-images.githubusercontent.com/141232/36737287-4a999d84-1c0d-11e8-8a14-43bcf9baf7ca.png)
Generate the unique selectors in your browser by using [Chrome Extension](https://chrome.google.com/webstore/detail/get-unique-css-selector/lkfaghhbdebclkklgjhhonadomejckai)
## License
[MIT](LICENSE)

View file

@ -1,432 +0,0 @@
type Node = {
name: string
penalty: number
level?: number
}
type Path = Node[]
enum Limit {
All,
Two,
One,
}
export type Options = {
root: Element
idName: (name: string) => boolean
className: (name: string) => boolean
tagName: (name: string) => boolean
attr: (name: string, value: string) => boolean
seedMinLength: number
optimizedMinLength: number
threshold: number
maxNumberOfTries: number
}
let config: Options
let rootDocument: Document | Element
export function finder(input: Element, options?: Partial<Options>) {
if (input.nodeType !== Node.ELEMENT_NODE) {
throw new Error("Can't generate CSS selector for non-element node type.")
}
if ('html' === input.tagName.toLowerCase()) {
return 'html'
}
const defaults: Options = {
root: document.body,
idName: (name: string) => true,
className: (name: string) => true,
tagName: (name: string) => true,
attr: (name: string, value: string) => false,
seedMinLength: 1,
optimizedMinLength: 2,
threshold: 1000,
maxNumberOfTries: 10000,
}
config = { ...defaults, ...options }
rootDocument = findRootDocument(config.root, defaults)
let path = bottomUpSearch(input, Limit.All, () =>
bottomUpSearch(input, Limit.Two, () => bottomUpSearch(input, Limit.One)),
)
if (path) {
const optimized = sort(optimize(path, input))
if (optimized.length > 0) {
path = optimized[0]
}
return selector(path)
} else {
throw new Error('Selector was not found.')
}
}
function findRootDocument(rootNode: Element | Document, defaults: Options) {
if (rootNode.nodeType === Node.DOCUMENT_NODE) {
return rootNode
}
if (rootNode === defaults.root) {
return rootNode.ownerDocument
}
return rootNode
}
function bottomUpSearch(input: Element, limit: Limit, fallback?: () => Path | null): Path | null {
let path: Path | null = null
const stack: Node[][] = []
let current: Element | null = input
let i = 0
while (current && current !== config.root.parentElement) {
let level: Node[] = maybe(id(current)) ||
maybe(...attr(current)) ||
maybe(...classNames(current)) ||
maybe(tagName(current)) || [any()]
const nth = index(current)
if (limit === Limit.All) {
if (nth) {
level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth)))
}
} else if (limit === Limit.Two) {
level = level.slice(0, 1)
if (nth) {
level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth)))
}
} else if (limit === Limit.One) {
const [node] = (level = level.slice(0, 1))
if (nth && dispensableNth(node)) {
level = [nthChild(node, nth)]
}
}
for (const node of level) {
node.level = i
}
stack.push(level)
if (stack.length >= config.seedMinLength) {
path = findUniquePath(stack, fallback)
if (path) {
break
}
}
current = current.parentElement
i++
}
if (!path) {
path = findUniquePath(stack, fallback)
}
return path
}
function findUniquePath(stack: Node[][], fallback?: () => Path | null): Path | null {
const paths = sort(combinations(stack))
if (paths.length > config.threshold) {
return fallback ? fallback() : null
}
for (const candidate of paths) {
if (unique(candidate)) {
return candidate
}
}
return null
}
function selector(path: Path): string {
let node = path[0]
let query = node.name
for (let i = 1; i < path.length; i++) {
const level = path[i].level || 0
if (node.level === level - 1) {
query = `${path[i].name} > ${query}`
} else {
query = `${path[i].name} ${query}`
}
node = path[i]
}
return query
}
function penalty(path: Path): number {
return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0)
}
function unique(path: Path) {
switch (rootDocument.querySelectorAll(selector(path)).length) {
case 0:
throw new Error(`Can't select any node with this selector: ${selector(path)}`)
case 1:
return true
default:
return false
}
}
function id(input: Element): Node | null {
const elementId = input.getAttribute('id')
if (elementId && config.idName(elementId)) {
return {
name: '#' + cssesc(elementId, { isIdentifier: true }),
penalty: 0,
}
}
return null
}
function attr(input: Element): Node[] {
const attrs = Array.from(input.attributes).filter((attr) => config.attr(attr.name, attr.value))
return attrs.map(
(attr): Node => ({
name: '[' + cssesc(attr.name, { isIdentifier: true }) + '="' + cssesc(attr.value) + '"]',
penalty: 0.5,
}),
)
}
function classNames(input: Element): Node[] {
const names = Array.from(input.classList).filter(config.className)
return names.map(
(name): Node => ({
name: '.' + cssesc(name, { isIdentifier: true }),
penalty: 1,
}),
)
}
function tagName(input: Element): Node | null {
const name = input.tagName.toLowerCase()
if (config.tagName(name)) {
return {
name,
penalty: 2,
}
}
return null
}
function any(): Node {
return {
name: '*',
penalty: 3,
}
}
function index(input: Element): number | null {
const parent = input.parentNode
if (!parent) {
return null
}
let child = parent.firstChild
if (!child) {
return null
}
let i = 0
while (child) {
if (child.nodeType === Node.ELEMENT_NODE) {
i++
}
if (child === input) {
break
}
child = child.nextSibling
}
return i
}
function nthChild(node: Node, i: number): Node {
return {
name: node.name + `:nth-child(${i})`,
penalty: node.penalty + 1,
}
}
function dispensableNth(node: Node) {
return node.name !== 'html' && !node.name.startsWith('#')
}
function maybe(...level: (Node | null)[]): Node[] | null {
const list = level.filter(notEmpty)
if (list.length > 0) {
return list
}
return null
}
function notEmpty<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
function combinations(stack: Node[][], path: Node[] = []): Node[][] {
const paths: Node[][] = []
if (stack.length > 0) {
for (const node of stack[0]) {
paths.push(...combinations(stack.slice(1, stack.length), path.concat(node)))
}
} else {
paths.push(path)
}
return paths
}
function sort(paths: Iterable<Path>): Path[] {
return Array.from(paths).sort((a, b) => penalty(a) - penalty(b))
}
type Scope = {
counter: number
visited: Map<string, boolean>
}
function optimize(
path: Path,
input: Element,
scope: Scope = {
counter: 0,
visited: new Map<string, boolean>(),
},
): Node[][] {
const paths: Node[][] = []
if (path.length > 2 && path.length > config.optimizedMinLength) {
for (let i = 1; i < path.length - 1; i++) {
if (scope.counter > config.maxNumberOfTries) {
return paths // Okay At least I tried!
}
scope.counter += 1
const newPath = [...path]
newPath.splice(i, 1)
const newPathKey = selector(newPath)
if (scope.visited.has(newPathKey)) {
return paths
}
if (unique(newPath) && same(newPath, input)) {
paths.push(newPath)
scope.visited.set(newPathKey, true)
paths.push(...optimize(newPath, input, scope))
}
}
}
return paths
}
function same(path: Path, input: Element) {
return rootDocument.querySelector(selector(path)) === input
}
const regexAnySingleEscape = /[ -,\.\/:-@\[-\^`\{-~]/
const regexSingleEscape = /[ -,\.\/:-@\[\]\^`\{-~]/
const regexExcessiveSpaces = /(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g
const defaultOptions = {
escapeEverything: false,
isIdentifier: false,
quotes: 'single',
wrap: false,
}
function cssesc(string: string, opt: Partial<typeof defaultOptions> = {}) {
const options = { ...defaultOptions, ...opt }
if (options.quotes != 'single' && options.quotes != 'double') {
options.quotes = 'single'
}
const quote = options.quotes == 'double' ? '"' : "'"
const isIdentifier = options.isIdentifier
const firstChar = string.charAt(0)
let output = ''
let counter = 0
const length = string.length
while (counter < length) {
const character = string.charAt(counter++)
let codePoint = character.charCodeAt(0)
let value: string | undefined = void 0
// If its not a printable ASCII character…
if (codePoint < 0x20 || codePoint > 0x7e) {
if (codePoint >= 0xd800 && codePoint <= 0xdbff && counter < length) {
// Its a high surrogate, and there is a next character.
const extra = string.charCodeAt(counter++)
if ((extra & 0xfc00) == 0xdc00) {
// next character is low surrogate
codePoint = ((codePoint & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000
} else {
// Its an unmatched surrogate; only append this code unit, in case
// the next code unit is the high surrogate of a surrogate pair.
counter--
}
}
value = '\\' + codePoint.toString(16).toUpperCase() + ' '
} else {
if (options.escapeEverything) {
if (regexAnySingleEscape.test(character)) {
value = '\\' + character
} else {
value = '\\' + codePoint.toString(16).toUpperCase() + ' '
}
} else if (/[\t\n\f\r\x0B]/.test(character)) {
value = '\\' + codePoint.toString(16).toUpperCase() + ' '
} else if (
character == '\\' ||
(!isIdentifier &&
((character == '"' && quote == character) || (character == "'" && quote == character))) ||
(isIdentifier && regexSingleEscape.test(character))
) {
value = '\\' + character
} else {
value = character
}
}
output += value
}
if (isIdentifier) {
if (/^-[-\d]/.test(output)) {
output = '\\-' + output.slice(1)
} else if (/\d/.test(firstChar)) {
output = '\\3' + firstChar + ' ' + output.slice(1)
}
}
// Remove spaces after `\HEX` escapes that are not followed by a hex digit,
// since theyre redundant. Note that this is only possible if the escape
// sequence isnt preceded by an odd number of backslashes.
output = output.replace(regexExcessiveSpaces, function ($0, $1, $2) {
if ($1 && $1.length % 2) {
// Its not safe to remove the space, so dont.
return $0
}
// Strip the space.
return ($1 || '') + $2
})
if (!isIdentifier && options.wrap) {
return quote + output + quote
}
return output
}

View file

@ -1,78 +0,0 @@
{
"_from": "@medv/finder",
"_id": "@medv/finder@2.0.0",
"_inBundle": false,
"_integrity": "sha512-gV4jOsGpiWNDGd8Dw7tod1Fc9Gc7StaOT4oZ/6srHRWtsHU+HYWzmkYsa3Qy/z0e9tY1WpJ9wWdBFGskfbzoug==",
"_location": "/@medv/finder",
"_phantomChildren": {},
"_requested": {
"type": "tag",
"registry": true,
"raw": "@medv/finder",
"name": "@medv/finder",
"escapedName": "@medv%2ffinder",
"scope": "@medv",
"rawSpec": "",
"saveSpec": null,
"fetchSpec": "latest"
},
"_requiredBy": [
"#USER",
"/"
],
"_resolved": "https://registry.npmjs.org/@medv/finder/-/finder-2.0.0.tgz",
"_shasum": "699b7141393aa815f120b38f54f92ad212225902",
"_spec": "@medv/finder",
"_where": "/Users/shikhu/work/openreplay/tracker/tracker",
"author": {
"name": "Anton Medvedev",
"email": "anton@medv.io"
},
"ava": {
"require": [
"esm",
"./test/helpers/setup-browser-env.js"
]
},
"bugs": {
"url": "https://github.com/antonmedv/finder/issues"
},
"bundleDependencies": false,
"deprecated": false,
"description": "CSS Selector Generator",
"devDependencies": {
"ava": "^3.8.2",
"babel-minify": "*",
"browser-env": "^3.3.0",
"esm": "^3.2.25",
"gzip-size-cli": "*",
"release-it": "^13.6.1",
"typescript": "3.9.3"
},
"files": [
"*.ts",
"*.js"
],
"homepage": "https://github.com/antonmedv/finder",
"keywords": [
"css",
"selector",
"generator"
],
"license": "MIT",
"main": "finder.js",
"name": "@medv/finder",
"repository": {
"type": "git",
"url": "git+https://github.com/antonmedv/finder.git"
},
"scripts": {
"prepare": "tsc",
"release": "release-it --access public",
"size": "minify finder.js --sourceType module | gzip-size",
"start": "tsc -w",
"test": "tsc && ava"
},
"types": "finder.d.ts",
"version": "2.0.0"
}

View file

@ -3,20 +3,38 @@ import QueueSender from './QueueSender.js'
global.fetch = () => Promise.resolve(new Response()) // jsdom does not have it
function mockFetch(status: number) {
function mockFetch(status: number, headers?: Record<string, string>) {
return jest
.spyOn(global, 'fetch')
.mockImplementation(() => Promise.resolve({ status } as Response))
.mockImplementation((request) =>
Promise.resolve({ status, headers, request } as unknown as Response & {
request: RequestInfo
}),
)
}
const baseURL = 'MYBASEURL'
const sampleArray = new Uint8Array(1)
const randomToken = 'abc'
const requestMock = {
body: sampleArray,
headers: { Authorization: 'Bearer abc' },
keepalive: true,
method: 'POST',
}
const gzipRequestMock = {
...requestMock,
headers: { ...requestMock.headers, 'Content-Encoding': 'gzip' },
}
function defaultQueueSender({
url = baseURL,
onUnauthorised = () => {},
onFailed = () => {},
} = {}) {
return new QueueSender(baseURL, onUnauthorised, onFailed, 10, 1000)
onCompress = undefined,
}: Record<string, any> = {}) {
return new QueueSender(baseURL, onUnauthorised, onFailed, 10, 1000, onCompress)
}
describe('QueueSender', () => {
@ -42,6 +60,25 @@ describe('QueueSender', () => {
expect(fetchMock).toBeCalledTimes(0)
queueSender.push(sampleArray)
expect(fetchMock).toBeCalledTimes(1)
expect(fetchMock.mock.calls[0][1]).toMatchObject(requestMock)
})
test('Sends compressed request if onCompress is provided and compressed batch is included', () => {
const queueSender = defaultQueueSender({ onCompress: () => true })
const fetchMock = mockFetch(200)
// @ts-ignore
const spyOnCompress = jest.spyOn(queueSender, 'onCompress')
// @ts-ignore
const spyOnSendNext = jest.spyOn(queueSender, 'sendNext')
queueSender.authorise(randomToken)
queueSender.push(sampleArray)
expect(spyOnCompress).toBeCalledTimes(1)
queueSender.sendCompressed(sampleArray)
expect(fetchMock).toBeCalledTimes(1)
expect(spyOnSendNext).toBeCalledTimes(1)
expect(spyOnCompress).toBeCalledTimes(1)
expect(fetchMock.mock.calls[0][1]).toMatchObject(gzipRequestMock)
})
test('Calls fetch on authorisation if there was a push() call before', () => {
const queueSender = defaultQueueSender()