Skip to content

Commit

Permalink
Merge pull request #27 from isinstock/amazon
Browse files Browse the repository at this point in the history
Start tracking Amazon products
  • Loading branch information
dewski authored Dec 6, 2023
2 parents d94e1ae + 822dedc commit fbaa5d7
Show file tree
Hide file tree
Showing 5 changed files with 257 additions and 2 deletions.
2 changes: 1 addition & 1 deletion build.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const cssAsStringPlugin = {

const config = {
logLevel: 'info',
entryPoints: ['./src/background.ts', './src/content_scripts/content_script.tsx'],
entryPoints: ['./src/background.ts', './src/content_scripts/content_script.tsx', './src/content_scripts/amazon.tsx'],
bundle: true,
sourcemap: !isProduction,
watch,
Expand Down
8 changes: 7 additions & 1 deletion chrome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,13 @@
"content_scripts": [
{
"js": ["content_scripts/content_script.js"],
"matches": ["<all_urls>"],
"matches": ["*://*/*"],
"exclude_matches": ["*://*.amazon.com/*"],
"run_at": "document_start"
},
{
"js": ["content_scripts/amazon.js"],
"matches": ["*://*.amazon.com/*"],
"run_at": "document_start"
}
]
Expand Down
12 changes: 12 additions & 0 deletions firefox/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,22 @@
"run_at": "document_start",
"match_about_blank": false,
"all_frames": false,
"exclude_matches": ["*://*.amazon.com/*"],
"matches": [
"http://*/*",
"https://*/*"
]
},
{
"js": [
"content_scripts/amazon.js"
],
"run_at": "document_start",
"match_about_blank": false,
"all_frames": false,
"matches": [
"*://*.amazon.com/*"
]
}
],
"icons": {
Expand Down
176 changes: 176 additions & 0 deletions src/__tests__/amazon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import {Browser, HTTPRequest, Page} from 'puppeteer'

import {createBrowser} from '../utils/browser'

const isValidationRequest = (request: HTTPRequest) => {
return request.url() === 'https://isinstock.com/api/products/validations' && request.method() === 'POST'
}

describe('Browser Extension Test', () => {
let browser: Browser
let page: Page

beforeAll(async () => {
browser = await createBrowser()
})

beforeEach(async () => {
page = await browser.newPage()
})

afterEach(async () => {
await page.close()
})

afterAll(async () => {
if (process.env.CHROME_DEVTOOLS_ID === undefined || process.env.CHROME_DEVTOOLS_ID === '') {
await browser.close()
}
})

test('extension is installable and renders correctly', async () => {
await page.goto('https://www.amazon.com/dp/B08G58D42M/')

expect(await page.waitForSelector('#isinstock-button')).not.toBe(null)
})

test('available product renders available button', async () => {
await page.goto('https://www.amazon.com/dp/B08G58D42M/')
await page.waitForSelector('#isinstock-button')
const result = await page.evaluate(() => {
const button = document.querySelector('#isinstock-button')
if (!button) return null

const shadowRoot = button.shadowRoot
if (!shadowRoot) return null

const element = shadowRoot.querySelector('a[data-inventory-state-normalized]') as HTMLLinkElement
return {
inventoryState: element?.dataset.inventoryState,
inventoryStateNormalized: element?.dataset.inventoryStateNormalized,
textContent: element?.textContent,
target: element?.target,
rel: element?.rel,
href: element?.href,
}
})

const href = new URL(result?.href ?? '')

expect(result?.inventoryState).toBe('InStock')
expect(result?.inventoryStateNormalized).toBe('available')
expect(result?.textContent).toBe('In Stock')
expect(result?.target).toBe('_blank')
expect(result?.rel).toBe('noreferrer')
expect(href.protocol).toBe('https:')
expect(href.hostname).toBe('isinstock.com')
expect(href.pathname).toBe('/track')
expect(href.searchParams.get('url')).toBe('https://www.amazon.com/dp/B08G58D42M/')
expect(href.searchParams.get('utm_campaign')).toBe('web_extension')
})

test('does not perform validation request on non-product pages', async () => {
await page.setRequestInterception(true)
let interceptedValidationsRequest: HTTPRequest | undefined
page.on('request', (interceptedRequest: HTTPRequest) => {
if (isValidationRequest(interceptedRequest)) {
interceptedValidationsRequest = interceptedRequest
}
interceptedRequest.continue()
})

await page.goto('https://www.amazon.com/')

expect(interceptedValidationsRequest).toBeUndefined()
})

test.skip('unavailable product renders unavailable button', async () => {

Check warning on line 87 in src/__tests__/amazon.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Disabled test
await page.goto('https://isinstock.com/store/products/unavailable')
await page.waitForSelector('#isinstock-button')
const result = await page.evaluate(() => {
const button = document.querySelector('#isinstock-button')
if (!button) return null

const shadowRoot = button.shadowRoot
if (!shadowRoot) return null

const element = shadowRoot.querySelector('a[data-inventory-state-normalized]') as HTMLLinkElement
return {
inventoryState: element?.dataset.inventoryState,
inventoryStateNormalized: element?.dataset.inventoryStateNormalized,
textContent: element?.textContent,
target: element?.target,
rel: element?.rel,
href: element?.href,
}
})

const href = new URL(result?.href ?? '')

expect(result?.inventoryState).toBe('OutOfStock')
expect(result?.inventoryStateNormalized).toBe('unavailable')
expect(result?.textContent).toBe('Notify Me When Available')
expect(result?.target).toBe('_blank')
expect(result?.rel).toBe('noreferrer')
expect(href.protocol).toBe('https:')
expect(href.hostname).toBe('isinstock.com')
expect(href.pathname).toBe('/track')
expect(href.searchParams.get('url')).toBe('https://isinstock.com/store/products/unavailable')
expect(href.searchParams.get('utm_campaign')).toBe('web_extension')
})

test('extension makes validation request to isinstock', async () => {
await page.setRequestInterception(true)
let interceptedValidationsRequest: HTTPRequest | undefined
page.on('request', (interceptedRequest: HTTPRequest) => {
if (isValidationRequest(interceptedRequest)) {
interceptedValidationsRequest = interceptedRequest
}
interceptedRequest.continue()
})

await page.goto('https://www.amazon.com/dp/B08G58D42M/', {waitUntil: 'networkidle2'})

expect(interceptedValidationsRequest).toBeDefined()
expect(interceptedValidationsRequest?.method()).toBe('POST')
expect(interceptedValidationsRequest?.postData()).toBe(`{"url":"https://www.amazon.com/dp/B08G58D42M/"}`)
})

test('popstate to restore page searches for products', async () => {
await page.goto('https://www.amazon.com/dp/B08G58D42M/')
await page.waitForSelector('#isinstock-button')

await page.goto('https://www.amazon.com/')
await page.goBack()
const element = await page.waitForSelector('#isinstock-button')

expect(element).not.toBe(null)
})

test('monitors URL changes when no other events are fired', async () => {
await page.setRequestInterception(true)
const interceptedValidationsRequests: HTTPRequest[] = []
page.on('request', (interceptedRequest: HTTPRequest) => {
if (interceptedRequest.isInterceptResolutionHandled()) {
return
}

if (isValidationRequest(interceptedRequest)) {
const postData = JSON.parse(interceptedRequest.postData() ?? '{}')
interceptedValidationsRequests.push(postData.url)
}

interceptedRequest.continue()
})

await page.goto('https://www.amazon.com/dp/B088HH6LW5/')
await page.click('[data-dp-url] button')

await page.waitForRequest(request => isValidationRequest(request))

expect(interceptedValidationsRequests).toStrictEqual([
'https://www.amazon.com/dp/B088HH6LW5/',
'https://www.amazon.com/dp/B0BLF2RWNV/?th=1',
])
})
})
61 changes: 61 additions & 0 deletions src/content_scripts/amazon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import browser from 'webextension-polyfill'

import {MessageAction} from '../@types/messages'
import {ObservableElement} from '../@types/observables'
import {insertIsInStockButton, removeIsInStockButton} from '../elements/isinstock-button'
import ExclusiveValidationRequestCache from '../utils/exclusive-validation-request-cache'
import {observeSelector} from '../utils/observers'
import {notFoundCallback} from '../utils/products'

const validationRequests = new ExclusiveValidationRequestCache()

// We're observing changes to the DOM to know when to insert the button.
//
// link[rel="canonical"] - The canonical link element. This is the link that points to the canonical URL of the page, which contains the URL.
// #ppd - The product details div. This is the div that contains the product title and price.
const {search, observe, disconnect} = observeSelector(
`link[rel="canonical"], #ppd`,
async (observedElements: ObservableElement[], containsProductCandidates: boolean) => {
if (/\/dp\//.test(window.location.href)) {
console.debug('observeSelector.callback: Product found in link', window.location.href)
validationRequests.fetchWithLock(window.location.href, productValidation => {
insertIsInStockButton({productValidation})
})
} else if (!containsProductCandidates) {
// Because we don't fire the MutationObserver twice on the same <script>, it's possible there are products on the
// page and we should not have any side effects that clear state in this callback.
console.debug('observeSelector.callback: No product candidates found in DOM.')
removeIsInStockButton()
notFoundCallback()
}
},
)

window.addEventListener('beforeunload', () => validationRequests.cancelAllRequests())
window.addEventListener('focus', observe)
window.addEventListener('blur', disconnect)
window.addEventListener('pageshow', async event => {
// If persisted then it's in the bfcache, meaning the page was restored from the bfcache.
if (event.persisted) {
console.debug('pageshow: Page was restored from cache.')
search({event})
} else {
console.debug('pageshow: Page was loaded without cache.')
observe()
search({event})
}
})

window.addEventListener('popstate', event => {
console.debug('popstate: The popstate event is fired when the active history entry changes.')
search({event})
})

browser.runtime.onMessage.addListener(async (request, _sender, _sendResponse) => {
if (request.action === MessageAction.URLChanged) {
const event = new CustomEvent('urlChanged', {detail: {request}})
search({event, filterFired: false})
} else {
console.debug('Unknown action', request.action)
}
})

0 comments on commit fbaa5d7

Please sign in to comment.