Skip to content

Commit

Permalink
Fire button in the extension (#2067)
Browse files Browse the repository at this point in the history
* Add type declarations for build flags injected by ESBuild

* Use typeRoots to declare build globals

* Add browsingData permission to chrome

* Wip FireButton feature

* FireButton feature

* Control firebutton display

* Handle options from popup

* Pass tab and cookie data to dashboard

* Move button config to a new message

* Implement clear single site

* Fire button on settings page

* Add chrome mv3 permissions

* Add fire button page.

* Ignore history and downloads for single site clearing

* Expand site to eTLD+1

* Update test spec

* Update for dashboard changes

* Fix tab clearing

* Clear adclick data on burn

* Save the last selected burn option.

* Unit tests for fire button utils

* Integration tests

* Burn test

* Test other burn permutations

* Add temporary pixel for the burn button.

* Rename pixel

* Test fixes

* Add ATB to fireButton pixel

* Options localization and tweaks

* Install lottie-web for fire animation

* Lint

* Skip some burn tests on MV3

We don't seem to be able to request optional permissions in MV3 in the test.

* TS

* Make features global an object.

* Add some comments

* Dark mode burn

* Copy updates

* Copy updates

* Review comments

* Localize burn page title

* Bump dashboard

* Use build flags for feature inclusion

* Move 'current site' to the last option

* Bump dashboard

* Fix test assertions for updated option ordering

* Only enable the fire button in debug builds until translations are complete

* Gate all of the fire button behind build flags
  • Loading branch information
sammacbeth committed Jul 11, 2023
1 parent bdc1cc1 commit 75620f4
Show file tree
Hide file tree
Showing 25 changed files with 874 additions and 29 deletions.
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ else
ESBUILD += --define:DEBUG=false --define:RELOADER=false
endif

## Feature build flags
ifeq ($(type), dev)
ifeq ($(BROWSER_TYPE), chrome)
ESBUILD += --define:FIREBUTTON_ENABLED=true
else
ESBUILD += --define:FIREBUTTON_ENABLED=false
endif
else
ESBUILD += --define:FIREBUTTON_ENABLED=false
endif

$(BUILD_DIR)/public/js/background.js: $(WATCHED_FILES)
$(ESBUILD) shared/js/background/background.js > $@

Expand Down Expand Up @@ -221,7 +232,10 @@ $(BUILD_DIR)/public/js/list-editor.js: $(WATCHED_FILES)
$(BUILD_DIR)/public/js/newtab.js: $(WATCHED_FILES)
$(ESBUILD) shared/js/newtab/newtab.js > $@

JS_BUNDLES = background.js base.js feedback.js options.js devtools-panel.js list-editor.js newtab.js
$(BUILD_DIR)/public/js/fire.js: $(WATCHED_FILES)
$(ESBUILD) shared/js/fire/index.js > $@

JS_BUNDLES = background.js base.js feedback.js options.js devtools-panel.js list-editor.js newtab.js fire.js

BUILD_TARGETS = $(addprefix $(BUILD_DIR)/public/js/, $(JS_BUNDLES))

Expand Down
3 changes: 3 additions & 0 deletions browsers/chrome-mv3/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
"webNavigation",
"cookies"
],
"optional_permissions": [
"browsingData"
],
"host_permissions": [
"*://*/*"
],
Expand Down
6 changes: 5 additions & 1 deletion browsers/chrome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@
"tabs",
"storage",
"<all_urls>",
"alarms"
"alarms",
"cookies"
],
"optional_permissions": [
"browsingData"
],
"web_accessible_resources": [
"/web_accessible_resources/*",
Expand Down
295 changes: 295 additions & 0 deletions integration-test/fire-button.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import { forExtensionLoaded } from './helpers/backgroundWait'
import { test, expect, getManifestVersion } from './helpers/playwrightHarness'
import { routeFromLocalhost } from './helpers/testPages'

const burnAnimationRegex = /^chrome-extension:\/\/[a-z]*\/html\/fire.html$/

async function loadPageInNewTab (context, url) {
const page = await context.newPage()
routeFromLocalhost(page)
await page.goto(url, { waitUntil: 'networkidle' })
return page
}

/**
* @param {import('@playwright/test').BrowserContext} context
* @returns {Promise<import('@playwright/test').Page[]>}
*/
function openTabs (context) {
return Promise.all([
loadPageInNewTab(context, 'https://duckduckgo.com/'),
loadPageInNewTab(context, 'https://privacy-test-pages.glitch.me/'),
loadPageInNewTab(context, 'https://good.third-party.site/privacy-protections/storage-blocking/?store'),
loadPageInNewTab(context, 'https://privacy-test-pages.glitch.me/privacy-protections/storage-blocking/?store')
])
}

function getOpenTabs (backgroundPage) {
return backgroundPage.evaluate(() => {
return new Promise(resolve => chrome.tabs.query({}, resolve))
})
}

async function requestBrowsingDataPermissions (backgroundPage) {
const permissionGranted = await backgroundPage.evaluate(() =>
new Promise(resolve => chrome.permissions.request({ permissions: ['browsingData'] }, resolve))
)
expect(permissionGranted).toBeTruthy()
}

/**
* @param {*} backgroundPage
* @returns {Promise<import('@playwright/test').JSHandle>}
*/
function getFireButtonHandle (backgroundPage) {
return backgroundPage.evaluateHandle(() => globalThis.features.fireButton)
}

async function waitForAllResults (page) {
while ((await page.$$('#tests-details > li > span > ul')).length < 2) {
await new Promise(resolve => setTimeout(resolve, 100))
}
}

test.describe('Fire Button', () => {
test('Fire animation', async ({ context, backgroundPage }) => {
await forExtensionLoaded(context)
const fireButton = await getFireButtonHandle(backgroundPage)
// detect the burn animation extension page being opened, and return the page object
const animationLoaded = new Promise((resolve) => {
context.once('page', (page) => {
if (page.url().match(burnAnimationRegex)) {
resolve(page)
}
})
})
// trigger the animation
await fireButton.evaluate(f => f.showBurnAnimation())
const burnAnimationPage = await animationLoaded
// wait for the animation to complete
await new Promise(resolve => setTimeout(resolve, 3000))
// check that we're redirected to the newtab page after the animation completes
expect(burnAnimationPage.url()).toMatch(/^(https:\/\/duckduckgo.com\/chrome_newtab|chrome:\/\/new-tab-page\/$)/)
})

test.describe('Tab clearing', () => {
const testCases = [{
desc: 'clearing all tabs',
args: [true],
expectedTabs: 1
}, {
desc: 'clearing no tabs',
args: [false],
expectedTabs: 7
}, {
desc: 'clearing specific origins',
args: [true, ['https://privacy-test-pages.glitch.me/', 'https://duckduckgo.com/']],
expectedTabs: 3
}]

testCases.forEach(({ desc, args, expectedTabs }) => {
test(desc, async ({ context, backgroundPage }) => {
await forExtensionLoaded(context)
// get the firebutton feature
const fireButton = await getFireButtonHandle(backgroundPage)
await openTabs(context)
await Promise.all([
fireButton.evaluate((f, argsInner) => f.clearTabs(...argsInner), args),
context.waitForEvent('page')
])
// expect((await getOpenTabs(backgroundPage)).map(t => t.url)).toEqual([])
expect(context.pages()).toHaveLength(expectedTabs)
expect((await getOpenTabs(backgroundPage)).find(({ active }) => active).url).toMatch(burnAnimationRegex)
})
})
})

test('getBurnOptions', async ({ context, backgroundPage }) => {
await forExtensionLoaded(context)
const fireButton = await getFireButtonHandle(backgroundPage)
const pages = await openTabs(context)
await pages[1].bringToFront()
await pages[1].waitForTimeout(500)

{
// default options on an clearable site
const { options } = await fireButton.evaluate(f => f.getBurnOptions())
expect(options).toHaveLength(6) // current site, plus 5 time frames
expect(options[5]).toMatchObject({
name: 'CurrentSite',
options: {
origins: ['https://privacy-test-pages.glitch.me', 'http://privacy-test-pages.glitch.me']
},
descriptionStats: {
clearHistory: true,
cookies: 1,
duration: 'all',
openTabs: 2, // gets the number of tabs matching this origin
pinnedTabs: 0,
site: 'privacy-test-pages.glitch.me'
},
selected: true
})
expect(options[2]).toMatchObject({
name: 'Last7days',
descriptionStats: {
clearHistory: true,
cookies: 3, // gets the number of domains with cookies set
duration: 'week',
openTabs: 6, // gets all open tabs that will be cleared
pinnedTabs: 0
}
})
expect(options[2].options.since).toBeGreaterThan(Date.now() - (8 * 24 * 60 * 60 * 1000))
}

// default options on a non-clearable site
await context.pages()[0].bringToFront()
{
const { options } = await fireButton.evaluate(f => f.getBurnOptions())
expect(options).toHaveLength(5) // only 5 time frames
}
await pages[0].bringToFront()

// with pinned tabs
const tabs = await getOpenTabs(backgroundPage)
await backgroundPage.evaluate((tabIds) => {
tabIds.forEach(id => chrome.tabs.update(id, { pinned: true }))
}, tabs.filter(t => t.url.startsWith('https://duckduckgo.com/')).map(t => t.id))
{
const { options } = await fireButton.evaluate(f => f.getBurnOptions())
expect(options.every(o => o.descriptionStats.pinnedTabs === 2)).toBeTruthy()
}
// if we select a non-pinned tab, that will not have the pinnedTabs option
await pages[1].bringToFront()
{
const { options } = await fireButton.evaluate(f => f.getBurnOptions())
expect(options[5]).toMatchObject({
descriptionStats: {
pinnedTabs: 0
}
})
expect(options[0]).toMatchObject({
descriptionStats: {
pinnedTabs: 2
}
})
}

// if clearHistory setting is disabled
await backgroundPage.evaluate(() => {
/* global dbg */
dbg.settings.updateSetting('fireButtonClearHistoryEnabled', false)
})
{
const { options } = await fireButton.evaluate(f => f.getBurnOptions())
expect(options.every(o => o.descriptionStats.clearHistory === false)).toBeTruthy()
}

// if clearTabs setting is disabled
await backgroundPage.evaluate(() => {
dbg.settings.updateSetting('fireButtonClearHistoryEnabled', true)
dbg.settings.updateSetting('fireButtonTabClearEnabled', false)
})
{
const { options } = await fireButton.evaluate(f => f.getBurnOptions())
expect(options.every(o => o.descriptionStats.openTabs === 0)).toBeTruthy()
expect(options.every(o => o.descriptionStats.pinnedTabs === 0)).toBeTruthy()
}
})

test.describe('burn', () => {
// Skip these tests on MV3.
// For these tests to work, we need to be able to successfully request the optional `browsingData`
// permission at runtime (`requestBrowsingDataPermissions`). When running these tests in Playwright,
// this works without issue with the MV2 extension, however in MV3 the permission is rejected.
if (getManifestVersion() === 3) {
return
}
test('clears tabs and storage', async ({ context, backgroundPage }) => {
await forExtensionLoaded(context)
await requestBrowsingDataPermissions(backgroundPage)
const fireButton = await getFireButtonHandle(backgroundPage)
await openTabs(context)

expect((await context.cookies()).length).toBeGreaterThan(0)
await fireButton.evaluate(f => f.burn({}))
expect((await getOpenTabs(backgroundPage)).length).toBe(1)
expect(await context.cookies()).toEqual([])
})

test('exempts duckduckgo.com cookies', async ({ context, backgroundPage }) => {
await forExtensionLoaded(context)
await requestBrowsingDataPermissions(backgroundPage)
const fireButton = await getFireButtonHandle(backgroundPage)
const ddgCookie = {
name: 'ae',
value: 'd',
domain: 'duckduckgo.com',
httpOnly: false,
path: '/',
sameSite: 'Lax',
secure: true
}
await context.addCookies([ddgCookie])
await openTabs(context)

expect((await context.cookies()).length).toBeGreaterThan(0)
await fireButton.evaluate(f => f.burn({}))
expect((await getOpenTabs(backgroundPage)).length).toBe(1)
expect(await context.cookies()).toMatchObject([ddgCookie])
})

test('clearing for a specific site', async ({ context, backgroundPage }) => {
await forExtensionLoaded(context)
await requestBrowsingDataPermissions(backgroundPage)
const fireButton = await getFireButtonHandle(backgroundPage)
await openTabs(context)

await fireButton.evaluate(f => f.burn({
origins: ['https://privacy-test-pages.glitch.me', 'http://privacy-test-pages.glitch.me']
}))

const tabs = await getOpenTabs(backgroundPage)
expect(tabs.every(t => !t.url.includes('privacy-test-pages.glitch.me'))).toBeTruthy()
const cookieDomains = (await context.cookies()).map(c => c.domain)
expect(cookieDomains).not.toContain('privacy-test-pages.glitch.me')
expect(cookieDomains).toContain('good.third-party.site')
})

test('clears all browser storage', async ({ context, backgroundPage, page }) => {
await forExtensionLoaded(context)
await requestBrowsingDataPermissions(backgroundPage)
await routeFromLocalhost(page)
await page.goto('https://privacy-test-pages.glitch.me/privacy-protections/storage-blocking/?store', { waitUntil: 'networkidle' })
const storedValue = new URL(page.url()).hash.slice(1)
await (await getFireButtonHandle(backgroundPage)).evaluate(f => f.burn({}))

const newPage = await context.newPage()
await routeFromLocalhost(newPage)
await newPage.goto('https://privacy-test-pages.glitch.me/privacy-protections/storage-blocking/?retrive', { waitUntil: 'networkidle' })
await waitForAllResults(newPage)
const { results } = await JSON.parse(await newPage.evaluate('JSON.stringify(results)'))
const apis = [
'JS cookie', 'localStorage', 'Cache API', 'WebSQL', 'service worker', 'first party header cookie', 'IndexedDB', 'browser cache'
]
for (const api of apis) {
expect(results.find(r => r.id === api).value, `${api} data should be cleared`).not.toBe(storedValue)
}
})

test('clear data without clearing tabs', async ({ context, backgroundPage, page }) => {
await forExtensionLoaded(context)
await requestBrowsingDataPermissions(backgroundPage)
const fireButton = await getFireButtonHandle(backgroundPage)
await openTabs(context)

await fireButton.evaluate(f => f.burn({
closeTabs: false
}))

expect((await getOpenTabs(backgroundPage)).length).toBe(8)
expect(await context.cookies()).toEqual([])
})
})
})
2 changes: 1 addition & 1 deletion integration-test/helpers/playwrightHarness.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function getHARPath (harFile) {
return path.join(testRoot, 'data', 'har', harFile)
}

function getManifestVersion () {
export function getManifestVersion () {
return process.env.npm_lifecycle_event === 'playwright-mv3' ? 3 : 2
}

Expand Down
Loading

0 comments on commit 75620f4

Please sign in to comment.