Skip to content
Merged
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
12 changes: 7 additions & 5 deletions src/content-script/site-adapters/brave/index.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { waitForElementToExistAndSelect } from '../../../utils'
import { waitForSiteAdapterElement } from '../../../utils'
Comment thread
PeterDaveHello marked this conversation as resolved.
import { config } from '../index.mjs'

export default {
init: async (hostname, userConfig) => {
const selector = userConfig.insertAtTop
? config.brave.resultsContainerQuery[0]
: config.brave.sidebarContainerQuery[0]
await waitForElementToExistAndSelect(selector, 5)
const selector = (
userConfig.insertAtTop
? config.brave.resultsContainerQuery
: [...config.brave.sidebarContainerQuery, ...config.brave.resultsContainerQuery]
).join(',')
await waitForSiteAdapterElement(selector)
Comment thread
PeterDaveHello marked this conversation as resolved.
return true
},
}
4 changes: 2 additions & 2 deletions src/content-script/site-adapters/duckduckgo/index.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { waitForElementToExistAndSelect } from '../../../utils/index.mjs'
import { waitForSiteAdapterElement } from '../../../utils/index.mjs'
import { config } from '../index'

export default {
init: async (hostname, userConfig) => {
if (userConfig.insertAtTop) {
return !!(await waitForElementToExistAndSelect(config.duckduckgo.resultsContainerQuery[0], 5))
return !!(await waitForSiteAdapterElement(config.duckduckgo.resultsContainerQuery[0]))
}
return true
},
Expand Down
26 changes: 17 additions & 9 deletions src/utils/wait-for-element-to-exist-and-select.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
export function waitForElementToExistAndSelect(selector, timeout = 0) {
return new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector))
const existingElement = document.querySelector(selector)
if (existingElement) return resolve(existingElement)

let timeoutId

const finish = (element) => {
observer.disconnect()
if (timeoutId !== undefined) clearTimeout(timeoutId)
resolve(element)
}

const observer = new MutationObserver(() => {
if (document.querySelector(selector)) {
resolve(document.querySelector(selector))
observer.disconnect()
}
const element = document.querySelector(selector)
if (element) finish(element)
})

observer.observe(document.body, {
Expand All @@ -17,9 +22,12 @@ export function waitForElementToExistAndSelect(selector, timeout = 0) {
})

if (timeout)
setTimeout(() => {
observer.disconnect()
resolve(null)
timeoutId = setTimeout(() => {
finish(null)
}, timeout)
})
}

export function waitForSiteAdapterElement(selector) {
return waitForElementToExistAndSelect(selector, 5_000)
}
160 changes: 160 additions & 0 deletions tests/unit/utils/wait-for-element-to-exist-and-select.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict'
import { afterEach, beforeEach, test } from 'node:test'
import {
waitForElementToExistAndSelect,
waitForSiteAdapterElement,
} from '../../../src/utils/wait-for-element-to-exist-and-select.mjs'

const originalDocument = globalThis.document
const originalMutationObserver = globalThis.MutationObserver

let matchingElement
let observers

class FakeMutationObserver {
constructor(callback) {
this.callback = callback
this.disconnected = false
observers.push(this)
}

observe() {}

disconnect() {
this.disconnected = true
}

trigger() {
this.callback()
}
}

beforeEach(() => {
matchingElement = null
observers = []
globalThis.document = {
body: {},
querySelector: () => matchingElement,
}
globalThis.MutationObserver = FakeMutationObserver
})

afterEach(() => {
globalThis.document = originalDocument
globalThis.MutationObserver = originalMutationObserver
})

test('waitForElementToExistAndSelect returns an existing element immediately', async () => {
matchingElement = { id: 'existing' }

assert.equal(await waitForElementToExistAndSelect('#target'), matchingElement)
assert.equal(observers.length, 0)
})

test('waitForElementToExistAndSelect resolves after a matching mutation', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const promise = waitForElementToExistAndSelect('#target', 100)
matchingElement = { id: 'inserted' }

observers[0].trigger()

assert.equal(await promise, matchingElement)
assert.equal(observers[0].disconnected, true)
})

test('waitForElementToExistAndSelect can wait indefinitely without a timeout', async () => {
const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = () => {
throw new Error('setTimeout should not be called')
}

try {
const promise = waitForElementToExistAndSelect('#target')
matchingElement = { id: 'inserted' }
observers[0].trigger()

assert.equal(await promise, matchingElement)
} finally {
globalThis.setTimeout = originalSetTimeout
}
})
Comment thread
PeterDaveHello marked this conversation as resolved.

test('waitForElementToExistAndSelect resolves null after its timeout', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const promise = waitForElementToExistAndSelect('#target', 100)

t.mock.timers.tick(100)

assert.equal(await promise, null)
assert.equal(observers[0].disconnected, true)
})

test('waitForElementToExistAndSelect clears a pending timeout after a match', async () => {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const timeoutId = { id: 'timeout' }
let clearedTimeout

globalThis.setTimeout = () => timeoutId
globalThis.clearTimeout = (id) => {
clearedTimeout = id
}

try {
const promise = waitForElementToExistAndSelect('#target', 100)
matchingElement = { id: 'inserted' }
observers[0].trigger()

assert.equal(await promise, matchingElement)
assert.equal(clearedTimeout, timeoutId)
} finally {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
}
})
Comment thread
PeterDaveHello marked this conversation as resolved.

test('site-adapter wait remains active after 5 ms and resolves from a later mutation', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let settled = false
const promise = waitForSiteAdapterElement('#target').then((element) => {
settled = true
return element
})

t.mock.timers.tick(5)
await Promise.resolve()
assert.equal(settled, false)

matchingElement = { id: 'inserted' }
observers[0].trigger()

assert.equal(await promise, matchingElement)
})

test('site-adapter wait accepts a compound selector for fallback containers', async () => {
const fallbackElement = { id: 'results' }
globalThis.document.querySelector = (selector) => {
assert.equal(selector, '.sidebar,#results')
return fallbackElement
}

assert.equal(await waitForSiteAdapterElement('.sidebar,#results'), fallbackElement)
assert.equal(observers.length, 0)
})

test('site-adapter wait times out after 5 seconds', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let settled = false
const promise = waitForSiteAdapterElement('#target').then((element) => {
settled = true
return element
})

t.mock.timers.tick(4_999)
await Promise.resolve()
assert.equal(settled, false)

t.mock.timers.tick(1)

assert.equal(await promise, null)
})