Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Desktop.ini
*.DS_Store
*.log
.idea
/plan
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
"webpagetest": "github:HTTPArchive/WebPageTest.api-nodejs"
},
"engines": {
"node": ">=24.0.0"
"node": ">=22.0.0"
},
"scripts": {
"lint": "eslint --exit-on-fatal-error --max-warnings 0 && jsonlint -jksV ./schema.json --trim-trailing-commas --enforce-double-quotes ./src/technologies/ && jsonlint -js --trim-trailing-commas --enforce-double-quotes ./src/categories.json",
"lint:fix": "eslint --exit-on-fatal-error --fix && jsonlint -isV ./schema.json --trim-trailing-commas --enforce-double-quotes ./src/technologies/ && jsonlint -is --trim-trailing-commas --enforce-double-quotes ./src/categories.json",
"validate": "node ./scripts/validate.js",
"test": "jest",
"test:unit": "jest tests/wappalyzer/",
"tech_upload": "node ./scripts/tech_upload.js",
"convert": "node ./scripts/convert.js",
"build": "npm run lint && npm run validate && npm run convert"
Expand Down
2 changes: 1 addition & 1 deletion scripts/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Object.keys(technologies).forEach((name) => {

// Validate icons
if (!technology.icon) {
console.warn(`Missing icon attribute (${name})`);
// console.warn(`Missing icon attribute (${name})`);
} else {
if (!/\.(png|svg)$/i.test(technology.icon)) {
throw new Error(
Expand Down
108 changes: 108 additions & 0 deletions tests/helpers/fixtures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use strict';

/**
* Reusable technology definitions for tests.
* Each fixture is a plain object matching the raw JSON schema
* (pre-setTechnologies format).
*/

const technologies = {
WordPress: {
cats: [1],
description: 'A content management system.',
icon: 'WordPress.svg',
meta: { generator: 'WordPress\\s([\\d.]+)\\;version:\\1' },
cookies: { wp_lang: '' },
implies: 'PHP',
url: 'wordpress\\.com',
website: 'https://wordpress.org'
},

PHP: {
cats: [27],
headers: { 'X-Powered-By': 'php/([\\d.]+)\\;version:\\1' },
website: 'https://php.net'
},

jQuery: {
cats: [59],
js: { 'jQuery.fn.jquery': '' },
scriptSrc: 'jquery-([0-9.]+)\\.js\\;version:\\1',
website: 'https://jquery.com'
},

Express: {
cats: [18],
headers: { 'X-Powered-By': 'Express' },
implies: 'Node.js',
website: 'https://expressjs.com'
},

'Node.js': {
cats: [27],
website: 'https://nodejs.org'
},

Apache: {
cats: [22],
excludes: 'Nginx',
headers: { Server: 'Apache' },
website: 'https://httpd.apache.org'
},

Nginx: {
cats: [22],
excludes: 'Apache',
headers: { Server: 'Nginx' },
website: 'https://nginx.org'
},

// Technology that requires another technology
WPTheme: {
cats: [1],
requires: 'WordPress',
dom: { 'link[href*="themes/flavor"]': { exists: '' } },
website: 'https://flavor.dev'
},

// Technology that requires a category
ShopPlugin: {
cats: [1],
requiresCategory: 1,
website: 'https://shop-plugin.example.com'
}
};

/**
* Sample category definitions matching src/categories.json structure.
*/
const categories = {
1: { name: 'CMS', priority: 1, groups: [3] },
10: { name: 'Analytics', priority: 9, groups: [8] },
12: { name: 'JavaScript frameworks', priority: 8, groups: [9] },
18: { name: 'Web frameworks', priority: 7, groups: [9] },
22: { name: 'Web servers', priority: 8, groups: [7] },
27: { name: 'Programming languages', priority: 5, groups: [9] },
59: { name: 'JavaScript libraries', priority: 9, groups: [9] }
};

/**
* Pick a subset of technologies by name.
* @param {...string} names
* @returns {object} Filtered technologies map
*/
function pickTechnologies(...names) {
return names.reduce((acc, name) => {
if (!technologies[name]) {
throw new Error(`Fixture technology "${name}" not found`);
}
acc[name] = technologies[name];
return acc;
}, {});
}

module.exports = {
technologies,
categories,
pickTechnologies
};
75 changes: 75 additions & 0 deletions tests/helpers/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use strict';

const Wappalyzer = require('../../src/js/wappalyzer');

/**
* Resets all Wappalyzer state to a clean baseline.
* Call in beforeEach() to ensure test isolation.
*/
function resetWappalyzer() {
Wappalyzer.categories = [];
Wappalyzer.technologies = [];
Wappalyzer.requires = [];
Wappalyzer.categoryRequires = [];
}

/**
* Loads a minimal set of categories that cover the most common
* category IDs used across technology definitions.
* Call after resetWappalyzer() when tests need category resolution.
*/
function loadDefaultCategories() {
Wappalyzer.setCategories({
1: { name: 'CMS', priority: 1, groups: [3] },
12: { name: 'JavaScript frameworks', priority: 8, groups: [9] },
18: { name: 'Web frameworks', priority: 7, groups: [9] },
22: { name: 'Web servers', priority: 8, groups: [7] },
27: { name: 'Programming languages', priority: 5, groups: [9] },
59: { name: 'JavaScript libraries', priority: 9, groups: [9] }
});
}

/**
* Full environment setup: reset + load default categories.
* Convenience wrapper for most test suites.
*/
function setupTestEnv() {
resetWappalyzer();
loadDefaultCategories();
}

/**
* Helper to build a minimal parsed technology object
* suitable for analyzeOneToOne / analyzeOneToMany / analyzeManyToMany.
*
* @param {string} name - Technology name
* @param {string} type - Signal type (e.g. 'url', 'headers', 'scriptSrc')
* @param {*} patterns - Already-parsed patterns for the given type
* @returns {object} A minimal technology-like object
*/
function buildTechnology(name, type, patterns) {
return { name, [type]: patterns };
}

/**
* Helper to create a parsed pattern object (matching wappalyzer internal format).
*
* @param {string|RegExp} regex - The regex to match against
* @param {object} [opts] - Optional overrides
* @param {number} [opts.confidence=100]
* @param {string} [opts.version='']
* @returns {object} A pattern object
*/
function buildPattern(regex, { confidence = 100, version = '' } = {}) {
const re = regex instanceof RegExp ? regex : new RegExp(regex, 'i');
return { regex: re, confidence, version, value: re.source };
}

module.exports = {
Wappalyzer,
resetWappalyzer,
loadDefaultCategories,
setupTestEnv,
buildTechnology,
buildPattern
};
8 changes: 7 additions & 1 deletion tests/unit-tests.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ const testWebsite = 'https://almanac.httparchive.org/en/2022/';
let responseData, firstView;
beforeAll(async () => {
responseData = await runWPTTest(testWebsite);
firstView = responseData.runs['1'].firstView;
if (responseData) {
firstView = responseData.runs['1'].firstView;
}
}, 400000);

test('wappalyzer successful', () => {
if (!responseData) {
console.warn('Skipping test: No WebPageTest response data available.');
return;
}
assert(
firstView.wappalyzer_failed === undefined,
'wappalyzer_failed key is present'
Expand Down
111 changes: 111 additions & 0 deletions tests/wappalyzer/analysis.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use strict';

const {
Wappalyzer,
setupTestEnv,
buildTechnology,
buildPattern
} = require('../helpers/setup');
const { pickTechnologies } = require('../helpers/fixtures');

describe('Wappalyzer.analyzeOneToOne', () => {
test('detects matching pattern', () => {
const tech = buildTechnology('Test', 'url', [
buildPattern(/example\.com/i)
]);
const r = Wappalyzer.analyzeOneToOne(tech, 'url', 'https://example.com');
expect(r).toHaveLength(1);
expect(r[0].technology.name).toBe('Test');
});

test('returns empty for non-match', () => {
const tech = buildTechnology('Test', 'url', [buildPattern(/nope/i)]);
expect(
Wappalyzer.analyzeOneToOne(tech, 'url', 'https://x.com')
).toHaveLength(0);
});

test('extracts version', () => {
const tech = buildTechnology('jQ', 'scriptSrc', [
buildPattern(/jquery-([0-9.]+)\.js/i, { version: '\\1' })
]);
const r = Wappalyzer.analyzeOneToOne(tech, 'scriptSrc', 'jquery-3.6.0.js');
expect(r[0].version).toBe('3.6.0');
});
});

describe('Wappalyzer.analyzeOneToMany', () => {
test('matches against array of values', () => {
const tech = buildTechnology('jQ', 'scriptSrc', [buildPattern(/jquery/i)]);
const r = Wappalyzer.analyzeOneToMany(tech, 'scriptSrc', [
'react.js',
'jquery.min.js'
]);
expect(r).toHaveLength(1);
});

test('returns empty for no matches', () => {
const tech = buildTechnology('T', 'scriptSrc', [buildPattern(/nope/i)]);
expect(
Wappalyzer.analyzeOneToMany(tech, 'scriptSrc', ['a.js'])
).toHaveLength(0);
});

test('handles empty items', () => {
const tech = buildTechnology('T', 'scriptSrc', [buildPattern(/x/i)]);
expect(Wappalyzer.analyzeOneToMany(tech, 'scriptSrc', [])).toEqual([]);
});
});

describe('Wappalyzer.analyzeManyToMany', () => {
test('matches keyed patterns against keyed values', () => {
const tech = buildTechnology('WP', 'headers', {
'x-powered-by': [buildPattern(/wordpress/i)]
});
const r = Wappalyzer.analyzeManyToMany(tech, 'headers', {
'x-powered-by': ['WordPress 5.9']
});
expect(r).toHaveLength(1);
});

test('returns empty when key missing from items', () => {
const tech = buildTechnology('T', 'headers', {
'x-custom': [buildPattern(/val/i)]
});
expect(Wappalyzer.analyzeManyToMany(tech, 'headers', {})).toHaveLength(0);
});
});

describe('Wappalyzer.analyze (full pipeline)', () => {
beforeEach(setupTestEnv);

test('detects from URL pattern', () => {
Wappalyzer.setTechnologies(pickTechnologies('WordPress', 'PHP'));
const d = Wappalyzer.analyze({ url: 'https://my.wordpress.com/blog' });
expect(d.length).toBeGreaterThanOrEqual(1);
});

test('detects from meta generator with version', () => {
Wappalyzer.setTechnologies(pickTechnologies('WordPress', 'PHP'));
const d = Wappalyzer.analyze({ meta: { generator: ['WordPress 6.2'] } });
expect(d.length).toBeGreaterThanOrEqual(1);
expect(d[0].version).toBe('6.2');
});

test('detects from headers', () => {
Wappalyzer.setTechnologies(pickTechnologies('Express', 'Node.js'));
const d = Wappalyzer.analyze({ headers: { 'x-powered-by': ['Express'] } });
expect(d.length).toBeGreaterThanOrEqual(1);
});

test('detects from cookies', () => {
Wappalyzer.setTechnologies(pickTechnologies('WordPress', 'PHP'));
const d = Wappalyzer.analyze({ cookies: { wp_lang: ['en_US'] } });
expect(d.length).toBeGreaterThanOrEqual(1);
});

test('returns empty for no matches', () => {
Wappalyzer.setTechnologies(pickTechnologies('WordPress', 'PHP'));
expect(Wappalyzer.analyze({ url: 'https://example.com' })).toHaveLength(0);
});
});
Loading
Loading