Skip to content
This repository was archived by the owner on Nov 4, 2021. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 45 additions & 17 deletions src/worker/ingestion/action-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,27 +470,54 @@ class SelectorPart {
requirements: Partial<Element>

constructor(tag: string, directDescendant: boolean, escapeSlashes: boolean) {
const SELECTOR_ATTRIBUTE_REGEX = /([a-zA-Z]*)\[(.*)=[\'|\"](.*)[\'|\"]\]/
const ATTRIBUTE_SELECTOR_REGEX = /\[(.*)=[\'|\"](.*)[\'|\"]\]/
const COLON_SELECTOR_REGEX = /:([A-Za-z-]+)\((\d+)\)/
const FINAL_TAG_REGEX = /^([A-Za-z0-9]+)/

this.directDescendant = directDescendant
this.uniqueOrder = 0
this.requirements = {}

const result = tag.match(SELECTOR_ATTRIBUTE_REGEX)
if (result && tag.includes('[id=')) {
this.requirements.attr_id = result[3]
tag = result[1]
}
if (result && tag.includes('[')) {
if (!this.requirements.attributes) {
this.requirements.attributes = {}
let attributeSelector = tag.match(ATTRIBUTE_SELECTOR_REGEX)
while (attributeSelector) {
tag =
tag.slice(0, attributeSelector.index) +
tag.slice(attributeSelector.index! + attributeSelector[0].length)
const attribute = attributeSelector[1].toLowerCase()
switch (attribute) {
case 'id':
this.requirements.attr_id = attributeSelector[2].toLowerCase()
break
case 'href':
this.requirements.href = attributeSelector[2]
break
default:
if (!this.requirements.attributes) {
this.requirements.attributes = {}
}
this.requirements.attributes[attribute] = attributeSelector[2]
break
}
this.requirements.attributes[result[2]] = result[3]
tag = result[1]
attributeSelector = tag.match(ATTRIBUTE_SELECTOR_REGEX)
}
if (tag.includes('nth-child(')) {
const nthChildParts = tag.split(':nth-child(')
this.requirements.nth_child = parseInt(nthChildParts[1].replace(')', ''))
tag = nthChildParts[0]
let colonSelector = tag.match(COLON_SELECTOR_REGEX)
while (colonSelector) {
tag = tag.slice(0, colonSelector.index) + tag.slice(colonSelector.index! + colonSelector[0].length)
const parsedArgument = parseInt(colonSelector[2])
if (!parsedArgument) {
continue
}
switch (colonSelector[1]) {
case 'nth-child':
this.requirements.nth_child = parsedArgument
break
case 'nth-of-type':
this.requirements.nth_of_type = parsedArgument
break
default:
continue // unsupported selector
}
colonSelector = tag.match(COLON_SELECTOR_REGEX)
}
if (tag.includes('.')) {
const classParts = tag.split('.')
Expand All @@ -501,8 +528,9 @@ class SelectorPart {
} // TODO: determine if we need escapeSlashes in this port
tag = classParts[0]
}
if (tag) {
this.requirements.tag_name = tag
const finalTag = tag.match(FINAL_TAG_REGEX)
if (finalTag) {
this.requirements.tag_name = finalTag[1]
}
}

Expand Down
207 changes: 197 additions & 10 deletions tests/worker/ingestion/action-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ActionStepUrlMatching,
Element,
Hub,
Person,
PropertyOperator,
RawAction,
} from '../../../src/types'
Expand Down Expand Up @@ -86,6 +87,21 @@ describe('ActionMatcher', () => {
}
}

/** Return a test person created on a common base using provided property overrides. */
function createTestPerson(overrides: Partial<Person> = {}): Person {
const url: string = overrides.properties?.$current_url ?? 'http://example.com/foo/'
return {
id: 2,
team_id: 2,
properties: {},
is_user_id: 0,
is_identified: true,
uuid: 'F99FA0A1-E0C2-4CFE-A09A-4C3C4327A4C8',
created_at: DateTime.fromSeconds(18000000),
...overrides,
}
}

describe('#match()', () => {
it('returns no match if action has no steps', async () => {
await createTestAction([])
Expand All @@ -95,7 +111,7 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(event)).toEqual([])
})

it('returns a match in case of property operator exact', async () => {
it('returns a match in case of event property operator exact', async () => {
const actionDefinitionOpExact: Action = await createTestAction([
{
properties: [{ type: 'event', key: 'foo', value: 'bar', operator: 'exact' as PropertyOperator }],
Expand Down Expand Up @@ -138,7 +154,7 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(eventFooNull)).toEqual([])
})

it('returns a match in case of property operator is not', async () => {
it('returns a match in case of event property operator is not', async () => {
const actionDefinitionOpIsNot: Action = await createTestAction([
{
properties: [{ type: 'event', key: 'foo', value: 'bar', operator: 'is_not' as PropertyOperator }],
Expand Down Expand Up @@ -170,7 +186,7 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(eventFooNull)).toEqual([actionDefinitionOpIsNot])
})

it('returns a match in case of property operator contains', async () => {
it('returns a match in case of event property operator contains', async () => {
const actionDefinitionOpContains: Action = await createTestAction([
{
properties: [
Expand Down Expand Up @@ -204,7 +220,7 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(eventFooNull)).toEqual([])
})

it('returns a match in case of property operator does not contain', async () => {
it('returns a match in case of event property operator does not contain', async () => {
const actionDefinitionOpNotContains: Action = await createTestAction([
{
properties: [
Expand Down Expand Up @@ -238,7 +254,7 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(eventFooNull)).toEqual([actionDefinitionOpNotContains])
})

it('returns a match in case of property operator regex', async () => {
it('returns a match in case of event property operator regex', async () => {
const actionDefinitionOpRegex1: Action = await createTestAction([
{
properties: [{ type: 'event', key: 'foo', value: '^bar', operator: 'regex' as PropertyOperator }],
Expand Down Expand Up @@ -280,7 +296,7 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(eventFooNull)).toEqual([])
})

it('returns a match in case of property operator not regex', async () => {
it('returns a match in case of event property operator not regex', async () => {
const actionDefinitionOpNotRegex1: Action = await createTestAction([
{
properties: [
Expand Down Expand Up @@ -344,7 +360,7 @@ describe('ActionMatcher', () => {
])
})

it('returns a match in case of property operator is set', async () => {
it('returns a match in case of event property operator is set', async () => {
const actionDefinitionOpIsSet: Action = await createTestAction([
{
properties: [{ type: 'event', key: 'foo', operator: 'is_set' as PropertyOperator }],
Expand Down Expand Up @@ -376,7 +392,7 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(eventFooNull)).toEqual([actionDefinitionOpIsSet])
})

it('returns a match in case of property operator is not set', async () => {
it('returns a match in case of event property operator is not set', async () => {
const actionDefinitionOpIsNotSet: Action = await createTestAction([
{
properties: [{ type: 'event', key: 'foo', operator: 'is_not_set' as PropertyOperator }],
Expand Down Expand Up @@ -408,7 +424,7 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(eventFooNull)).toEqual([])
})

it('returns a match in case of property operator greater than', async () => {
it('returns a match in case of event property operator greater than', async () => {
const actionDefinitionOpGreaterThan: Action = await createTestAction([
{
properties: [{ type: 'event', key: 'foo', value: 5, operator: 'gt' as PropertyOperator }],
Expand Down Expand Up @@ -444,7 +460,7 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(eventFooNull)).toEqual([])
})

it('returns a match in case of property operator less than', async () => {
it('returns a match in case of event property operator less than', async () => {
const actionDefinitionOpLessThan: Action = await createTestAction([
{
properties: [{ type: 'event', key: 'foo', value: 5, operator: 'lt' as PropertyOperator }],
Expand Down Expand Up @@ -656,6 +672,51 @@ describe('ActionMatcher', () => {
expect(await actionMatcher.match(eventExampleBad3)).toEqual([])
})

it('returns a match in case of person property operator exact', async () => {
const actionDefinitionOpExact: Action = await createTestAction([
{
properties: [{ type: 'person', key: 'foo', value: 'bar', operator: 'exact' as PropertyOperator }],
},
])
const actionDefinitionOpUndefined: Action = await createTestAction([
{
properties: [{ type: 'person', key: 'foo', value: 'bar' }], // undefined operator should mean "exact"
},
])

const event = createTestEvent()

const personFooBar = createTestPerson({ properties: { foo: 'bar' } })
const personFooBarPolPot = createTestPerson({ properties: { foo: 'bar', pol: 'pot' } })
const personFooBaR = createTestPerson({ properties: { foo: 'baR' } })
const personFooBaz = createTestPerson({ properties: { foo: 'baz' } })
const personFooBarabara = createTestPerson({ properties: { foo: 'barabara' } })
const personFooRabarbar = createTestPerson({ properties: { foo: 'rabarbar' } })
const personFooNumber = createTestPerson({ properties: { foo: 7 } })
const personNoNothing = createTestPerson()
const personFigNumber = createTestPerson({ properties: { fig: 999 } })
const personFooTrue = createTestPerson({ properties: { foo: true } })
const personFooNull = createTestPerson({ properties: { foo: null } })

expect(await actionMatcher.match(event, personFooBar)).toEqual([
actionDefinitionOpExact,
actionDefinitionOpUndefined,
])
expect(await actionMatcher.match(event, personFooBarPolPot)).toEqual([
actionDefinitionOpExact,
actionDefinitionOpUndefined,
])
expect(await actionMatcher.match(event, personFooBaR)).toEqual([])
expect(await actionMatcher.match(event, personFooBaz)).toEqual([])
expect(await actionMatcher.match(event, personFooBarabara)).toEqual([])
expect(await actionMatcher.match(event, personFooRabarbar)).toEqual([])
expect(await actionMatcher.match(event, personFooNumber)).toEqual([])
expect(await actionMatcher.match(event, personNoNothing)).toEqual([])
expect(await actionMatcher.match(event, personFigNumber)).toEqual([])
expect(await actionMatcher.match(event, personFooTrue)).toEqual([])
expect(await actionMatcher.match(event, personFooNull)).toEqual([])
})

it('returns a match in case of cohort match', async () => {
const testCohort = await hub.db.createCohort({ name: 'Test', created_by_id: commonUserId, team_id: 2 })

Expand Down Expand Up @@ -717,6 +778,118 @@ describe('ActionMatcher', () => {
)
).toEqual([])
})

it('returns a match in case of element href equals', async () => {
const actionDefinitionLinkHref: Action = await createTestAction([
{
href: 'https://example.com/',
},
])

const event = createTestEvent()
const elementsHrefOuter: Element[] = [
{ tag_name: 'h1', attr_class: ['headline'] },
{ tag_name: 'a', href: 'https://example.com/' },
{ tag_name: 'main' },
]
const elementsHrefInner: Element[] = [
{ tag_name: 'a', href: 'https://example.com/' },
{ tag_name: 'h1', attr_class: ['headline'] },
{ tag_name: 'main' },
]
const elementsNoHref: Element[] = [
{ tag_name: 'span' },
{ tag_name: 'h1', attr_class: ['headline'] },
{ tag_name: 'main' },
]

expect(await actionMatcher.match(event, undefined, elementsHrefOuter)).toEqual([actionDefinitionLinkHref])
expect(await actionMatcher.match(event, undefined, elementsHrefInner)).toEqual([actionDefinitionLinkHref])
expect(await actionMatcher.match(event, undefined, elementsNoHref)).toEqual([])
})

it('returns a match in case of element text and tag name equals', async () => {
const actionDefinitionLinkHref: Action = await createTestAction([
{
tag_name: 'h1',
text: 'Hallo!',
},
])

const event = createTestEvent()
const elementsHrefProper: Element[] = [
{ tag_name: 'h1', attr_class: ['headline'], text: 'Hallo!' },
{ tag_name: 'a', href: 'https://example.com/' },
{ tag_name: 'main' },
]
const elementsHrefWrongTag: Element[] = [
{ tag_name: 'h3', attr_class: ['headline'], text: 'Hallo!' },
{ tag_name: 'a', href: 'https://example.com/' },
{ tag_name: 'main' },
]
const elementsHrefWrongText: Element[] = [
{ tag_name: 'h3', attr_class: ['headline'], text: 'Auf Wiedersehen!' },
{ tag_name: 'a', href: 'https://example.com/' },
{ tag_name: 'main' },
]
const elementsHrefWrongLevel: Element[] = [
{ tag_name: 'i', attr_class: ['headline'], text: 'Auf Wiedersehen!' },
{ tag_name: 'h1' },
{ tag_name: 'a', href: 'https://example.com/' },
{ tag_name: 'main' },
]

expect(await actionMatcher.match(event, undefined, elementsHrefProper)).toEqual([actionDefinitionLinkHref])
expect(await actionMatcher.match(event, undefined, elementsHrefWrongTag)).toEqual([])
expect(await actionMatcher.match(event, undefined, elementsHrefWrongText)).toEqual([])
expect(await actionMatcher.match(event, undefined, elementsHrefWrongLevel)).toEqual([])
})

it('returns a match in case of element selector', async () => {
const actionDefinitionAnyDescendant: Action = await createTestAction([
{
selector: 'main h1.headline',
},
])
const actionDefinitionDirectDescendant: Action = await createTestAction([
{
selector: 'main > h1.headline',
},
])
const actionDefinitionDirectHref: Action = await createTestAction([
{
selector: 'main > a[href="https://example.com/"]',
},
])

const event = createTestEvent()
const elementsHrefProperNondirect: Element[] = [
{ tag_name: 'h1', attr_class: ['headline'], text: 'Hallo!' },
{ tag_name: 'a', href: 'https://example.com/' },
{ tag_name: 'main' },
]
const elementsHrefWrongClassNondirect: Element[] = [
{ tag_name: 'h1', attr_class: ['oof'], text: 'Hallo!' },
{ tag_name: 'a', href: 'https://example.com/' },
{ tag_name: 'main' },
]
const elementsHrefProperDirect: Element[] = [
{ tag_name: 'h1', attr_class: ['headline'], text: 'Hallo!' },
{ tag_name: 'main' },
]

expect(await actionMatcher.match(event, undefined, elementsHrefProperNondirect)).toEqual([
actionDefinitionAnyDescendant,
actionDefinitionDirectHref,
])
expect(await actionMatcher.match(event, undefined, elementsHrefWrongClassNondirect)).toEqual([
actionDefinitionDirectHref,
])
expect(await actionMatcher.match(event, undefined, elementsHrefProperDirect)).toEqual([
actionDefinitionAnyDescendant,
actionDefinitionDirectDescendant,
])
})
})

describe('#checkElementsAgainstSelector()', () => {
Expand Down Expand Up @@ -797,5 +970,19 @@ describe('ActionMatcher', () => {

expect(actionMatcher.checkElementsAgainstSelector(elements, 'aside div > span')).toBeTruthy()
})

it('handles direct descendant selector edge cases 3', () => {
const elements: Element[] = [{ tag_name: 'span', nth_child: 2, nth_of_type: 1 }, { tag_name: 'section' }]

expect(
actionMatcher.checkElementsAgainstSelector(elements, 'section > span:nth-child(2):nth-of-type(1)')
).toBeTruthy()
expect(
actionMatcher.checkElementsAgainstSelector(elements, 'section > span:nth-child(1):nth-of-type(1)')
).toBeFalsy()
expect(
actionMatcher.checkElementsAgainstSelector(elements, 'section > span:nth-child(2):nth-of-type(3)')
).toBeFalsy()
})
})
})