-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #27 from isinstock/amazon
Start tracking Amazon products
- Loading branch information
Showing
5 changed files
with
257 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 () => { | ||
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', | ||
]) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) |