├── .eslintignore ├── .npmignore ├── .codeclimate.yml ├── .eslintrc.json ├── .travis.yml ├── .babelrc ├── modules ├── index.js ├── parser.js ├── methods │ ├── inspectTags.js │ ├── inspectSiblings.js │ ├── index.js │ ├── inspectSpecialAttributes.js │ ├── validationHelpers.js │ ├── inspectElementID.js │ ├── inspectNthChild.js │ └── inspectNthChild.test.js ├── exposeOnWindow.js ├── convertSelectorStateIntoCSSSelector.js ├── stackHierarchy.js ├── validateSelector.js ├── parser.test.js ├── configuration.js ├── convertSelectorStateIntoCSSSelector.test.js ├── stackHeirarchy.test.js ├── queryEngine.test.js ├── queryEngine.js └── simmer.js ├── __tests__ ├── server.js ├── integration │ ├── utils.js │ ├── fixture.js │ └── index.test.js └── e2e │ ├── test.html │ └── index.js ├── .gitignore ├── LICENSE ├── rollup.config.js ├── package.json ├── nightwatch.conf.js ├── README.md └── dist └── simmer.js /.eslintignore: -------------------------------------------------------------------------------- 1 | **/lib/*.js 2 | **/lib/**/*.js 3 | **/dist/*.js -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | modules/**/*.test.js 4 | __tests__ -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | JavaScript: true 3 | exclude_paths: 4 | - "node_modules" 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "plugins": [ 4 | "jest" 5 | ], 6 | "env": { 7 | "jest/globals": true 8 | } 9 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | sudo: false 5 | install: 6 | - npm install 7 | script: 8 | - npm test 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "es2016" 5 | ], 6 | "plugins": [ 7 | "transform-object-rest-spread" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /modules/index.js: -------------------------------------------------------------------------------- 1 | /* global window, document */ 2 | import simmer from './simmer' 3 | import exposeOnWindow from './exposeOnWindow' 4 | 5 | exposeOnWindow(window, simmer(window)) 6 | -------------------------------------------------------------------------------- /modules/parser.js: -------------------------------------------------------------------------------- 1 | export default function Parser (parsingMethods) { 2 | const queue = parsingMethods.getMethods() 3 | return { 4 | finished () { 5 | return queue.length === 0 6 | }, 7 | next (...args) { 8 | if (this.finished()) { 9 | return false 10 | } 11 | return queue.shift()(...args) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /__tests__/server.js: -------------------------------------------------------------------------------- 1 | function makeServer(done = () => {}) { 2 | var express = require('express'); 3 | var path = require('path'); 4 | var app = express(); 5 | 6 | app.get('/', function (req, res) { 7 | res.status(200).sendFile(`__tests__/e2e/test.html`, {root: path.resolve()}); 8 | }); 9 | app.get('/simmer.js', function (req, res) { 10 | res.status(200).sendFile(`dist/simmer.js`, {root: path.resolve()}); 11 | }); 12 | var server = app.listen(3993, function () { 13 | var port = server.address().port; 14 | done() 15 | }); 16 | return server; 17 | } 18 | module.exports = makeServer; 19 | -------------------------------------------------------------------------------- /modules/methods/inspectTags.js: -------------------------------------------------------------------------------- 1 | import { tagName } from './validationHelpers' 2 | 3 | /** 4 | /** 5 | * Inspect the elements' Tag names and add them to the calculates CSS selector 6 | * @param {array} hierarchy. The hierarchy of elements 7 | * @param {object} state. The current calculated CSS selector 8 | */ 9 | export default function (hierarchy, state) { 10 | return hierarchy.reduce((selectorState, currentElem, index) => { 11 | ;[currentElem.el.nodeName].filter(tagName).forEach(tagName => { 12 | selectorState.stack[index].splice(0, 0, tagName) 13 | selectorState.specificity += 10 14 | }) 15 | return selectorState 16 | }, state) 17 | } 18 | -------------------------------------------------------------------------------- /modules/exposeOnWindow.js: -------------------------------------------------------------------------------- 1 | export default function (windowScope, simmerInstance) { 2 | // Save the previous value of the `simmer` variable. 3 | let conflictedSimmer = windowScope.Simmer 4 | windowScope.Simmer = simmerInstance 5 | 6 | /** 7 | * Revert the global window.simmer variable to it's original value and return this simmer object. 8 | * This allows users to include multiple versions of Simmer objects on a single page. 9 | * @example 10 |
11 |    Simmer.noConflict();
12 |    
13 | */ 14 | simmerInstance.noConflict = function () { 15 | windowScope.Simmer = conflictedSimmer 16 | return simmerInstance 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Intellij ### 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode 3 | 4 | ## Directory-based project format 5 | .idea/ 6 | # if you remove the above rule, at least ignore user-specific stuff: 7 | # .idea/workspace.xml 8 | # .idea/tasks.xml 9 | # and these sensitive or high-churn files: 10 | # .idea/dataSources.ids 11 | # .idea/dataSources.xml 12 | # .idea/sqlDataSources.xml 13 | # .idea/dynamic.xml 14 | 15 | ## File-based project format 16 | *.ipr 17 | *.iws 18 | *.iml 19 | 20 | ## Additional for IntelliJ 21 | out/ 22 | 23 | # generated by mpeltonen/sbt-idea plugin 24 | .idea_modules/ 25 | 26 | 27 | # files created by the build process and the "node install" command 28 | node_modules/* 29 | bower_components/* 30 | report/* 31 | reports/* 32 | selenium-debug.log 33 | screenshots/ 34 | .DS_Store 35 | lib/ -------------------------------------------------------------------------------- /modules/convertSelectorStateIntoCSSSelector.js: -------------------------------------------------------------------------------- 1 | import takeRight from 'lodash.takeright' 2 | /** 3 | * Conver the Selector State into a merged CSS selector 4 | * @param state (object) The current selector state (has the stack and specificity sum) 5 | * @param depth (int) The number of levels to merge (1..state.stack.length) 6 | */ 7 | export default function (state, depth = state.stack.length) { 8 | return ( 9 | takeRight( 10 | state.stack.reduceRight((selectorSegments, elementState) => { 11 | if (elementState.length) { 12 | selectorSegments.push(elementState.join('')) 13 | } else if (selectorSegments.length) { 14 | selectorSegments.push('*') 15 | } 16 | return selectorSegments 17 | }, []), 18 | depth 19 | ).join(' > ') || '*' 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /modules/stackHierarchy.js: -------------------------------------------------------------------------------- 1 | function push (arr, val) { 2 | arr.push(val) 3 | return arr 4 | } 5 | 6 | function tail (arr) { 7 | return arr[arr.length - 1] 8 | } 9 | /** 10 | * Retireve the element's ancestors up to the configured level. 11 | * This is an internal function and is not to be used from the outside (nor can it, it is private) 12 | * @param element (Object) The elemen't whose ancestry we want to retrieve 13 | * @param depth (number) How deep to into the heirarchy to collect elements 14 | */ 15 | export default function stackHierarchy (element, depth) { 16 | if (depth <= 0) { 17 | throw new Error(`Simmer: An invalid depth of ${depth} has been specified`) 18 | } 19 | return Array(depth - 1) 20 | .fill() 21 | .reduce( 22 | (acc, val) => (tail(acc).parent() ? push(acc, tail(acc).parent()) : acc), 23 | [element] 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /modules/methods/inspectSiblings.js: -------------------------------------------------------------------------------- 1 | import take from 'lodash.take' 2 | import { className as validateClassName } from './validationHelpers' 3 | /** 4 | /** 5 | * Inspect the element's siblings by CSS Class names and compare them to the analyzed element. 6 | * @param {array} hierarchy. The hierarchy of elements 7 | * @param {object} state. The current calculated CSS selector 8 | */ 9 | export default function (hierarchy, state) { 10 | return hierarchy.reduce((selectorState, currentElem, index) => { 11 | const validClasses = take(currentElem.getClasses(), 10) 12 | .filter(validateClassName) 13 | .map(className => `.${className}`) 14 | 15 | if (validClasses.length) { 16 | // limit to 10 classes 17 | selectorState.stack[index].push(validClasses.join('')) 18 | selectorState.specificity += 10 * validClasses.length 19 | } 20 | return selectorState 21 | }, state) 22 | } 23 | -------------------------------------------------------------------------------- /modules/methods/index.js: -------------------------------------------------------------------------------- 1 | import inspectElementID from './inspectElementID' 2 | import inspectTags from './inspectTags' 3 | import inspectSiblings from './inspectSiblings' 4 | import inspectNthChild from './inspectNthChild' 5 | import inspectSpecialAttributes from './inspectSpecialAttributes' 6 | 7 | /** 8 | * The ParsingMethods are the key methods for the parsing process. They provide the various ways by which we analyze an element. 9 | * This object is a wrapper for building the list of available parsing methods and managing the context in which they are run so 10 | * that they all have access to basic parsing helper methods 11 | * */ 12 | const parsingMethods = { 13 | methods: [], 14 | getMethods: function () { 15 | return this.methods.slice(0) 16 | }, 17 | addMethod: function (fn) { 18 | this.methods.push(fn) 19 | } 20 | } 21 | 22 | parsingMethods.addMethod(inspectElementID) 23 | parsingMethods.addMethod(inspectTags) 24 | parsingMethods.addMethod(inspectSpecialAttributes) 25 | parsingMethods.addMethod(inspectSiblings) 26 | parsingMethods.addMethod(inspectNthChild) 27 | 28 | export default parsingMethods 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Gidi Meir Morris AKA "CheKofif" 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // rollup.config.js 2 | import resolve from 'rollup-plugin-node-resolve' 3 | import babel from 'rollup-plugin-babel' 4 | import closure from 'rollup-plugin-closure-compiler-js' 5 | import commonjs from 'rollup-plugin-commonjs' 6 | import fs from 'fs' 7 | 8 | const babelrc = JSON.parse(fs.readFileSync('./.babelrc', 'utf8')) 9 | 10 | const babelConfig = { 11 | babelrc: false, 12 | presets: [['es2015', { modules: false }]].concat( 13 | (babelrc.presets || []).filter(preset => preset !== 'es2015') 14 | ), 15 | plugins: ['external-helpers'].concat(babelrc.plugins || []) 16 | } 17 | 18 | export default { 19 | entry: 'modules/index.js', 20 | format: 'iife', 21 | plugins: [ 22 | resolve({ 23 | jsnext: true, 24 | main: true 25 | }), 26 | commonjs({ 27 | // non-CommonJS modules will be ignored, but you can also 28 | // specifically include/exclude files 29 | include: 'node_modules/**', 30 | 31 | // if true then uses of `global` won't be dealt with by this plugin 32 | ignoreGlobal: false, 33 | 34 | // if false then skip sourceMap generation for CommonJS modules 35 | sourceMap: false 36 | }), 37 | babel(babelConfig), 38 | closure() 39 | ], 40 | dest: 'dist/simmer.js' 41 | } 42 | -------------------------------------------------------------------------------- /modules/methods/inspectSpecialAttributes.js: -------------------------------------------------------------------------------- 1 | const handlers = { 2 | A: (state, elm) => { 3 | const attribute = elm.el.getAttribute('href') 4 | if (attribute) { 5 | state.stack[0].push(`A[href="${attribute}"]`) 6 | state.specificity += 10 7 | } 8 | return state 9 | }, 10 | IMG: (state, elm) => { 11 | const attribute = elm.el.getAttribute('src') 12 | if (attribute) { 13 | state.stack[0].push(`IMG[src="${attribute}"]`) 14 | state.specificity += 10 15 | } 16 | return state 17 | } 18 | } 19 | 20 | /** 21 | * Inspect the elements' special attributes which are likely to be unique to the element 22 | * @param {array} hierarchy. The hierarchy of elements 23 | * @param {object} state. The current calculated CSS selector 24 | */ 25 | export default function (hierarchy, state, validateSelector) { 26 | const elm = hierarchy[0] 27 | const tag = elm.el.nodeName 28 | 29 | if (handlers[tag]) { 30 | state = handlers[tag](state, elm) 31 | if (validateSelector(state)) { 32 | // the unique attribute worked! 33 | state.verified = true 34 | } else { 35 | // turns out our so called unique attribute isn't as unique as we thought, 36 | // we'll remove it to keep the selector's noise level down 37 | state.stack[0].pop() 38 | } 39 | } 40 | return state 41 | } 42 | -------------------------------------------------------------------------------- /modules/methods/validationHelpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Validate the syntax of a tagName to make sure that it has a valid syntax for the query engine. 3 | * Many libraries use invalid property and tag names, such as Facebook that use FB: prefixed tags. 4 | * These make the query engines fail and must be filtered out. 5 | * @param {string} tagName. The element's tag name 6 | */ 7 | export function tagName (tagName) { 8 | if ( 9 | typeof tagName === 'string' && 10 | tagName.match(/^[a-zA-Z0-9]+$/gi) !== null 11 | ) { 12 | return tagName 13 | } 14 | return false 15 | } 16 | /** 17 | * Validate the syntax of an attribute to make sure that it has a valid syntax for the query engine. 18 | * @param {string} attribute. The element's attribute's value 19 | */ 20 | export function attr (attribute) { 21 | if ( 22 | typeof attribute === 'string' && 23 | attribute.match(/^[0-9a-zA-Z][a-zA-Z_\-:0-9.]*$/gi) !== null 24 | ) { 25 | return attribute 26 | } 27 | return false 28 | } 29 | 30 | /** 31 | * Validate the syntax of an attribute to make sure that it has a valid syntax for the query engine. 32 | * @param {string} attribute. The element's attribute's value 33 | */ 34 | export function className (className) { 35 | if ( 36 | typeof className === 'string' && 37 | className.match(/^\.?[a-zA-Z_\-:0-9]*$/gi) !== null 38 | ) { 39 | return className 40 | } 41 | return false 42 | } 43 | -------------------------------------------------------------------------------- /modules/validateSelector.js: -------------------------------------------------------------------------------- 1 | import convertSelectorStateIntoCSSSelector from './convertSelectorStateIntoCSSSelector' 2 | /** 3 | * Execute a query using the current selector state and see if it produces a unique result. 4 | * If the result is unique then the analysis is complete and we can finish the process. 5 | * @param {object} element. The element we are trying to build a selector for 6 | * @param {object} state. The current selector state (has the stack and specificity sum) 7 | */ 8 | export default function (element, config, query, onError) { 9 | const { selectorMaxLength } = config 10 | return function (state) { 11 | let validated = false 12 | let selector 13 | let results 14 | for (let depth = 1; depth <= state.stack.length && !validated; depth += 1) { 15 | // use selector to query an element and see if it is a one-to-one selection 16 | 17 | selector = convertSelectorStateIntoCSSSelector(state, depth).trim() 18 | 19 | if (!(selector && selector.length)) { 20 | // too short 21 | return false 22 | } 23 | if (selectorMaxLength && selector.length > selectorMaxLength) { 24 | // the selector is too long 25 | return false 26 | } 27 | results = query(selector, onError) 28 | validated = 29 | results.length === 1 && 30 | (element.el !== undefined 31 | ? results[0] === element.el 32 | : results[0] === element) 33 | 34 | // we have to mark how deep the valdiation passed at 35 | if (validated) { 36 | state.verificationDepth = depth 37 | } 38 | } 39 | 40 | return validated 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /modules/parser.test.js: -------------------------------------------------------------------------------- 1 | import Parser from './parser' 2 | 3 | describe('Parser', () => { 4 | describe('next', () => { 5 | test(`calls the next function in the queue with the supplied arguments`, function () { 6 | const returnValue = { some: 0 } 7 | const method = jest.fn(() => returnValue) 8 | const parser = new Parser({ 9 | getMethods: () => [method] 10 | }) 11 | 12 | expect(parser.next(1, 2, 3)).toBe(returnValue) 13 | 14 | expect(method.mock.calls[0]).toMatchObject([1, 2, 3]) 15 | }) 16 | test(`removes the called method`, function () { 17 | const first = jest.fn(val => val) 18 | const second = jest.fn(val => val) 19 | const parser = new Parser({ 20 | getMethods: () => [first, second] 21 | }) 22 | 23 | expect(parser.next(1, 2, 3, 4, 5, 6)).toBe(1) 24 | 25 | expect(parser.next(6, 5, 4, 3, 2, 1)).toBe(6) 26 | 27 | expect(second.mock.calls[0]).toMatchObject([6, 5, 4, 3, 2, 1]) 28 | }) 29 | test(`returns false if no more methods are left`, function () { 30 | const parser = new Parser({ 31 | getMethods: () => [val => val] 32 | }) 33 | 34 | parser.next(1, 2, 3, 4, 5, 6) 35 | 36 | expect(parser.next(6, 5, 4, 3, 2, 1)).toBe(false) 37 | }) 38 | }) 39 | describe('finish', () => { 40 | test(`returns true if no methods are left in the queue`, function () { 41 | const parser = new Parser({ 42 | getMethods: () => [] 43 | }) 44 | 45 | expect(parser.finished()).toBe(true) 46 | }) 47 | test(`returns false if there are methods left in the queue`, function () { 48 | const parser = new Parser({ 49 | getMethods: () => [() => 123] 50 | }) 51 | 52 | expect(parser.finished()).toBe(false) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /modules/configuration.js: -------------------------------------------------------------------------------- 1 | // Configuration 2 | export const DEFAULT_CONFIGURATION = { 3 | // A function for calling an external query engine for testing CSS selectors such as jQuery or Sizzle 4 | // (If you have jQuery or Sizzle on the page, you have no need to supply such a function as Simmer will detect 5 | // these and use them if they are available. this will not work if you have these libraries in noConflict mode. 6 | queryEngine: null, 7 | // A minimum specificty level. Once the parser reaches this level it starts verifying the selector after every method is called 8 | // This can cut down our execution time by avoiding needless parsing but can also hurt execution times by performing many 9 | // verifications. This number will have to be tweeked here and there as we use the component... 10 | specificityThreshold: 100, 11 | // How deep into the DOM hierarchy should Simmer go in order to reach a unique selector. 12 | // This is a delicate game because the higher the number the more likely you are to reach a unique selector, 13 | // but it also means a longer and more breakable one. Assuming you want to store this selector to use later, 14 | // making it longer also means it is more likely to change and loose it's validity. 15 | depth: 3, 16 | // Handling errors in the Simmer analysis process. 17 | // true / false / callback 18 | // false: errors are ignored by Simmer 19 | // true: errors rethrown by the process 20 | // a function callback will be called with two parameters: the exception and the element being analyzed 21 | errorHandling: false, 22 | // A maximum length for the CSS selector can be specified - if no specific selector can be found which is shorter than this length 23 | // then it is treated as if no selector could be found 24 | selectorMaxLength: 512 25 | } 26 | 27 | export function configure (config = {}) { 28 | return { 29 | ...DEFAULT_CONFIGURATION, 30 | ...config 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /modules/methods/inspectElementID.js: -------------------------------------------------------------------------------- 1 | import { isUniqueElementID } from '../queryEngine' 2 | import { attr } from './validationHelpers' 3 | /** 4 | * Inspect the elements' IDs and add them to the CSS Selector 5 | * @param {array} hierarchy. The hierarchy of elements 6 | * @param {object} state. The current selector state (has the stack and specificity sum) 7 | */ 8 | export default function (hierarchy, state, validateSelector, config, query) { 9 | return hierarchy.reduce((selectorState, currentElem, index) => { 10 | if (!selectorState.verified) { 11 | const [validatedState] = [currentElem.el.getAttribute('id')] 12 | .filter(id => attr(id)) 13 | // make sure the ID is unique 14 | .filter(id => isUniqueElementID(query, id)) 15 | .map(validId => { 16 | selectorState.stack[index].push(`[id='${validId}']`) 17 | selectorState.specificity += 100 18 | 19 | if (selectorState.specificity >= config.specificityThreshold) { 20 | // we have reached the minimum specificity, lets try verifying now, as this will save us having to add more IDs to the selector 21 | if (validateSelector(selectorState)) { 22 | // The ID worked like a charm - mark this state as verified and move on! 23 | selectorState.verified = true 24 | } 25 | } 26 | 27 | if (!selectorState.verified && index === 0) { 28 | // if the index is 0 then this is the ID of the actual element! Which means we have found our selector! 29 | // The ID wasn't enough, this means the page, this should never happen as we tested for the ID's uniquness, but just incase 30 | // we will pop it from the stack as it only adds noise 31 | selectorState.stack[index].pop() 32 | selectorState.specificity -= 100 33 | } 34 | return selectorState 35 | }) 36 | return validatedState || selectorState 37 | } 38 | return selectorState 39 | }, state) 40 | } 41 | -------------------------------------------------------------------------------- /modules/convertSelectorStateIntoCSSSelector.test.js: -------------------------------------------------------------------------------- 1 | import convertSelectorStateIntoCSSSelector from './convertSelectorStateIntoCSSSelector' 2 | 3 | describe.only('convertSelectorStateIntoCSSSelector', () => { 4 | test(`converts an empty stack into a "select all" selector`, function () { 5 | expect( 6 | convertSelectorStateIntoCSSSelector({ 7 | stack: [[]] 8 | }) 9 | ).toBe('*') 10 | }) 11 | 12 | test(`converts an one level stack with one selector into that selector`, function () { 13 | expect( 14 | convertSelectorStateIntoCSSSelector({ 15 | stack: [['DIV']] 16 | }) 17 | ).toBe('DIV') 18 | }) 19 | 20 | test(`converts an one level stack with multiple selectors into a merged selector`, function () { 21 | expect( 22 | convertSelectorStateIntoCSSSelector({ 23 | stack: [['DIV', '.className']] 24 | }) 25 | ).toBe('DIV.className') 26 | }) 27 | 28 | test(`converts an multi level stack with multiple selectors into a merged selector`, function () { 29 | expect( 30 | convertSelectorStateIntoCSSSelector({ 31 | stack: [['span'], ['DIV', '#someId'], ['DIV', '.className']] 32 | }) 33 | ).toBe('DIV.className > DIV#someId > span') 34 | }) 35 | 36 | test(`converts an empty selector in a multi level stack into a "select all" selector`, function () { 37 | expect( 38 | convertSelectorStateIntoCSSSelector({ 39 | stack: [['span'], [], ['DIV', '.className']] 40 | }) 41 | ).toBe('DIV.className > * > span') 42 | }) 43 | 44 | test(`omits "select all" selectors from the parent levels`, function () { 45 | expect( 46 | convertSelectorStateIntoCSSSelector({ 47 | stack: [['span'], [], ['DIV', '.className'], [], []] 48 | }) 49 | ).toBe('DIV.className > * > span') 50 | }) 51 | 52 | test(`takes a depth level which will omit any selectors beyond that depth`, function () { 53 | expect( 54 | convertSelectorStateIntoCSSSelector( 55 | { 56 | stack: [ 57 | ['span'], 58 | [], 59 | ['DIV', '.className'], 60 | [], 61 | ['DIV', '#someId'], 62 | ['ARTICLE'], 63 | [] 64 | ] 65 | }, 66 | 3 67 | ) 68 | ).toBe('DIV.className > * > span') 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /__tests__/integration/utils.js: -------------------------------------------------------------------------------- 1 | // code utils 2 | export const either = (value, left, right) => 3 | value ? right(value) : left(value) 4 | export const identity = i => i 5 | export const compose = (f, ...fns) => { 6 | if (fns.length === 1) { 7 | const g = fns.pop() 8 | return (...args) => f(g(...args)) 9 | } 10 | return (...args) => f(compose(...fns)(...args)) 11 | } 12 | export const rcompose = (...fns) => { 13 | return compose(...fns.reverse()) 14 | } 15 | export const mapfn = fn => arr => arr.map(fn) 16 | export const reducefn = (fn, init) => arr => arr.reduce(fn, init) 17 | 18 | // Dom utils 19 | // use query in current scope 20 | export const queryEngine = document => ({ 21 | query: (...args) => { 22 | if (!document) { 23 | throw Error( 24 | 'No global scoped document is available - somethign is wrong with the test runner' 25 | ) 26 | } 27 | let elm = document.querySelectorAll(...args) 28 | if (elm && elm.length) { 29 | if (elm.length === 1) { 30 | return elm[0] 31 | } 32 | throw new Error( 33 | `Invalid number of elements returned for query: ${elm.join()}` 34 | ) 35 | } 36 | return elm 37 | }, 38 | default: true 39 | }) 40 | 41 | export const setQueryFunction = (queryFn, name) => { 42 | queryEngine.default = false 43 | queryEngine.query = queryFn 44 | describe(`Simmer tests along side a ${name} selector engine`) 45 | } 46 | 47 | export const NoResult = { el: undefined, SimmerEl: false } 48 | 49 | export const compareParentElementAndSimmer = (windowScope, elementSelector) => { 50 | const comp = compareElementsAndSimmer(windowScope, elementSelector) 51 | if (comp !== NoResult && comp.element) { 52 | comp.el = comp.el.parentNode 53 | } 54 | return comp 55 | } 56 | 57 | export const compareElementsAndSimmer = (windowScope, elementSelector) => { 58 | var element, selector 59 | if (typeof elementSelector === 'string') { 60 | element = windowScope.document.querySelectorAll(elementSelector)[0] 61 | } else { 62 | element = elementSelector 63 | } 64 | 65 | if (!element) { 66 | return NoResult 67 | } 68 | 69 | selector = windowScope.Simmer(element) 70 | if (selector === false) { 71 | return false 72 | } 73 | 74 | return { 75 | el: element, 76 | SimmerEl: queryEngine(windowScope.document).query(selector), 77 | selector: selector 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /modules/methods/inspectNthChild.js: -------------------------------------------------------------------------------- 1 | import difference from 'lodash.difference' 2 | import flatmap from 'lodash.flatmap' 3 | /** 4 | * Inspect the element's siblings by index (nth-child) name and compare them to the analyzed element. 5 | * The sibling comparison is done from level 0 (the analyzed element) upwards in the hierarchy, In an effort to avoid unneeded parsing. 6 | * @param {array} hierarchy. The hierarchy of elements 7 | * @param {object} state. The current calculated CSS selector 8 | */ 9 | 10 | const not = i => !i 11 | const getNodeNames = siblings => siblings.map(sibling => sibling.el.nodeName) 12 | /*** 13 | * The main method splits up the hierarchy and isolates each element, while this helper method is designed 14 | * to then take each level in the hierarchy and fid into it's surounding siblings. 15 | * We've separated these to make the code more managable. 16 | * @param siblingElement 17 | * @param siblings 18 | * @returns {boolean} 19 | */ 20 | export function analyzeElementSiblings (element, siblings) { 21 | return ( 22 | difference( 23 | element.getClasses(), 24 | flatmap(siblings, sibling => sibling.getClasses()) 25 | ).length > 0 || not(getNodeNames(siblings).includes(element.el.nodeName)) 26 | ) 27 | } 28 | 29 | export default function (hierarchy, state, validateSelector) { 30 | return hierarchy.reduce((selectorState, currentElem, index) => { 31 | if (!selectorState.verified) { 32 | // get siblings BEFORE out element 33 | const prevSiblings = currentElem.prevAll() 34 | const nextSiblings = currentElem.nextAll() 35 | 36 | // get element's index by the number of elements before it 37 | // note that the nth-child selector uses a 1 based index, not 0 38 | const indexOfElement = prevSiblings.length + 1 39 | 40 | // If the element has no siblings! 41 | // we have no need for the nth-child selector 42 | if ( 43 | (prevSiblings.length || nextSiblings.length) && 44 | !analyzeElementSiblings(currentElem, [...prevSiblings, ...nextSiblings]) 45 | ) { 46 | // if we don't have a unique tag or a unique class, then we need a nth-child to help us 47 | // differenciate our element from the rest of the pack 48 | selectorState.stack[index].push(`:nth-child(${indexOfElement})`) 49 | 50 | // Verify the selector as we don't want to go on and parse the parent's siblings 51 | // if we don't have to! 52 | selectorState.verified = validateSelector(selectorState) 53 | } 54 | } 55 | return selectorState 56 | }, state) 57 | } 58 | -------------------------------------------------------------------------------- /modules/stackHeirarchy.test.js: -------------------------------------------------------------------------------- 1 | import { wrap } from './queryEngine' 2 | import stackHierarchy from './stackHierarchy' 3 | const { JSDOM } = require('jsdom') 4 | 5 | const createElementHeirarchy = (dom = '', selector) => 6 | new JSDOM(`${dom}`).window.document.querySelector(selector) 7 | 8 | describe('stackHierarchy', () => { 9 | test(`takes a wrapped element and a depth and returns the element heirarchy up tp that depth`, function () { 10 | const child = createElementHeirarchy( 11 | ` 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | `, 21 | `#leaf` 22 | ) 23 | const stackedHeirarchy = stackHierarchy(wrap(child), 3) 24 | 25 | expect(stackedHeirarchy[0].el).toBe(child) 26 | expect(stackedHeirarchy[1].el).toBe(child.parentNode) 27 | expect(stackedHeirarchy[2].el).toBe(child.parentNode.parentNode) 28 | expect(stackedHeirarchy.length).toBe(3) 29 | }) 30 | 31 | test(`throws an error if the depth is invalid`, function () { 32 | const child = createElementHeirarchy( 33 | ` 34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | `, 43 | `#leaf` 44 | ) 45 | 46 | expect(() => stackHierarchy(wrap(child), 0)).toThrowError( 47 | /An invalid depth of/ 48 | ) 49 | 50 | expect(() => stackHierarchy(wrap(child), -5)).toThrowError( 51 | /An invalid depth of/ 52 | ) 53 | }) 54 | 55 | test(`returns a partial heirarchy if the depth is higher than what is available on the DOM`, function () { 56 | const child = createElementHeirarchy( 57 | ` 58 |
59 |
60 |
61 |
62 | `, 63 | `#leaf` 64 | ) 65 | 66 | const stackedHeirarchy = stackHierarchy(wrap(child), 10) 67 | 68 | expect(stackedHeirarchy[0].el).toBe(child) 69 | expect(stackedHeirarchy[1].el).toBe(child.parentNode) 70 | // Body 71 | expect(stackedHeirarchy[2].el).toBe(child.parentNode.parentNode) 72 | /// HTML 73 | expect(stackedHeirarchy[3].el).toBe(child.parentNode.parentNode.parentNode) 74 | // Document 75 | expect(stackedHeirarchy[4].el).toBe( 76 | child.parentNode.parentNode.parentNode.parentNode 77 | ) 78 | 79 | expect(stackedHeirarchy.length).toBe(5) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /modules/queryEngine.test.js: -------------------------------------------------------------------------------- 1 | import initQueryEngine, { isUniqueElementID } from './queryEngine' 2 | const { JSDOM } = require('jsdom') 3 | const createElement = (dom = '') => 4 | new JSDOM(`
`).window.document.querySelector('div') 5 | /// HTMLElement 6 | describe('QueryEngine', () => { 7 | describe('attachQueryEngine', () => { 8 | test(`defaults to using the document.querySelectorAll as queryEngine`, function () { 9 | const returnValue = [{ tagName: 'div' }, { tagName: 'div' }] 10 | const querySelectorAll = jest.fn(() => returnValue) 11 | 12 | const windowScope = { 13 | document: { 14 | querySelectorAll 15 | } 16 | } 17 | 18 | const $ = initQueryEngine(windowScope) 19 | 20 | expect($('div')).toBe(returnValue) 21 | 22 | expect(querySelectorAll.mock.calls[0][0]).toBe('div') 23 | }) 24 | 25 | test(`takes a query engine and uses it to query`, function () { 26 | const returnValue = [{ tagName: 'div' }, { tagName: 'div' }] 27 | const customQueryEngine = jest.fn(() => returnValue) 28 | const querySelectorAll = jest.fn(() => []) 29 | 30 | const windowScope = { 31 | document: { 32 | querySelectorAll 33 | } 34 | } 35 | 36 | const $ = initQueryEngine(windowScope, customQueryEngine) 37 | 38 | expect($('div')).toBe(returnValue) 39 | 40 | expect(querySelectorAll.mock.calls.length).toBe(0) 41 | expect(customQueryEngine.mock.calls[0][0]).toBe('div') 42 | }) 43 | }) 44 | 45 | describe('isUniqueElementID', () => { 46 | test(`takes a query engine and an id and returns true if the query engine has only one result for that id`, function () { 47 | const query = jest.fn(() => [createElement()]) 48 | 49 | expect(isUniqueElementID(query, 'uniqueId')).toBe(true) 50 | 51 | expect(query.mock.calls[0][0]).toBe(`[id="uniqueId"]`) 52 | }) 53 | 54 | test(`takes a query engine and an id and returns false if the query engine has no result for that id`, function () { 55 | const query = jest.fn(() => []) 56 | 57 | expect(isUniqueElementID(query, 'uniqueId')).toBe(false) 58 | 59 | expect(query.mock.calls[0][0]).toBe(`[id="uniqueId"]`) 60 | }) 61 | 62 | test(`takes a query engine and an id and returns false if the query engine returns multiple result for that id`, function () { 63 | const query = jest.fn(() => [createElement(), createElement()]) 64 | 65 | expect(isUniqueElementID(query, 'uniqueId')).toBe(false) 66 | 67 | expect(query.mock.calls[0][0]).toBe(`[id="uniqueId"]`) 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /modules/queryEngine.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Verify a specific ID's uniqueness one the page 3 | * @param {object} element. The element we are trying to build a selector for 4 | * @param {object} state. The current selector state (has the stack and specificity sum) 5 | */ 6 | export function isUniqueElementID (query, elementID) { 7 | // use selector to query an element and see if it is a one-to-one selection 8 | var results = query(`[id="${elementID}"]`) || [] 9 | return results.length === 1 10 | } 11 | 12 | function traverseAttribute (el, dir) { 13 | const matched = [] 14 | let cur = el[dir] 15 | 16 | while (cur && cur.nodeType !== 9) { 17 | if (cur.nodeType === 1) { 18 | matched.push(wrap(cur)) 19 | } 20 | cur = cur[dir] 21 | } 22 | return matched 23 | } 24 | 25 | export function wrap (el) { 26 | /// When the DOM wrapper return the selected element it wrapps 27 | /// it with helper methods which aid in analyzing the result 28 | return { 29 | el, 30 | 31 | getClass: function () { 32 | return this.el.getAttribute('class') || '' 33 | }, 34 | 35 | getClasses: function () { 36 | return this.getClass() 37 | .split(' ') 38 | .map(className => className.replace(/^\s\s*/, '').replace(/\s\s*$/, '')) 39 | .filter(className => className.length > 0) 40 | }, 41 | 42 | prevAll: function () { 43 | return traverseAttribute(this.el, 'previousSibling') 44 | }, 45 | 46 | nextAll: function () { 47 | return traverseAttribute(this.el, 'nextSibling') 48 | }, 49 | 50 | parent: function () { 51 | return this.el.parentNode && this.el.parentNode.nodeType !== 11 52 | ? wrap(this.el.parentNode) 53 | : null 54 | } 55 | } 56 | } 57 | 58 | const INVALID_DOCUMENT = { 59 | querySelectorAll () { 60 | throw new Error( 61 | 'An invalid context has been provided to Simmer, it doesnt know how to query it' 62 | ) 63 | } 64 | } 65 | 66 | const documentQuerySelector = scope => { 67 | const document = typeof scope.querySelectorAll === 'function' 68 | ? scope 69 | : scope.document ? scope.document : INVALID_DOCUMENT 70 | return (selector, onError) => { 71 | try { 72 | return document.querySelectorAll(selector) 73 | } catch (ex) { 74 | // handle error 75 | onError(ex) 76 | } 77 | } 78 | } 79 | 80 | /** 81 | * A DOM manipulation object. Provides both CSS selector querying for verifications and a wrapper for the elements them selves to provide 82 | * key behavioural methods, such as sibling querying etc. 83 | * @param {object} elementOrSelector. An element we wish to wrapper or a CSS query string 84 | */ 85 | export default function (scope, configuredQueryEngine) { 86 | const queryEngine = typeof configuredQueryEngine === 'function' 87 | ? configuredQueryEngine 88 | : documentQuerySelector(scope) 89 | 90 | // If no selector we return an empty array - no CSS selector will ever be generated in this situation! 91 | return (selector, onError) => 92 | typeof selector !== 'string' ? [] : queryEngine(selector, onError, scope) 93 | } 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simmerjs", 3 | "description": "A pure Javascript reverse CSS selector engine which calculates a DOM element's unique CSS selector on the current page.", 4 | "version": "0.5.6", 5 | "author": "Gidi Meir Morris", 6 | "main": "lib/simmer.js", 7 | "jsnext:main": "modules/simmer.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/gmmorris/simmerjs.git" 11 | }, 12 | "licenses": [ 13 | { 14 | "type": "MIT", 15 | "url": "https://github.com/gmmorris/simmerjs/blob/master/LICENSE" 16 | } 17 | ], 18 | "dependencies": { 19 | "lodash.difference": "^4.5.0", 20 | "lodash.flatmap": "^4.5.0", 21 | "lodash.isfunction": "^3.0.8", 22 | "lodash.take": "^4.1.1", 23 | "lodash.takeright": "^4.1.1" 24 | }, 25 | "scripts": { 26 | "test": "npm run testUnit && npm run testIntegration", 27 | "testw": "jest .test.js --watch", 28 | "testUnit": "jest ./modules", 29 | "testIntegration": "jest ./**/integration/*.test.js", 30 | "testE2E": "npm run build && nightwatch --config nightwatch.conf.js", 31 | "build": "npm run buildBrowser && npm run buildModule", 32 | "buildBrowser": "rollup -c", 33 | "buildModule": "rm -rf ./lib && babel modules -d lib --ignore .test.js", 34 | "lint": "eslint ./modules/**/*.js", 35 | "format": "prettier-eslint --write \"modules/**/*.js\"", 36 | "formatTests": "prettier-eslint --write \"__tests__/integration/*.js\"", 37 | "precommit": "lint-staged", 38 | "prepublish": "npm run buildModule" 39 | }, 40 | "lint-staged": { 41 | "*.js": [ 42 | "npm run format", 43 | "npm run lint", 44 | "git add" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "babel-cli": "^6.24.1", 49 | "babel-jest": "^20.0.3", 50 | "babel-plugin-external-helpers": "^6.22.0", 51 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 52 | "babel-preset-env": "^1.5.1", 53 | "babel-preset-es2015": "^6.24.1", 54 | "babel-preset-es2016": "^6.24.1", 55 | "env2": "^2.2.0", 56 | "eslint": "^4.0.0", 57 | "eslint-config-standard": "^10.2.1", 58 | "eslint-plugin-import": "^2.3.0", 59 | "eslint-plugin-jest": "^20.0.3", 60 | "eslint-plugin-node": "^5.0.0", 61 | "eslint-plugin-promise": "^3.5.0", 62 | "eslint-plugin-standard": "^3.0.1", 63 | "express": "^4.15.3", 64 | "husky": "^0.13.4", 65 | "jest": "^20.0.4", 66 | "jest-codemods": "^0.10.1", 67 | "jsdom": "^11.0.0", 68 | "lint-staged": "^4.0.0", 69 | "nightwatch": "^0.9.15", 70 | "path": "^0.12.7", 71 | "prettier": "^1.4.4", 72 | "prettier-eslint-cli": "^4.1.1", 73 | "regenerator-runtime": "^0.10.5", 74 | "rollup": "^0.42.0", 75 | "rollup-plugin-babel": "^2.7.1", 76 | "rollup-plugin-closure-compiler-js": "^1.0.4", 77 | "rollup-plugin-commonjs": "^8.0.2", 78 | "rollup-plugin-node-resolve": "^3.0.0", 79 | "selenium-download": "^2.0.10", 80 | "sinon": "^2.3.2", 81 | "standard": "^10.0.2" 82 | }, 83 | "keywords": [ 84 | "Simmer", 85 | "css", 86 | "css selector", 87 | "sizzle" 88 | ] 89 | } 90 | -------------------------------------------------------------------------------- /nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | const SCREENSHOT_PATH = "./screenshots/"; 2 | const BINPATH = './node_modules/nightwatch/bin/'; 3 | 4 | // we use a nightwatch.conf.js file so we can include comments and helper functions 5 | module.exports = { 6 | "src_folders": [ 7 | "__tests__/e2e"// Where you are storing your Nightwatch e2e tests 8 | ], 9 | "output_folder": "./reports", // reports (test outcome) output by nightwatch 10 | "selenium": { // downloaded by selenium-download module (see readme) 11 | "start_process": true, // tells nightwatch to start/stop the selenium process 12 | "server_path": "./node_modules/nightwatch/bin/selenium.jar", 13 | "host": "127.0.0.1", 14 | "port": 4444, // standard selenium port 15 | "cli_args": { // chromedriver is downloaded by selenium-download (see readme) 16 | "webdriver.chrome.driver" : "./node_modules/nightwatch/bin/chromedriver" 17 | } 18 | }, 19 | "test_settings": { 20 | "default": { 21 | "screenshots": { 22 | "enabled": true, // if you want to keep screenshots 23 | "path": './screenshots' // save screenshots here 24 | }, 25 | "globals": { 26 | "waitForConditionTimeout": 5000 // sometimes internet is slow so wait. 27 | }, 28 | "desiredCapabilities": { // use Chrome as the default browser for tests 29 | "browserName": "chrome" 30 | } 31 | }, 32 | "chrome": { 33 | "desiredCapabilities": { 34 | "browserName": "chrome", 35 | "javascriptEnabled": true // turn off to test progressive enhancement 36 | } 37 | } 38 | } 39 | } 40 | /** 41 | * selenium-download does exactly what it's name suggests; 42 | * downloads (or updates) the version of Selenium (& chromedriver) 43 | * on your localhost where it will be used by Nightwatch. 44 | /the following code checks for the existence of `selenium.jar` before trying to run our tests. 45 | */ 46 | 47 | require('fs').stat(BINPATH + 'selenium.jar', function (err, stat) { // got it? 48 | if (err || !stat || stat.size < 1) { 49 | require('selenium-download').ensure(BINPATH, function(error) { 50 | if (error) throw new Error(error); // no point continuing so exit! 51 | console.log('✔ Selenium & Chromedriver downloaded to:', BINPATH); 52 | }); 53 | } 54 | }); 55 | 56 | function padLeft (count) { // theregister.co.uk/2016/03/23/npm_left_pad_chaos/ 57 | return count < 10 ? '0' + count : count.toString(); 58 | } 59 | 60 | var FILECOUNT = 0; // "global" screenshot file count 61 | /** 62 | * The default is to save screenshots to the root of your project even though 63 | * there is a screenshots path in the config object above! ... so we need a 64 | * function that returns the correct path for storing our screenshots. 65 | * While we're at it, we are adding some meta-data to the filename, specifically 66 | * the Platform/Browser where the test was run and the test (file) name. 67 | */ 68 | function imgpath (browser) { 69 | var a = browser.options.desiredCapabilities; 70 | var meta = [a.platform]; 71 | meta.push(a.browserName ? a.browserName : 'any'); 72 | meta.push(a.version ? a.version : 'any'); 73 | meta.push(a.name); // this is the test filename so always exists. 74 | var metadata = meta.join('~').toLowerCase().replace(/ /g, ''); 75 | return SCREENSHOT_PATH + metadata + '_' + padLeft(FILECOUNT++) + '_'; 76 | } 77 | 78 | module.exports.imgpath = imgpath; 79 | module.exports.SCREENSHOT_PATH = SCREENSHOT_PATH; -------------------------------------------------------------------------------- /modules/methods/inspectNthChild.test.js: -------------------------------------------------------------------------------- 1 | import { analyzeElementSiblings } from './inspectNthChild' 2 | 3 | describe('analyzeElementSiblings', () => { 4 | test(`takes an only child with no classes and identifies it as having a unique tag / class`, function () { 5 | expect( 6 | analyzeElementSiblings( 7 | { el: { nodeName: 'DIV' }, getClasses: () => [] }, 8 | [] 9 | ) 10 | ).toBe(true) 11 | }) 12 | 13 | test(`takes an only child with classes and identifies it as having a unique tag / class`, function () { 14 | expect( 15 | analyzeElementSiblings( 16 | { el: { nodeName: 'DIV' }, getClasses: () => ['someClass'] }, 17 | [] 18 | ) 19 | ).toBe(true) 20 | }) 21 | 22 | test(`takes an child with a sibling and identifies it as having a unique tag`, function () { 23 | expect( 24 | analyzeElementSiblings( 25 | { el: { nodeName: 'DIV' }, getClasses: () => ['someClass'] }, 26 | [{ el: { nodeName: 'P' }, getClasses: () => [] }] 27 | ) 28 | ).toBe(true) 29 | }) 30 | 31 | test(`takes an child with a sibling and identifies it as having a unique class`, function () { 32 | expect( 33 | analyzeElementSiblings( 34 | { el: { nodeName: 'DIV' }, getClasses: () => ['someClass'] }, 35 | [{ el: { nodeName: 'DIV' }, getClasses: () => [] }] 36 | ) 37 | ).toBe(true) 38 | }) 39 | 40 | test(`takes an child with a sibling and identifies it as having a unique class among matching classes`, function () { 41 | expect( 42 | analyzeElementSiblings( 43 | { 44 | el: { nodeName: 'DIV' }, 45 | getClasses: () => ['someAClass', 'someUniqueClass', 'someBClass'] 46 | }, 47 | [ 48 | { 49 | el: { nodeName: 'DIV' }, 50 | getClasses: () => ['someAClass', 'someBClass'] 51 | } 52 | ] 53 | ) 54 | ).toBe(true) 55 | }) 56 | 57 | test(`takes an child with multiple sibling and identifies it as having a unique class among matching classes`, function () { 58 | expect( 59 | analyzeElementSiblings( 60 | { 61 | el: { nodeName: 'DIV' }, 62 | getClasses: () => ['someAClass', 'someUniqueClass', 'someBClass'] 63 | }, 64 | [ 65 | { 66 | el: { nodeName: 'DIV' }, 67 | getClasses: () => ['someAClass', 'someBClass'] 68 | }, 69 | { el: { nodeName: 'DIV' }, getClasses: () => ['someBClass'] }, 70 | { el: { nodeName: 'DIV' }, getClasses: () => ['someBClass'] }, 71 | { el: { nodeName: 'DIV' }, getClasses: () => ['someDClass'] } 72 | ] 73 | ) 74 | ).toBe(true) 75 | }) 76 | 77 | test(`takes an child with multiple sibling and identifies it as having no unique class`, function () { 78 | expect( 79 | analyzeElementSiblings( 80 | { 81 | el: { nodeName: 'DIV' }, 82 | getClasses: () => ['someAClass', 'someBClass'] 83 | }, 84 | [ 85 | { 86 | el: { nodeName: 'DIV' }, 87 | getClasses: () => ['someAClass', 'someBClass'] 88 | }, 89 | { el: { nodeName: 'DIV' }, getClasses: () => ['someBClass'] }, 90 | { el: { nodeName: 'DIV' }, getClasses: () => ['someBClass'] }, 91 | { el: { nodeName: 'DIV' }, getClasses: () => ['someDClass'] } 92 | ] 93 | ) 94 | ).toBe(false) 95 | }) 96 | 97 | test(`takes an child with multiple sibling and identifies it as having no unique tag`, function () { 98 | expect( 99 | analyzeElementSiblings( 100 | { el: { nodeName: 'DIV' }, getClasses: () => [] }, 101 | [ 102 | { el: { nodeName: 'DIV' }, getClasses: () => [] }, 103 | { el: { nodeName: 'DIV' }, getClasses: () => [] } 104 | ] 105 | ) 106 | ).toBe(false) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /__tests__/integration/fixture.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | 78 | ` 79 | -------------------------------------------------------------------------------- /__tests__/e2e/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | hello 7 | 112 | 113 | -------------------------------------------------------------------------------- /modules/simmer.js: -------------------------------------------------------------------------------- 1 | import initQueryEngine, { wrap } from './queryEngine' 2 | import parsingMethods from './methods' 3 | import validateSelector from './validateSelector' 4 | import convertSelectorStateIntoCSSSelector from './convertSelectorStateIntoCSSSelector' 5 | import Parser from './parser' 6 | import stackHierarchy from './stackHierarchy' 7 | import { configure } from './configuration' 8 | 9 | export default function createSimmer ( 10 | windowScope = window, 11 | customConfig = {}, 12 | customQuery = false 13 | ) { 14 | const config = configure(customConfig) 15 | const query = customQuery || initQueryEngine(windowScope, config.queryEngine) 16 | /** 17 | * Handle errors in accordance with what is specified in the configuration 18 | * @param {object/string} ex. The exception object or message 19 | * @param {object} element. The element Simmer was asked to process 20 | */ 21 | function onError (ex, element) { 22 | // handle error 23 | if (config.errorHandling === true) { 24 | throw ex 25 | } 26 | if (typeof config.errorHandling === 'function') { 27 | config.errorHandling(ex, element) 28 | } 29 | } 30 | 31 | // Initialize the Simmer object and set it over the reference on the window 32 | /** 33 | * The main Simmer action - parses an element on the page to produce a CSS selector for it. 34 | * This function will be returned into the global Simmer object. 35 | * @param {object} element. A DOM element you wish to create a selector for. 36 | * @example 37 |
 38 |    var cssSelectorForDonJulio = Simmer(document.getElementByID('DonJulio'));
 39 |    
40 | */ 41 | const simmer = function (element) { 42 | if (!element) { 43 | // handle error 44 | onError.call( 45 | simmer, 46 | new Error('Simmer: No element was specified for parsing.'), 47 | element 48 | ) 49 | return false 50 | } 51 | 52 | // The parser cycles through a set of parsing methods specified in an order optimal 53 | // for creating as specific as possible a selector 54 | const parser = new Parser(parsingMethods) 55 | 56 | // get the element's ancestors 57 | const hierarchy = stackHierarchy(wrap(element), config.depth) 58 | 59 | // initialize the state of the selector 60 | let selectorState = { 61 | // the stack is used to build a layer of selectors, each layer coresponding to a specific element in the heirarchy 62 | // for each level we create a private stack of properties, so that we can then merge them 63 | // comfortably and allow all methods to see the level at which existing properties have been set 64 | stack: Array(hierarchy.length).fill().map(() => []), 65 | // follow the current specificity level of the selector - the higher the better 66 | specificity: 0 67 | } 68 | 69 | const validator = validateSelector(element, config, query, onError) 70 | 71 | // cycle through the available parsing methods and while we still have yet to find the requested element's one-to-one selector 72 | // we keep calling the methods until we are either satisfied or run out of methods 73 | while (!parser.finished() && !selectorState.verified) { 74 | try { 75 | selectorState = parser.next( 76 | hierarchy, 77 | selectorState, 78 | validator, 79 | config, 80 | query 81 | ) 82 | 83 | // if we have reached a satisfactory level of specificity, try the selector, perhaps we have found our selector? 84 | if ( 85 | selectorState.specificity >= config.specificityThreshold && 86 | !selectorState.verified 87 | ) { 88 | selectorState.verified = validator(selectorState) 89 | } 90 | } catch (ex) { 91 | // handle error 92 | onError.call(simmer, ex, element) 93 | } 94 | } 95 | 96 | // if we were not able to produce a one-to-one selector, return false 97 | if ( 98 | selectorState.verified === undefined || 99 | selectorState.specificity < config.specificityThreshold 100 | ) { 101 | // if it is undefined then verfication has never been run! 102 | // try and verify, and if verification fails - return false 103 | // if it is false and the specificity is too low to actually try and find the element in the first place, then we may simply have not run 104 | // an up to date verification - try again 105 | selectorState.verified = validator(selectorState) 106 | } 107 | 108 | if (!selectorState.verified) { 109 | return false 110 | } 111 | 112 | if (selectorState.verificationDepth) { 113 | return convertSelectorStateIntoCSSSelector( 114 | selectorState, 115 | selectorState.verificationDepth 116 | ) 117 | } 118 | return convertSelectorStateIntoCSSSelector(selectorState) 119 | } 120 | 121 | /** 122 | * Get/Set the configuration for the Simmer object 123 | * @param config (Object) A configuration object with any of the properties tweeked (none/depth/minimumSpecificity) 124 | * @example 125 |
126 |    configuration({
127 |             depth: 3
128 |          });
129 |    
130 | */ 131 | simmer.configure = function (configValues = {}, scope = windowScope) { 132 | const newConfig = configure({ 133 | ...config, 134 | ...configValues 135 | }) 136 | return createSimmer( 137 | scope, 138 | newConfig, 139 | initQueryEngine(scope, newConfig.queryEngine) 140 | ) 141 | } 142 | 143 | return simmer 144 | } 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Simmer JS [![Build Status](https://travis-ci.org/gmmorris/simmerjs.svg?branch=master)](https://travis-ci.org/gmmorris/simmerjs) [![Code Climate](https://codeclimate.com/github/gmmorris/simmerjs/badges/gpa.svg)](https://codeclimate.com/github/gmmorris/simmerjs) 2 | ========= 3 | 4 | ## Docs 5 | 6 | A pure Javascript reverse CSS selector engine which calculates a DOM element's unique CSS selector on the current page. 7 | 8 | ## Installation 9 | Simmer is now meant to be consumed as a module via npm and is no longer meant as a drop in script into the browser. 10 | That said, we still build a version meant to be dropped into the browser which resides on *window.Simmer* as before. 11 | 12 | We highly recommend you consume Simmer in the following manner, but in the interest of supporting exosting users we've retained the previous API. 13 | 14 | ```bash 15 | npm i --save simmerjs 16 | ``` 17 | 18 | And then in your code: 19 | ```js 20 | import Simmer from 'simmerjs' 21 | 22 | const simmer = new Simmer() 23 | 24 | const el = document.getElementById('#SomeElement') 25 | 26 | expect( 27 | simmer(el) 28 | ).to.equal( 29 | '#SomeElement' 30 | ) 31 | 32 | ``` 33 | 34 | When using the distribution located in the repo under **dist/simmer.js** a global *Simmer* function will be exposed on the **window**. 35 | This is **not** the constructor, but rather a default instance which has exposed itself on the window wit ha default configuration. 36 | This is not an idea API and is meant to maintain the original API dating back to 2011 when this library was originally written. 37 | 38 | ### Basic usage 39 | 40 | To use Simmer to analyze an element and produce a unique CSS selector for it, all you have to do is instansiate a simmer query engine and pass it an element on the page. 41 | Usually you'd create a single instance which you would then use multiple times. 42 | 43 | 44 | ```html 45 |
46 | 47 |
48 | 49 |
50 | ``` 51 | 52 | ```js 53 | import Simmer from 'simmerjs' 54 | 55 | const simmer = new Simmer(window, { /* some custom configuration */ }) 56 | var myElement = document.getElementById("#myUniqueElement"); 57 | 58 | console.log(simmer(myElement)); // prints "[id='myUniqueElement']" 59 | ``` 60 | ## API 61 | 62 | ### Simmer 63 | ```js 64 | Simmer([Scope], [Options], [query]) 65 | ``` 66 | 67 | #### Scope 68 | When you create an instance of Simmer its first argument is the context in which Simmer should query for elements. 69 | Generally speaking this would be the **window**, which is the default value, but it would be overriden in a situation where you might be using Simmer against a Virtual Dom implementation. 70 | If you _are_ using a Virtual DOM, you should provide the Window object of the Virtual DOM 71 | 72 | #### Options 73 | The second argument is an Options object allowing you to override the default configuration for Simmer's behaviour. 74 | 75 | The options are: 76 | 77 | | Option | Default | 78 | | ------ | ------- | 79 | | **specificityThreshold** - A minimum specificty level. Once the parser reaches this level it starts verifying the selector after every method is called. This can cut down our execution time by avoiding needless parsing but can also hurt execution times by performing many verifications. Specificity is calculated based on the W3C spec: http://www.w3.org/TR/css3-selectors/#specificity | 100 | 80 | | **depth** - How deep into the DOM hierarchy should Simmer go in order to reach a unique selector. This is a delicate game because the higher the number the more likely you are to reach a unique selector, but it also means a longer and more breakable one. Assuming you want to store this selector to use later, making it longer also means it is more likely to change and loose it's validity. | 3 | | 81 | | **errorHandling** - How to handle errors which occur during the analysis

Valid Options
| false | 82 | | **selectorMaxLength** - A maximum length for the CSS selector can be specified - if no specific selector can be found which is shorter than this length then it is treated as if no selector could be found. | 520 | 83 | 84 | #### query 85 | The third argument is a query engine you wish Simmer to use when evaluating generated selectors. 86 | By default Simmer uses the **window.document.querySelectorAll** function and if you provide a window to the scope, Simmer will assume that you want it to use the **document.querySelectorAll** on that **window**. 87 | But if you wish Simmer to use another custom function, such as your own tweaked version of jQuery, you can do so by passing the third argument to the Simmer constructor. 88 | 89 | The signature the query function should provide is this: 90 | 91 | ##### query(selector: String, onError: Error => {}) : Array(DomElements) 92 | What this means is that the function you provide should expect to receive a string CSS selector and a function. 93 | It should them query for elements matching the selector and return an array of the results (even if there is only one result, it should be returned in an Array.). 94 | 95 | The second argument is a function which should be called if any error is encountered when querying for the elements. 96 | If an error occurs or a problem is encountered, instead of throwing, you should call the function and pass the error object to it. Simmer will then handle the error as per its configuration. 97 | 98 | ### Reconfiguring 99 | If you have an existing instance of Simmer, you can use its **configure** method to instanciate a new Simmer which has the same scope and configuration as the existing one, with any new configuration you wish to apply. 100 | 101 | So, for example, in the browser, you can replace the global Simmer wit ha newly configured version: 102 | ```js 103 | 104 | window.Simmer = window.Simmer.configure({ 105 | depth: 10 106 | }) 107 | ``` 108 | 109 | And in Node: 110 | ```js 111 | import Simmer from 'simmerjs' 112 | 113 | const virtualWindow = new JSDom() 114 | 115 | const simmer = new Simmer(virtualWindow) 116 | 117 | const reconfiguredSimmer = simmer.configure({ /* some custom configuration */ }) 118 | 119 | ``` 120 | 121 | ### Conflict 122 | When the Simmer browser dist located at **dist/simmer.js** is injected in to the browser it adds the noConflict function on itself. 123 | This is not relevant in a Node environment and isn't available there. 124 | 125 | Just in case you also had the brilliant idea of using a variable called "Simmer", or you wish to move it off of the global object then you can use the noConflict method to receive a reference to the object and remove it from the window. 126 | Calling it will also revert the original value of window.Simmer which was there before loading the Simmer.js script (if there was one) 127 | 128 | ```js 129 | var mySimmer = Simmer.noConflict(); 130 | mySimmer(myElm); 131 | ``` 132 | -------------------------------------------------------------------------------- /__tests__/integration/index.test.js: -------------------------------------------------------------------------------- 1 | import fixture from './fixture' 2 | import { 3 | queryEngine, 4 | NoResult, 5 | compareParentElementAndSimmer, 6 | compareElementsAndSimmer 7 | } from './utils' 8 | 9 | import createSimmer from '../../modules/simmer' 10 | import exposeOnWindow from '../../modules/exposeOnWindow' 11 | 12 | const { JSDOM } = require('jsdom') 13 | 14 | const installSimmerOnWindow = windowScope => { 15 | exposeOnWindow( 16 | windowScope, 17 | createSimmer(windowScope, { errorHandling: e => console.log(e) }) 18 | ) 19 | return windowScope 20 | } 21 | 22 | const createWindow = (dom = '') => 23 | installSimmerOnWindow(new JSDOM(`${dom}`).window) 24 | 25 | test(`can analyze an element with an ID`, function () { 26 | const windowScope = createWindow(fixture) 27 | var elements = compareElementsAndSimmer(windowScope, '#BodyDiv') 28 | expect(elements).not.toBe(undefined) 29 | expect(elements.SimmerEl).not.toBe(undefined) 30 | expect(elements.el).not.toBe(undefined) 31 | expect(elements.el).toBe(elements.SimmerEl) 32 | }) 33 | 34 | test(`can analyze an element with classes only (No ID)`, function () { 35 | const windowScope = createWindow(fixture) 36 | var elements = compareElementsAndSimmer(windowScope, '.header') 37 | expect(elements).not.toBe(undefined) 38 | expect(elements.SimmerEl).not.toBe(undefined) 39 | expect(elements.el).not.toBe(undefined) 40 | expect(elements.el).toBe(elements.SimmerEl) 41 | }) 42 | 43 | test(`can analyze an element which is a child of an element with an ID`, function () { 44 | const windowScope = createWindow(fixture) 45 | var elements = compareElementsAndSimmer(windowScope, '#NavBar ul') 46 | expect(elements).not.toBe(undefined) 47 | expect(elements.SimmerEl).not.toBe(undefined) 48 | expect(elements.el).not.toBe(undefined) 49 | expect(elements.el).toBe(elements.SimmerEl) 50 | }) 51 | 52 | test(`can analyze anelement which has a class only (no IDs on it or its direct parent)`, function () { 53 | const windowScope = createWindow(fixture) 54 | var elements = compareElementsAndSimmer(windowScope, '.Active') 55 | expect(elements).not.toBe(undefined) 56 | expect(elements.el).toBe(elements.SimmerEl) 57 | expect(elements.SimmerEl).not.toBe(undefined) 58 | expect(elements.el).not.toBe(undefined) 59 | }) 60 | 61 | test(`can analyze an element with an invalid ID (isn't unique)`, function () { 62 | const windowScope = createWindow(fixture) 63 | var elements = compareElementsAndSimmer( 64 | windowScope, 65 | '#BodyDiv div:nth-child(3)' 66 | ) 67 | expect(elements.el).toBe(elements.SimmerEl) 68 | expect(elements.SimmerEl).not.toBe(undefined) 69 | expect(elements.el).not.toBe(undefined) 70 | expect(elements).not.toBe(undefined) 71 | }) 72 | 73 | test(`can analyze an element with an invalid ID (isn't unique) at same level as other element with the same ID`, function () { 74 | const windowScope = createWindow(fixture) 75 | var elements = compareElementsAndSimmer( 76 | windowScope, 77 | '#BodyDiv div:nth-child(1)' 78 | ) 79 | expect(elements).not.toBe(undefined) 80 | expect(elements.el).toBe(elements.SimmerEl) 81 | expect(elements.SimmerEl).not.toBe(undefined) 82 | expect(elements.el).not.toBe(undefined) 83 | }) 84 | 85 | test(`can analyze an element with identical siblings (neither unique ID nor class)`, function () { 86 | const windowScope = createWindow(fixture) 87 | var elements = compareElementsAndSimmer(windowScope, '.Edge:nth-child(5)') 88 | expect(elements).not.toBe(undefined) 89 | expect(elements.SimmerEl).not.toBe(undefined) 90 | expect(elements.el).not.toBe(undefined) 91 | expect(elements.el).toBe(elements.SimmerEl) 92 | }) 93 | 94 | test(`can analyze an element with a parent which has identical siblings`, function () { 95 | const windowScope = createWindow(fixture) 96 | var elements = compareParentElementAndSimmer(windowScope, '#helper') // parent of #helper! 97 | expect(elements).not.toBe(undefined) 98 | expect(elements.SimmerEl).not.toBe(undefined) 99 | expect(elements.el).not.toBe(undefined) 100 | expect(elements.el).toBe(elements.SimmerEl) 101 | }) 102 | 103 | test(`can analyze an image who'se paren't has an invalid ID`, function () { 104 | const windowScope = createWindow(fixture) 105 | var elements = compareElementsAndSimmer( 106 | windowScope, 107 | '#BodyDiv div:nth-child(1) img' 108 | ) 109 | expect(elements).not.toBe(undefined) 110 | expect(elements.SimmerEl).not.toBe(undefined) 111 | expect(elements.el).not.toBe(undefined) 112 | expect(elements.el).toBe(elements.SimmerEl) 113 | }) 114 | 115 | test(`can analyze an image with a non unique src attribute`, function () { 116 | const windowScope = createWindow(fixture) 117 | var elements = compareElementsAndSimmer( 118 | windowScope, 119 | '#BodyDiv div:nth-child(2) img' 120 | ) 121 | expect(elements).not.toBe(undefined) 122 | expect(elements.SimmerEl).not.toBe(undefined) 123 | expect(elements.el).not.toBe(undefined) 124 | expect(elements.el).toBe(elements.SimmerEl) 125 | }) 126 | 127 | test(`can analyze an image with a unique src attribute`, function () { 128 | const windowScope = createWindow(fixture) 129 | var elements = compareElementsAndSimmer( 130 | windowScope, 131 | 'img[src="./image/Pixel.png"]' 132 | ) 133 | expect(elements).not.toBe(undefined) 134 | expect(elements.SimmerEl).not.toBe(undefined) 135 | expect(elements.el).not.toBe(undefined) 136 | expect(elements.el).toBe(elements.SimmerEl) 137 | }) 138 | 139 | test(`can analyze a child of a hierarchy with identical siblings and no unique IDs`, function () { 140 | const windowScope = createWindow(fixture) 141 | var elements = compareElementsAndSimmer( 142 | windowScope, 143 | 'tr:nth-child(2) td:nth-child(3)' 144 | ) 145 | expect(elements).not.toBe(undefined) 146 | expect(elements.SimmerEl).not.toBe(undefined) 147 | expect(elements.el).not.toBe(undefined) 148 | expect(elements.el).toBe(elements.SimmerEl) 149 | }) 150 | 151 | test(`can analyze an anchor (link) with a unique href attribute`, function () { 152 | const windowScope = createWindow(fixture) 153 | var elements = compareElementsAndSimmer( 154 | windowScope, 155 | 'table tr th:nth-child(2) a' 156 | ) 157 | expect(elements).not.toBe(undefined) 158 | expect(elements.SimmerEl).not.toBe(undefined) 159 | expect(elements.el).not.toBe(undefined) 160 | expect(elements.el).toBe(elements.SimmerEl) 161 | }) 162 | 163 | test(`can analyze a link with a non unique href attribute and a heirarchy which has no unique IDs`, function () { 164 | const windowScope = createWindow(fixture) 165 | var elements = compareElementsAndSimmer( 166 | windowScope, 167 | 'table tr th:nth-child(3) a' 168 | ) 169 | expect(elements).not.toBe(undefined) 170 | expect(elements.SimmerEl).not.toBe(undefined) 171 | expect(elements.el).not.toBe(undefined) 172 | expect(elements.el).toBe(elements.SimmerEl) 173 | }) 174 | 175 | test(`can analyze an element with identical siblings, no IDs and sibling with the same class`, function () { 176 | const windowScope = createWindow(fixture) 177 | var elements = compareElementsAndSimmer(windowScope, 'td span:nth-child(3)') 178 | expect(elements).not.toBe(undefined) 179 | expect(elements.SimmerEl).not.toBe(undefined) 180 | expect(elements.el).not.toBe(undefined) 181 | expect(elements.el).toBe(elements.SimmerEl) 182 | }) 183 | 184 | test(`can analyze an element with identical siblings, no ID and same class as sibling but different class order`, function () { 185 | const windowScope = createWindow(fixture) 186 | var elements = compareElementsAndSimmer(windowScope, 'td span:nth-child(4)') 187 | expect(elements).not.toBe(undefined) 188 | expect(elements.SimmerEl).not.toBe(undefined) 189 | expect(elements.el).not.toBe(undefined) 190 | expect(elements.el).toBe(elements.SimmerEl) 191 | }) 192 | 193 | test(`can analyze an element with identical siblings, no ID and different classes completely`, function () { 194 | const windowScope = createWindow(fixture) 195 | var elements = compareElementsAndSimmer(windowScope, 'td span:nth-child(2)') 196 | expect(elements).not.toBe(undefined) 197 | expect(elements.SimmerEl).not.toBe(undefined) 198 | expect(elements.el).not.toBe(undefined) 199 | expect(elements.el).toBe(elements.SimmerEl) 200 | }) 201 | 202 | test(`cannot parse an element with an identical hierarchy whithin the Simmer's default configured depth`, function () { 203 | const windowScope = createWindow(fixture) 204 | var placeHolder = queryEngine(windowScope.document).query('#placeholderId') 205 | placeHolder.removeAttribute('id') 206 | const elements = compareElementsAndSimmer(windowScope, placeHolder[0]) 207 | expect(elements).not.toBe(undefined) 208 | expect(elements).toEqual(NoResult) 209 | }) 210 | 211 | test(`can analyze an element with a parent which has an invalid Tag name`, function () { 212 | const windowScope = createWindow(fixture) 213 | var elements = compareElementsAndSimmer( 214 | windowScope, 215 | '.invalid_child_tag span' 216 | ) 217 | expect(elements).not.toBe(undefined) 218 | expect(elements.SimmerEl).not.toBe(undefined) 219 | expect(elements.el).not.toBe(undefined) 220 | expect(elements.el).toBe(elements.SimmerEl) 221 | }) 222 | 223 | test(`can analyze an element based only on child elements when specific enough at level 1`, function () { 224 | const windowScope = createWindow(fixture) 225 | var elements = compareElementsAndSimmer(windowScope, '.uniqueClassName') 226 | expect(elements).not.toBe(undefined) 227 | expect(elements.SimmerEl).not.toBe(undefined) 228 | expect(elements.el).not.toBe(undefined) 229 | expect(elements.el).toBe(elements.SimmerEl) 230 | // make sure the selector is one level deep 231 | expect(elements.selector.split('>').length).toBe(1) 232 | }) 233 | 234 | test(`can analyze an element based only on child elements when specific enough at level 2`, function () { 235 | const windowScope = createWindow(fixture) 236 | var elements = compareElementsAndSimmer( 237 | windowScope, 238 | '.secondLevelUniqueClassName div' 239 | ) 240 | expect(elements).not.toBe(undefined) 241 | expect(elements.SimmerEl).not.toBe(undefined) 242 | expect(elements.el).not.toBe(undefined) 243 | expect(elements.el).toBe(elements.SimmerEl) 244 | // make sure the selector is one level deep 245 | expect(elements.selector.split('>').length).toBe(2) 246 | }) 247 | 248 | test(`can analyze an element with a valid ID that ends with numbers in it's ID`, function () { 249 | const windowScope = createWindow(fixture) 250 | var elements = compareElementsAndSimmer(windowScope, '#a111') 251 | expect(elements).not.toBe(undefined) 252 | expect(elements.SimmerEl).not.toBe(undefined) 253 | expect(elements.el).not.toBe(undefined) 254 | expect(elements.el).toBe(elements.SimmerEl) 255 | // make sure the selector is one level deep 256 | expect(elements.selector.match('a111')).toBeTruthy() 257 | }) 258 | 259 | test(`can analyze an element with a valid ID that has numbers in the middle of it's ID`, function () { 260 | const windowScope = createWindow(fixture) 261 | var elements = compareElementsAndSimmer(windowScope, '#a111a') 262 | expect(elements).not.toBe(undefined) 263 | expect(elements.SimmerEl).not.toBe(undefined) 264 | expect(elements.el).not.toBe(undefined) 265 | expect(elements.el).toBe(elements.SimmerEl) 266 | // make sure the selector is one level deep 267 | expect(elements.selector.match('a111a')).toBeTruthy() 268 | }) 269 | 270 | test(`can analyze an element with an invalid ID (not unique)`, function () { 271 | const windowScope = createWindow(fixture) 272 | var elements = compareElementsAndSimmer(windowScope, '.invalidIDElement') 273 | expect(elements).not.toBe(undefined) 274 | expect(elements.SimmerEl).not.toBe(undefined) 275 | expect(elements.el).not.toBe(undefined) 276 | expect(elements.el).toBe(elements.SimmerEl) 277 | // make sure the selector is one level deep 278 | expect(elements.selector.match(':asd')).toBe(null) 279 | }) 280 | 281 | test(`can't analyze an element which is longer than the selectorMaxLength chars`, function () { 282 | const windowScope = createWindow(fixture) 283 | const placeHolder = queryEngine(windowScope.document).query( 284 | '#aZROCRDPX41Qkden3aiC3o9Tkl0xqENUjIgNSWbe6pSddw86ogN018T9lD67zAF1YHaLkRngy8YVq88IBfqdvtO9aXZZbD1NsSBiUo6txcv22ufrkRs9AZKkxIkTF1gNAZ3Oh4M6TcYWRARVJqOZwo3dQufTDm904ep3yHZ5vdHqIyFqTFdZYPWYumx5gJBmWn7GbZQ3O3HodzmHYIHhCYg4dCDfSN8iCHzezerdHbzWUKR7pzMDOzvq017a63LSqYkSJ0gWxrgJFj45HR25eJj5szEFmuQlCfkbWpCwYopeNhy1toC9PvSfVCnHpI7EXeqVcspP0aQISflgD0pBMgg2ieITRa5gXRnKoDdem1yXvHjcDBXJFoUy63zDwg6tTtRR6rijcvoxNzGjWCgQhdqzlv6CW2CVgK2aa0VSX9RMSUTSKXmru7mvZUXJxv7RO7n1Zw9meFygwHwgNrZgeRWVYhsXBtEG8Bak7sPQ7x37QXgIgbJRcbhqMK2F5baa' 285 | ) 286 | const elements = compareElementsAndSimmer(windowScope, placeHolder[0]) 287 | expect(elements).not.toBe(undefined) 288 | expect(elements).toEqual(NoResult) 289 | }) 290 | -------------------------------------------------------------------------------- /__tests__/e2e/index.js: -------------------------------------------------------------------------------- 1 | // var config = require('../../nightwatch.conf.js') 2 | 3 | const compareElementsAndSimmer = ( 4 | browser, 5 | selector, 6 | onFinish = () => browser.end() 7 | ) => { 8 | browser.execute( 9 | function (selector) { 10 | var el = document.querySelector(selector) 11 | var simmerSelector = window.Simmer(el) 12 | var simmerEl = document.querySelector(simmerSelector) 13 | 14 | return { 15 | simmerSelector, 16 | didTheyMatch: el === simmerEl 17 | } 18 | }, 19 | [selector], 20 | function (result) { 21 | browser.assert.equal(result.value.didTheyMatch, true) 22 | onFinish(result.value.simmerSelector) 23 | } 24 | ) 25 | } 26 | 27 | const assertUnqueriableBySimmer = ( 28 | browser, 29 | selector, 30 | onFinish = () => browser.end() 31 | ) => { 32 | browser.execute( 33 | function (selector) { 34 | var el = document.querySelector(selector) 35 | var simmerSelector = window.Simmer(el) 36 | 37 | return { 38 | simmerSelector, 39 | wasUnselectable: simmerSelector === false 40 | } 41 | }, 42 | [selector], 43 | function (result) { 44 | browser.assert.equal(result.value.wasUnselectable, true) 45 | onFinish(result.value.simmerSelector) 46 | } 47 | ) 48 | } 49 | 50 | const compareElementsAndSimmerWithReconfiguration = ( 51 | browser, 52 | id, 53 | configuration, 54 | onFinish = () => browser.end() 55 | ) => { 56 | browser.execute( 57 | function (id, configuration) { 58 | var el = document.getElementById(id) 59 | el.removeAttribute('id') 60 | 61 | var simmerSelector = window.Simmer(el) 62 | 63 | var reconfiguredSimmer = window.Simmer.configure(configuration) 64 | 65 | var reconfiguredSimmerSelector = reconfiguredSimmer(el) 66 | var simmerEl = document.querySelector(reconfiguredSimmerSelector) 67 | 68 | return { 69 | configuration, 70 | reconfiguredSimmerSelector, 71 | didItFailAtDefault: simmerSelector === false, 72 | didTheyMatch: el === simmerEl 73 | } 74 | }, 75 | [id, configuration], 76 | function (result) { 77 | browser.assert.equal(result.value.didItFailAtDefault, true) 78 | browser.assert.equal(result.value.didTheyMatch, true) 79 | onFinish(result.value.reconfiguredSimmerSelector) 80 | } 81 | ) 82 | } 83 | 84 | let server 85 | module.exports = { 86 | before: function (browser, done) { 87 | server = require('../server')(done) // done is a callback that executes when the server is started 88 | }, 89 | 90 | after: function () { 91 | server.close() 92 | }, 93 | 94 | 'can analyze an element with an ID': function (browser) { 95 | browser 96 | .url('localhost:3993') // visit the local url 97 | .waitForElementVisible('body') // wait for the body to be rendered 98 | 99 | compareElementsAndSimmer(browser, '#BodyDiv') 100 | }, 101 | 102 | 'can analyze an element with classes only (No ID)': function (browser) { 103 | browser 104 | .url('localhost:3993') // visit the local url 105 | .waitForElementVisible('body') // wait for the body to be rendered 106 | 107 | compareElementsAndSimmer(browser, '.header') 108 | }, 109 | 110 | 'can analyze an element which is a child of an element with an ID': function ( 111 | browser 112 | ) { 113 | browser 114 | .url('localhost:3993') // visit the local url 115 | .waitForElementVisible('body') // wait for the body to be rendered 116 | 117 | compareElementsAndSimmer(browser, '#NavBar ul') 118 | }, 119 | 120 | 'can analyze anelement which has a class only (no IDs on it or its direct parent)': function ( 121 | browser 122 | ) { 123 | browser 124 | .url('localhost:3993') // visit the local url 125 | .waitForElementVisible('body') // wait for the body to be rendered 126 | 127 | compareElementsAndSimmer(browser, '.Active') 128 | }, 129 | 130 | 'can analyze an element with an invalid ID (isnt unique)': function (browser) { 131 | browser 132 | .url('localhost:3993') // visit the local url 133 | .waitForElementVisible('body') // wait for the body to be rendered 134 | 135 | compareElementsAndSimmer(browser, '#BodyDiv div:nth-child(3)') 136 | }, 137 | 138 | 'can analyze an element with an invalid ID (isnt unique) at same level as other element with the same ID': function ( 139 | browser 140 | ) { 141 | browser 142 | .url('localhost:3993') // visit the local url 143 | .waitForElementVisible('body') // wait for the body to be rendered 144 | 145 | compareElementsAndSimmer(browser, '#BodyDiv div:nth-child(1)') 146 | }, 147 | 148 | 'can analyze an element with identical siblings (neither unique ID nor class)': function ( 149 | browser 150 | ) { 151 | browser 152 | .url('localhost:3993') // visit the local url 153 | .waitForElementVisible('body') // wait for the body to be rendered 154 | 155 | compareElementsAndSimmer(browser, '.Edge:nth-child(5)') 156 | }, 157 | 158 | 'can analyze an element with a parent which has identical siblings': function ( 159 | browser 160 | ) { 161 | browser 162 | .url('localhost:3993') // visit the local url 163 | .waitForElementVisible('body') // wait for the body to be rendered 164 | 165 | compareElementsAndSimmer(browser, '#helper') // parent of #helper! 166 | }, 167 | 168 | 'can analyze an image whose parent has an invalid ID': function (browser) { 169 | browser 170 | .url('localhost:3993') // visit the local url 171 | .waitForElementVisible('body') // wait for the body to be rendered 172 | 173 | compareElementsAndSimmer(browser, '#BodyDiv div:nth-child(1) img') 174 | }, 175 | 176 | 'can analyze an image with a non unique src attribute': function (browser) { 177 | browser 178 | .url('localhost:3993') // visit the local url 179 | .waitForElementVisible('body') // wait for the body to be rendered 180 | 181 | compareElementsAndSimmer(browser, '#BodyDiv div:nth-child(2) img') 182 | }, 183 | 184 | 'can analyze an image with a unique src attribute': function (browser) { 185 | browser 186 | .url('localhost:3993') // visit the local url 187 | .waitForElementVisible('body') // wait for the body to be rendered 188 | 189 | compareElementsAndSimmer(browser, 'img[src="./image/Pixel.png"]') 190 | }, 191 | 192 | 'can analyze a child of a hierarchy with identical siblings and no unique IDs': function ( 193 | browser 194 | ) { 195 | browser 196 | .url('localhost:3993') // visit the local url 197 | .waitForElementVisible('body') // wait for the body to be rendered 198 | 199 | compareElementsAndSimmer(browser, 'tr:nth-child(2) td:nth-child(3)') 200 | }, 201 | 202 | 'can analyze an anchor (link) with a unique href attribute': function ( 203 | browser 204 | ) { 205 | browser 206 | .url('localhost:3993') // visit the local url 207 | .waitForElementVisible('body') // wait for the body to be rendered 208 | 209 | compareElementsAndSimmer(browser, 'table tr th:nth-child(2) a') 210 | }, 211 | 212 | 'can analyze a link with a non unique href attribute and a heirarchy which has no unique IDs': function ( 213 | browser 214 | ) { 215 | browser 216 | .url('localhost:3993') // visit the local url 217 | .waitForElementVisible('body') // wait for the body to be rendered 218 | 219 | compareElementsAndSimmer(browser, 'table tr th:nth-child(3) a') 220 | }, 221 | 222 | 'can analyze an element with identical siblings, no IDs and sibling with the same class': function ( 223 | browser 224 | ) { 225 | browser 226 | .url('localhost:3993') // visit the local url 227 | .waitForElementVisible('body') // wait for the body to be rendered 228 | 229 | compareElementsAndSimmer(browser, 'td span:nth-child(3)') 230 | }, 231 | 232 | 'can analyze an element with identical siblings, no ID and same class as sibling but different class order': function ( 233 | browser 234 | ) { 235 | browser 236 | .url('localhost:3993') // visit the local url 237 | .waitForElementVisible('body') // wait for the body to be rendered 238 | 239 | compareElementsAndSimmer(browser, 'td span:nth-child(4)') 240 | }, 241 | 242 | 'can analyze an element with identical siblings, no ID and different classes completely': function ( 243 | browser 244 | ) { 245 | browser 246 | .url('localhost:3993') // visit the local url 247 | .waitForElementVisible('body') // wait for the body to be rendered 248 | 249 | compareElementsAndSimmer(browser, 'td span:nth-child(2)') 250 | }, 251 | 252 | 'cannot parse an element with an identical hierarchy whithin the Simmers default configured depth': function ( 253 | browser 254 | ) { 255 | browser 256 | .url('localhost:3993') // visit the local url 257 | .waitForElementVisible('body') // wait for the body to be rendered 258 | 259 | compareElementsAndSimmerWithReconfiguration( 260 | browser, 261 | 'placeholderId', 262 | { depth: 7 }, 263 | selector => { 264 | browser.assert.equal( 265 | selector, 266 | "SPAN[id='deepId'] > SPAN > SPAN > SPAN > SPAN > P > A" 267 | ) 268 | browser.end() 269 | } 270 | ) 271 | }, 272 | 273 | 'can analyze an element with a parent which has an invalid Tag name': function ( 274 | browser 275 | ) { 276 | browser 277 | .url('localhost:3993') // visit the local url 278 | .waitForElementVisible('body') // wait for the body to be rendered 279 | 280 | compareElementsAndSimmer(browser, '.invalid_child_tag span') 281 | }, 282 | 283 | 'can analyze an element based only on child elements when specific enough at level 1': function ( 284 | browser 285 | ) { 286 | browser 287 | .url('localhost:3993') // visit the local url 288 | .waitForElementVisible('body') // wait for the body to be rendered 289 | 290 | compareElementsAndSimmer(browser, '.uniqueClassName', selector => { 291 | // make sure the selector is one level deep 292 | browser.assert.equal(selector.split('>').length, 1) 293 | browser.end() 294 | }) 295 | }, 296 | 297 | 'can analyze an element based only on child elements when specific enough at level 2': function ( 298 | browser 299 | ) { 300 | browser 301 | .url('localhost:3993') // visit the local url 302 | .waitForElementVisible('body') // wait for the body to be rendered 303 | 304 | compareElementsAndSimmer( 305 | browser, 306 | '.secondLevelUniqueClassName div', 307 | selector => { 308 | // make sure the selector is one level deep 309 | browser.assert.equal(selector.split('>').length, 2) 310 | browser.end() 311 | } 312 | ) 313 | }, 314 | 315 | 'can analyze an element with a valid ID that ends with numbers in its ID': function ( 316 | browser 317 | ) { 318 | browser 319 | .url('localhost:3993') // visit the local url 320 | .waitForElementVisible('body') // wait for the body to be rendered 321 | 322 | compareElementsAndSimmer(browser, '#a111', selector => { 323 | browser.assert.equal(selector.match('a111')[0], 'a111') 324 | browser.end() 325 | }) 326 | }, 327 | 328 | 'can analyze an element with a valid ID that has numbers in the middle of its ID': function ( 329 | browser 330 | ) { 331 | browser 332 | .url('localhost:3993') // visit the local url 333 | .waitForElementVisible('body') // wait for the body to be rendered 334 | 335 | compareElementsAndSimmer(browser, '#a111a', selector => { 336 | browser.assert.equal(selector.match('a111a')[0], 'a111a') 337 | browser.end() 338 | }) 339 | }, 340 | 341 | 'can analyze an element with an invalid ID (not unique)': function (browser) { 342 | browser 343 | .url('localhost:3993') // visit the local url 344 | .waitForElementVisible('body') // wait for the body to be rendered 345 | 346 | compareElementsAndSimmer(browser, '.invalidIDElement', selector => { 347 | // make sure the invalid ID is ommited 348 | browser.assert.equal(selector.match(':asd'), null) 349 | browser.end() 350 | }) 351 | }, 352 | 353 | 'cant analyze an element which is longer than the selectorMaxLength chars': function ( 354 | browser 355 | ) { 356 | browser 357 | .url('localhost:3993') // visit the local url 358 | .waitForElementVisible('body') // wait for the body to be rendered 359 | 360 | assertUnqueriableBySimmer( 361 | browser, 362 | '#aZROCRDPX41Qkden3aiC3o9Tkl0xqENUjIgNSWbe6pSddw86ogN018T9lD67zAF1YHaLkRngy8YVq88IBfqdvtO9aXZZbD1NsSBiUo6txcv22ufrkRs9AZKkxIkTF1gNAZ3Oh4M6TcYWRARVJqOZwo3dQufTDm904ep3yHZ5vdHqIyFqTFdZYPWYumx5gJBmWn7GbZQ3O3HodzmHYIHhCYg4dCDfSN8iCHzezerdHbzWUKR7pzMDOzvq017a63LSqYkSJ0gWxrgJFj45HR25eJj5szEFmuQlCfkbWpCwYopeNhy1toC9PvSfVCnHpI7EXeqVcspP0aQISflgD0pBMgg2ieITRa5gXRnKoDdem1yXvHjcDBXJFoUy63zDwg6tTtRR6rijcvoxNzGjWCgQhdqzlv6CW2CVgK2aa0VSX9RMSUTSKXmru7mvZUXJxv7RO7n1Zw9meFygwHwgNrZgeRWVYhsXBtEG8Bak7sPQ7x37QXgIgbJRcbhqMK2F5baa' 363 | ) 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /dist/simmer.js: -------------------------------------------------------------------------------- 1 | var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.owns=function(d,k){return Object.prototype.hasOwnProperty.call(d,k)};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(d,k,e){d!=Array.prototype&&d!=Object.prototype&&(d[k]=e.value)}; 2 | $jscomp.getGlobal=function(d){return"undefined"!=typeof window&&window===d?d:"undefined"!=typeof global&&null!=global?global:d};$jscomp.global=$jscomp.getGlobal(this);$jscomp.polyfill=function(d,k,e,h){if(k){e=$jscomp.global;d=d.split(".");for(h=0;he&&(e=Math.max(0,k+e));if(null==h||h>k)h=k;h=Number(h);0>h&&(h=Math.max(0,k+h));for(e=Number(e||0);e=b;b=b&&!aa(a)}return b}function aa(a){a=Z(a)?na.call(a):"";return"[object Function]"==a||"[object GeneratorFunction]"==a}function Z(a){var b="undefined"===typeof a?"undefined":l(a);return!!a&&("object"==b||"function"==b)}function mb(a,b){return 0=b)throw Error("Simmer: An invalid depth of "+b+" has been specified");return Array(b-1).fill().reduce(function(a,b){a[a.length-1].parent()&&(b=a[a.length-1].parent(),a.push(b));return a},[a])}function sa(){return ta({},zb,0=g.specificityThreshold&&!e.verified&&(e.verified=h(e))}catch(Bb){a.call(eb,Bb,b)}if(void 0===e.verified||e.specificityc?-1:1):c===c?c:0):c=0===c?c:0,b=c%1,c=c===c?b?c-b:c:0);b=c;c=0;var d=0>b?0:b;b=-1;var e=a.length;0>c&&(c=-c>e?0:e+c);d=d>e? 24 | e:d;0>d&&(d+=e);e=c>d?0:d-c>>>0;c>>>=0;for(d=Array(e);++ba)return!1;a==b.length-1?b.pop():Gb.call(b, 27 | a,1);return!0};C.prototype.get=function(a){var b=this.__data__;a=O(b,a);return 0>a?void 0:b[a][1]};C.prototype.has=function(a){return-1d?c.push([a,b]):c[d][1]=b;return this};D.prototype.clear=function(){this.__data__={hash:new x,map:new (Hb||C),string:new x}};D.prototype["delete"]=function(a){return P(this,a)["delete"](a)};D.prototype.get=function(a){return P(this,a).get(a)};D.prototype.has=function(a){return P(this, 28 | a).has(a)};D.prototype.set=function(a,b){P(this,a).set(a,b);return this};N.prototype.add=N.prototype.push=function(a){this.__data__.set(a,"__lodash_hash_undefined__");return this};N.prototype.has=function(a){return this.__data__.has(a)};var y=function(a,b){b=ha(void 0===b?a.length-1:b,0);return function(){for(var c=arguments,d=-1,e=ha(c.length-b,0),h=Array(e);++dp))return!1;if((k=h.get(a))&&h.get(b))return k==b;var k=-1,t=!0,u=g&1?new x:void 0;h.set(a,b);for(h.set(b,a);++k=a}function ia(a){var b="undefined"===typeof a?"undefined":l(a);return!!a&&("object"==b||"function"==b)}function ya(a){return!!a&&"object"==("undefined"===typeof a?"undefined":l(a))}function Wa(a){return"symbol"== 43 | ("undefined"===typeof a?"undefined":l(a))||ya(a)&&"[object Symbol]"==U.call(a)}function za(a){if(Aa(a)){if(F(a)||Va(a)){var b=a.length;for(var c=String,f=-1,d=Array(b);++fa)return!1;a==b.length-1?b.pop():Ja.call(b,a,1);return!0};v.prototype.get= 49 | function(a){var b=this.__data__;a=y(b,a);return 0>a?void 0:b[a][1]};v.prototype.has=function(a){return-1d?c.push([a,b]):c[d][1]=b;return this};z.prototype.clear=function(){this.__data__={hash:new r,map:new (ja||v),string:new r}};z.prototype["delete"]=function(a){return Ba(this,a)["delete"](a)};z.prototype.get=function(a){return Ba(this,a).get(a)};z.prototype.has=function(a){return Ba(this,a).has(a)};z.prototype.set= 50 | function(a,b){Ba(this,a).set(a,b);return this};x.prototype.add=x.prototype.push=function(a){this.__data__.set(a,"__lodash_hash_undefined__");return this};x.prototype.has=function(a){return this.__data__.has(a)};E.prototype.clear=function(){this.__data__=new v};E.prototype["delete"]=function(a){return this.__data__["delete"](a)};E.prototype.get=function(a){return this.__data__.get(a)};E.prototype.has=function(a){return this.__data__.has(a)};E.prototype.set=function(a,b){var c=this.__data__;if(c instanceof 51 | v){c=c.__data__;if(!ja||199>c.length)return c.push([a,b]),this;c=this.__data__=new z(c)}c.set(a,b);return this};var Qa=function(a,b){return function(c,d){if(null==c)return c;if(!Aa(c))return a(c,d);for(var f=c.length,e=b?f:-1,g=Object(c);(b?e--:++e=d.specificityThreshold&&c(a)&&(a.verified=!0);a.verified||0!==g||(a.stack[g].pop(),a.specificity-=100);return a}),Eb(b,1)[0]||a)},b)});w.addMethod(function(a,b){return a.reduce(function(a,b,d){[b.el.nodeName].filter(e).forEach(function(b){a.stack[d].splice(0,0,b);a.specificity+=10});return a},b)});w.addMethod(function(a,b,c){a=a[0];var d=a.el.nodeName;la[d]&&(b=la[d](b,a),c(b)?b.verified=!0: 56 | b.stack[0].pop());return b});w.addMethod(function(a,b){return a.reduce(function(a,b,d){b=Fb(b.getClasses(),10).filter(h).map(function(a){return"."+a});b.length&&(a.stack[d].push(b.join("")),a.specificity+=10*b.length);return a},b)});w.addMethod(function(a,b,c){return a.reduce(function(a,b,d){if(!a.verified){var e=b.prevAll(),g=b.nextAll(),h=e.length+1;!e.length&&!g.length||mb(b,[].concat(ca(e),ca(g)))||(a.stack[d].push(":nth-child("+h+")"),a.verified=c(a))}return a},b)});var Fa=1/0,ra=0/0,sb=/^\s+|\s+$/g, 57 | wb=/^[-+]0x[0-9a-f]+$/i,tb=/^0b[01]+$/i,ub=/^0o[0-7]+$/i,vb=parseInt,rb=Object.prototype.toString,Ib=function(a,b,c){var d=a?a.length:0;if(!d)return[];c||void 0===b?b=1:(b?(b=qb(b),b=b===Fa||b===-Fa?1.7976931348623157e+308*(0>b?-1:1):b===b?b:0):b=0===b?b:0,c=b%1,b=b===b?c?b-c:b:0);b=d-b;b=0>b?0:b;var e=d,d=-1;c=a.length;0>b&&(b=-b>c?0:c+b);e=e>c?c:e;0>e&&(e+=c);c=b>e?0:e-b>>>0;b>>>=0;for(e=Array(c);++de)return!1;g=c(g,d);if(g=1===g.length&&(void 0!==a.el?g[0]===a.el:g[0]===a))b.verificationDepth=h}return g}},zb={queryEngine:null,specificityThreshold:100,depth:3,errorHandling:!1,selectorMaxLength:512}; 59 | (function(a,b){var c=a.Simmer;a.Simmer=b;b.noConflict=function(){a.Simmer=c;return b}})(window,ba(window))})(); 60 | --------------------------------------------------------------------------------