diff --git a/CHANGES.md b/CHANGES.md index 56026c6..ba536ea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## Version 1.3 - Add per-domain settings for deduplicating by thumbnail +- Automate basic browser tests using Selenium - Minor fixes/improvements ## Version 1.2 diff --git a/chrome.jq b/chrome.jq index 9d99298..ba648e5 100644 --- a/chrome.jq +++ b/chrome.jq @@ -26,3 +26,6 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }) + +# Chrome emits a spurious warning about browser_specific_settings +| del(.browser_specific_settings) diff --git a/manifest.json b/manifest.json index 989f741..5b0175b 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,12 @@ "description": "Hide duplicate posts on pre-redesign Reddit.", "homepage_url": "https://nickgaya.github.io/rededup/", + "browser_specific_settings": { + "gecko": { + "id": "{68f0c654-5a3d-423b-b846-2b3ab68d05dd}" + } + }, + "icons": { "48": "icons/icon.svg", "96": "icons/icon.svg" diff --git a/rededup.js b/rededup.js index 04b9df9..058eb65 100644 --- a/rededup.js +++ b/rededup.js @@ -195,7 +195,10 @@ async function getLinkInfo(thing, pageType, settings) { if (settings.showHashValues) { const hashElt = document.createElement('code'); hashElt.textContent = bufToHex(linkInfo.thumbnailHash); - getTagline(thing, pageType).append(' [', hashElt, ']'); + const spanElt = document.createElement('span'); + spanElt.classList.add('rededup-hash'); + spanElt.append(' [', hashElt, ']'); + getTagline(thing, pageType).append(spanElt); } } catch (error) { console.warn("Failed to get thumbnail hash", thumbnailImg, diff --git a/tests/README.md b/tests/README.md index c65b73f..11d06f7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,7 +5,8 @@ gecko-webdriver to be installed on your system. To run the tests: - export REDEDUP_PATH=../artifacts/rededup--fx.zip npm test + export REDEDUP_PATH=../artifacts/rededup--fx.zip + npm test By default, the tests run in "headless" mode. To enable the browser GUI, you can set the environment variable `BROWSER_GUI=true`. diff --git a/tests/package-lock.json b/tests/package-lock.json index 912af3c..529cdda 100644 --- a/tests/package-lock.json +++ b/tests/package-lock.json @@ -7,7 +7,8 @@ "devDependencies": { "chai": "^4.3.4", "mocha": "^8.4.0", - "selenium-webdriver": "^4.0.0-beta.3" + "selenium-webdriver": "^4.0.0-beta.3", + "uuid": "^8.3.2" } }, "node_modules/@ungap/promise-all-settled": { @@ -418,20 +419,6 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1047,6 +1034,15 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1582,13 +1578,6 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2054,6 +2043,12 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/tests/package.json b/tests/package.json index 1e518c6..4e66e30 100644 --- a/tests/package.json +++ b/tests/package.json @@ -3,7 +3,8 @@ "devDependencies": { "chai": "^4.3.4", "mocha": "^8.4.0", - "selenium-webdriver": "^4.0.0-beta.3" + "selenium-webdriver": "^4.0.0-beta.3", + "uuid": "^8.3.2" }, "mocha": { "ui": "tdd" diff --git a/tests/test.js b/tests/test.js index 40bcb7a..0344d16 100644 --- a/tests/test.js +++ b/tests/test.js @@ -1,8 +1,14 @@ const webdriver = require('selenium-webdriver'); const firefox = require('selenium-webdriver/firefox'); +const {Origin} = require('selenium-webdriver/lib/input'); const {assert} = require('chai'); +const uuid = require('uuid'); + +const firefoxExtensionId = '{68f0c654-5a3d-423b-b846-2b3ab68d05dd}'; +const extensionUuid = uuid.v4(); + function sleep(milliseconds) { return new Promise((resolve, reject) => { setTimeout(resolve, milliseconds); @@ -21,6 +27,16 @@ function arraysEqual(arr1, arr2) { return true; } +function eltOrNull(promise) { + return promise.catch((error) => { + if (error instanceof webdriver.error.NoSuchElementError) { + return null; + } else { + throw error; + } + }); +} + class Link { constructor(id, element) { this.id = id; @@ -38,16 +54,8 @@ class Link { async getTagline() { if (this.tagline === undefined) { - try { - this.tagline = await this.element.findElement( - {css: '.tagline .rededup-tagline'}); - } catch (error) { - if (error instanceof webdriver.error.NoSuchElementError) { - this.tagline = null; - } else { - throw error; - } - } + this.tagline = await eltOrNull(this.element.findElement( + {css: '.tagline .rededup-tagline'})); } return this.tagline; } @@ -56,6 +64,11 @@ class Link { const tagline = await this.getTagline(); return await tagline.findElement({css: '.rededup-toggle'}); } + + async getHash() { + return await eltOrNull(this.element.findElement( + {css: '.tagline .rededup-hash'})); + } } async function loadByIds(driver, ids) { @@ -142,16 +155,18 @@ async function verifyShowHide(link, ...duplicateLinks) { `Expect updated toggle text for link ${link.id}`); } -async function deduplicateTest(driver, ids, expected) { +async function deduplicateTest(driver, ids, expected, showHide = false) { const links = await loadByIds(driver, ids); await verifyDuplicates(links, expected); - let idx = 0; - for (const item of ids) { - if (Array.isArray(item)) { - await verifyShowHide(...links.slice(idx, idx+item.length)); - idx += item.length; - } else { - idx++; + if (showHide) { + let idx = 0; + for (const item of ids) { + if (Array.isArray(item)) { + await verifyShowHide(...links.slice(idx, idx+item.length)); + idx += item.length; + } else { + idx++; + } } } return links; @@ -161,6 +176,10 @@ async function getVisibility(links) { return await Promise.all(links.map((link) => link.isVisible())); } +async function openSettingsPage(driver) { + await driver.get(`moz-extension://${extensionUuid}/options/index.html`); +} + suite('Selenium', function() { this.timeout(10000); @@ -171,6 +190,9 @@ suite('Selenium', function() { if (process.env.BROWSER_GUI !== 'true') { options.headless(); } + options.setPreference('extensions.webextensions.uuids', + JSON.stringify({[firefoxExtensionId]: + extensionUuid})) driver = await new webdriver.Builder() .forBrowser('firefox') @@ -186,19 +208,22 @@ suite('Selenium', function() { test('deduplicate by thumbnail', async function() { await deduplicateTest(driver, ['t3_jrjed7', 't3_jrqhj4', 't3_jo1qwh', 't3_jri2y8'], - [['t3_jrjed7', 't3_jrqhj4', 't3_jri2y8'], 't3_jo1qwh']); + [['t3_jrjed7', 't3_jrqhj4', 't3_jri2y8'], 't3_jo1qwh'], + true); }); test('deduplicate by url', async function() { await deduplicateTest(driver, ['t3_jyu5b2', 't3_jysgvx', 't3_jywerx'], - [['t3_jyu5b2', 't3_jywerx'], 't3_jysgvx']); + [['t3_jyu5b2', 't3_jywerx'], 't3_jysgvx'], + true); }); test('deduplicate crosspost', async function() { await deduplicateTest(driver, ['t3_jyu5b2', 't3_jyvuiz'], - [['t3_jyu5b2', 't3_jyvuiz']]); + [['t3_jyu5b2', 't3_jyvuiz']], + true); }); test('deduplicate multiple', async function() { @@ -208,8 +233,10 @@ suite('Selenium', function() { ['t3_jysgvx', ['t3_jrjed7', 't3_jrqhj4', 't3_jri2y8'], ['t3_jyu5b2', 't3_jywerx', 't3_jyvuiz'], - 't3_jo1qwh']); + 't3_jo1qwh'], + true); + // Verify that different toggle links function independently const toggle1 = await links[1].getToggle(); const toggle2 = await links[4].getToggle(); @@ -251,6 +278,131 @@ suite('Selenium', function() { }); }); + suite('settings', function() { + + test('deduplicate by', async function() { + // Posts should be collated + await deduplicateTest(driver, + ['t3_jrjed7', 't3_jrqhj4',], + [['t3_jrjed7', 't3_jrqhj4']]); + + // Select "Deduplicate posts by URL only" + await openSettingsPage(driver); + const elt = await driver.findElement({id: 'ddByUrlOnly'}); + await elt.click(); + + // Now posts should NOT be collated + await deduplicateTest(driver, + ['t3_jrjed7', 't3_jrqhj4',], + ['t3_jrjed7', 't3_jrqhj4']); + }); + + test('domain overrides', async function() { + // Posts should be collated + await deduplicateTest(driver, + ['t3_narqjh', 't3_na423x', 't3_nas73l'], + [['t3_narqjh', 't3_na423x', 't3_nas73l']]); + + // Disable thumbnail processing for a domain + await openSettingsPage(driver); + let elt = await driver.findElement({id: 'domainSettingsButton'}); + await elt.click(); + elt = await driver.findElement({id: 'domainInputText'}); + await elt.sendKeys('independent.co.uk'); + elt = await driver.findElement({id: 'domainInputButton'}); + await elt.click(); + elt = await driver.findElement({id: 'domainSettingsSave'}); + await elt.click(); + + // Now posts should be collated by URL but not thumbnail + await deduplicateTest(driver, + ['t3_narqjh', 't3_na423x', 't3_nas73l'], + [['t3_narqjh', 't3_nas73l'], 't3_na423x']); + }); + + for (const [name, eltId] of [['dct', 'dctHash'], + ['difference', 'diffHash'], + ['wavelet', 'waveletHash']]) { + test(`${name} hash function`, async function() { + // Select hash function + await openSettingsPage(driver); + const elt = await driver.findElement({id: eltId}); + await elt.click(); + + // Verify posts are collated + await deduplicateTest(driver, + ['t3_k7rhax', 't3_k7rdve'], + [['t3_k7rhax', 't3_k7rdve']]); + }); + } + + test('hamming distance', async function() { + // Posts should all be collated + await deduplicateTest(driver, + ['t3_it6czg', 't3_it6el1', 't3_it8ric'], + [['t3_it6czg', 't3_it6el1', 't3_it8ric']]); + + await openSettingsPage(driver); + const elt = await driver.findElement({id: 'maxHammingDistance'}); + const {width} = await elt.getRect(); + const actions = driver.actions(); + await actions.move({origin: elt, x:0, y:0}) + .press() + .move({origin: Origin.POINTER, x:-width/2, y:0}) + .release() + .perform(); + assert.equal(await elt.getAttribute('value'), '0'); + + // One post should now be separate from the other two + await deduplicateTest(driver, + ['t3_it6czg', 't3_it6el1', 't3_it8ric'], + [['t3_it6czg', 't3_it6el1'], 't3_it8ric']); + }); + + test('partition by domain', async function() { + // Posts with different domains should NOT be collated by default + await deduplicateTest(driver, + ['t3_jyymza', 't3_jyyy2a'], + ['t3_jyymza', 't3_jyyy2a']); + + await openSettingsPage(driver); + const elt = await driver.findElement({id: 'partitionByDomain'}); + await elt.click(); + + // Posts should now be collated + await deduplicateTest(driver, + ['t3_jyymza', 't3_jyyy2a'], + [['t3_jyymza', 't3_jyyy2a']]); + }); + + test('show hash values', async function() { + await openSettingsPage(driver); + const elt = await driver.findElement({id: 'showHashValues'}); + await elt.click(); + + const links = await loadByIds(driver, ['t3_jyu5b2', 't3_ncjf1w']); + await verifyDuplicates(links, ['t3_jyu5b2', 't3_ncjf1w']); + + assert.isNull(await links[0].getHash(), + "Expect no thumbnail hash element for post without " + + "thumbnail"); + + const hashElt = await links[1].getHash(); + assert.isNotNull(hashElt, + "Expect thumbnail hash for post with thumbnail"); + // Use textContent to preserve whitespace + assert.match(await hashElt.getAttribute('textContent'), + /^ \[[0-9a-f]{16}\]$/, + "Expect hash text to be 16 hex digits in brackets"); + }); + + teardown(async function() { + await openSettingsPage(driver); + const resetButton = driver.findElement({id: 'reset'}); + await resetButton.click(); + }); + }); + suiteTeardown(async function() { if (driver) { await driver.quit();