Skip to content

Commit

Permalink
chore: redesign network expects (#1096)
Browse files Browse the repository at this point in the history
  • Loading branch information
patrickhousley authored Jul 1, 2024
1 parent e2beeed commit 5372438
Show file tree
Hide file tree
Showing 15 changed files with 852 additions and 256 deletions.
248 changes: 154 additions & 94 deletions tests/specs/api.e2e.js

Large diffs are not rendered by default.

24 changes: 10 additions & 14 deletions tests/specs/csp.e2e.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { notIE } from '../../tools/browser-matcher/common-matchers.mjs'
import { faker } from '@faker-js/faker'
import { testSupportMetricsRequest } from '../../tools/testing-server/utils/expect-tests'

describe.withBrowsersMatching(notIE)('Content Security Policy', () => {
afterEach(async () => {
Expand All @@ -8,11 +9,8 @@ describe.withBrowsersMatching(notIE)('Content Security Policy', () => {

it('should support a nonce script element', async () => {
const nonce = faker.string.uuid()
await Promise.all([
browser.testHandle.expectRum(),
browser.url(await browser.testHandle.assetURL('instrumented.html', { nonce }))
.then(() => browser.waitForAgentLoad())
])
await browser.url(await browser.testHandle.assetURL('instrumented.html', { nonce }))
.then(() => browser.waitForAgentLoad())

const foundNonces = await browser.execute(function () {
var scriptTags = document.querySelectorAll('script')
Expand All @@ -30,16 +28,19 @@ describe.withBrowsersMatching(notIE)('Content Security Policy', () => {
})

it.withBrowsersMatching(notIE)('should send a nonce supportability metric', async () => {
const supportMetricsCapture = await browser.testHandle.createNetworkCaptures('bamServer', {
test: testSupportMetricsRequest
})
const nonce = faker.string.uuid()
await browser.url(await browser.testHandle.assetURL('instrumented.html', { nonce }))
.then(() => browser.waitForAgentLoad())

const [unloadSupportMetricsResults] = await Promise.all([
browser.testHandle.expectSupportMetrics(),
supportMetricsCapture.waitForResult({ totalCount: 1 }),
await browser.url(await browser.testHandle.assetURL('/')) // Setup expects before navigating
])

const supportabilityMetrics = unloadSupportMetricsResults.request.body.sm || []
const supportabilityMetrics = unloadSupportMetricsResults[0].request.body.sm || []
expect(supportabilityMetrics).toEqual(expect.arrayContaining([{
params: { name: 'Generic/Runtime/Nonce/Detected' },
stats: { c: expect.toBeWithin(1, Infinity) }
Expand All @@ -49,17 +50,12 @@ describe.withBrowsersMatching(notIE)('Content Security Policy', () => {
it('should load async chunk with subresource integrity', async () => {
await browser.enableSessionReplay()

const url = await browser.testHandle.assetURL('subresource-integrity-capture.html', {
await browser.url(await browser.testHandle.assetURL('subresource-integrity-capture.html', {
init: {
privacy: { cookies_enabled: true },
session_replay: { enabled: true }
}
})
await Promise.all([
browser.testHandle.expectRum(),
browser.url(url)
.then(() => browser.waitForAgentLoad())
])
})).then(() => browser.waitForAgentLoad())

await browser.waitUntil(
() => browser.execute(function () {
Expand Down
4 changes: 2 additions & 2 deletions tools/browsers-lists/lt-desktop-latest-vers.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"chrome": 125,
"chrome": 126,
"edge": 126,
"firefox": 126,
"firefox": 127,
"safari": 17,
"safari_min": 16
}
8 changes: 4 additions & 4 deletions tools/browsers-lists/lt-mobile-supported.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
{
"ios": [
{
"device_name": "iPad (9th generation)",
"device_name": "iPhone 15",
"version": "17.2",
"platformName": "ios"
},
{
"device_name": "iPhone SE (3rd generation)",
"device_name": "iPhone 11",
"version": "16.0",
"platformName": "ios"
}
],
"android": [
{
"device_name": "ASUS ZenFone 8",
"device_name": "Pixel 7",
"version": "14",
"platformName": "android"
},
{
"device_name": "Galaxy S10",
"device_name": "Pixel 3",
"version": "9",
"platformName": "android"
}
Expand Down
8 changes: 8 additions & 0 deletions tools/browsers-lists/lt-update-supported.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ function updateMobileVersions (mobilePlatforms) {
const testedMobileVersionsJson = {}

const iosDevices = mobilePlatforms.find(p => p.platform === 'ios')?.devices
.filter(device => /^iPhone \d{2,}$/.test(device.device_name))
.sort((deviceA, deviceB) =>
Number.parseInt(deviceB.device_name.match(/^iPhone (\d{2,})$/)[1]) - Number.parseInt(deviceA.device_name.match(/^iPhone (\d{2,})$/)[1])
)
const androidDevices = mobilePlatforms.find(p => p.platform === 'android')?.devices
.filter(device => /^Pixel \d{1,}$/.test(device.device_name))
.sort((deviceA, deviceB) =>
Number.parseInt(deviceB.device_name.match(/^Pixel (\d{1,})$/)[1]) - Number.parseInt(deviceA.device_name.match(/^Pixel (\d{1,})$/)[1])
)
if (!iosDevices || !androidDevices) throw new Error('iOS or Android mobile could not be found in API response.')

// iOS versions should already be sorted in descending; the built list should also be in desc order.
Expand Down
4 changes: 2 additions & 2 deletions tools/testing-server/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ module.exports.loaderConfigKeys = [
module.exports.loaderOnlyConfigKeys = ['accountID', 'agentID', 'trustKey']

module.exports.rumFlags = {
loaded: 1, // Used internally to signal the tests that the agent has loaded

st: 1, // session trace entitlements 0|1
err: 1, // err entitlements 0|1
ins: 1, // ins entitlements 0|1
cap: 1, // ?
spa: 1, // spa entitlements 0|1
loaded: 1,
sr: 1, // session replay entitlements 0|1
sts: 1, // session trace sampling 0|1|2 - off full error
srs: 1, // session replay sampling 0|1|2 - off full error
Expand Down
1 change: 1 addition & 0 deletions tools/testing-server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ class TestServer {
}

destroyTestHandle (testId) {
this.#testHandles.get(testId).destroy()
this.#testHandles.delete(testId)
}

Expand Down
237 changes: 237 additions & 0 deletions tools/testing-server/network-capture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
const crypto = require('crypto')

/**
* A function used to check if a fastify server request should be captured.
* @typedef {Function} NetworkCaptureTestFn
* @param {import('./test-handle')} this a reference to the testHandle
* @param {import('fastify').FastifyRequest} request a reference to the fastify request object
* @returns {boolean} true if the network request should be captured
*/

/**
* Network capture options
* @typedef {object} NetworkCaptureOptions
* @property {NetworkCaptureTestFn} test function that takes the fastify request object and returns true if the expect should
* be resolved
*/

/**
* Serialized network capture
* @typedef {object} SerializedNetworkCapture
* @property {object} request object containing data extracted from the fastify request
* @property {any} request.body a copy of the request body
* @property {object} request.query a set of K/V pairs containing all the query parameters from the URI
* @property {object} request.headers a set of K/V pairs containing all the headers from the request
* @property {string} request.method the HTTP method used to make the request
* @property {object} reply object containing data extracted from the fastify reply
* @property {number} reply.statusCode status code the server responded with for the request
* @property {number} reply.headers a set of K/V pairs containing all the headers from the reply
* @property {number} reply.body a copy of the reply body if the reply was not a static asset
*/

/**
* Conditions to pause execution based on requests being captured.
* @typedef {object} NetworkCaptureWaitConditions
* @property {number} timeout a predefined time to wait before continuing execution
* @property {number} totalCount a predefined number of requests to wait on before continuing execution
* @example If both timeout and totalCount are supplied, when the timeout is reached, the pause will end regardless
* of the number of network captures and the supplied totalCount condition.
* @example If both timeout and totalCount are supplied, when the totalCount of network captures reached the supplied
* totalCount condition, the pause will end regardless of the timeout.
*/

/**
* Deferred object
* @typedef {object} Deferred
* @property {Promise<SerializedNetworkCapture[]>} promise the underlying promise of the deferred object
* @property {Function} resolve the resolve function of the deferred object
* @property {Function} reject the reject function of the deferred object
* @property {number} [timeout] the pending timeout for the deferred object
* @property {() => void} checkWaitConditions a function used to force the wait conditions to be evaluated. If
* the conditions pass, the wait will be ended and execution will continue
*/

module.exports = class NetworkCapture {
#instanceId = crypto.randomUUID()

/**
* Cache of capture requests and replies
* @type {Set<SerializedNetworkCapture>}
*/
#captureCache = new Set()

/**
* Cache of deferred requests waiting on some capture conditions.
* @type {Set<Deferred>}
*/
#deferredCache = new Set()

/**
* The reference back to the wrapping test handle.
* @type {import('./test-handle')}
*/
#testHandle

/**
* The test function for this network capture
* @type {NetworkCaptureTestFn}
*/
#test

/**
* Creates a new instance of a network capture.
* @param {import('./test-handle')} testHandle
* @param {NetworkCaptureOptions} options
*/
constructor (testHandle, options) {
if (!options) {
throw new Error('Options must be supplied when creating a network capture.')
}
if (typeof options.test !== 'function') {
throw new Error('A test function must be supplied in the network capture options.')
}

this.#testHandle = testHandle
this.#test = options.test
}

/**
* A unique id to identify this specific network capture. This should be
* used when interacting with this network capture from the testing platform.
* @return {UUID}
*/
get instanceId () {
return this.#instanceId
}

/**
* Exposes all the current captures this network capture has saved.
* @return {SerializedNetworkCapture[]}
*/
get captures () {
return Array.from(this.#captureCache)
}

/**
* Provides the results of executing the network capture test function passing in
* the test server as the `this` context and the fastify request as the parameter.
* @param {import('fastify').FastifyRequest} request
* @return {boolean} True if the fastify request matches the requirements of the test
* function.
*/
test (request) {
return this.#test.call(this.#testHandle, request)
}

/**
* Captured a network request and reply.
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
* @param {any} payload
*/
capture (request, reply, payload) {
this.#captureCache.add({
request: {
body: request.body,
query: request.query,
headers: request.headers,
method: request.method.toUpperCase()
},
reply: {
statusCode: reply.statusCode,
headers: reply.getHeaders(),
body: request.url.startsWith('/tests/assets/') || request.url.startsWith('/build/')
? 'Asset content'
: payload
}
})

for (const deferred of this.#deferredCache) {
deferred.checkWaitConditions()
}
}

/**
* Clears the cached of network captures.
* @returns {void}
*/
clear () {
this.#captureCache = new Set()
}

/**
* Destroy all memory references to allow for garbage collection.
* @returns {void}
*/
destroy () {
for (const deferred of this.#deferredCache) {
if (deferred.timeout) {
clearTimeout(deferred.timeout)
}
if (deferred.promise) {
deferred.reject('Waiting network capture destroyed before resolving')
}
}
this.#deferredCache.clear()
this.#deferredCache = null

this.#captureCache.clear()
this.#captureCache = null

this.#instanceId = null
this.#testHandle = null
this.#test = null
}

/**
* Returns a promise that will resolve with the current capture cache once the provided
* conditions have been met.
* @param {NetworkCaptureWaitConditions} waitConditions Conditions to pause execution
* @return {Promise<SerializedNetworkCapture[]>}
*/
waitFor (waitConditions) {
return this.#createDeferred(waitConditions).promise
}

/**
* Creates a basic deferred object
* @param {NetworkCaptureWaitConditions} waitConditions The conditions that, once met, indicate the
* deferred object can be resolved.
* @returns {Deferred}
*/
#createDeferred (waitConditions) {
let capturedResolve
let capturedReject
let promise = new Promise((resolve, reject) => {
capturedResolve = resolve
capturedReject = reject
})

/**
* @type {Partial<Deferred>}
*/
const deferred = { promise, resolve: capturedResolve, reject: capturedReject }
deferred.checkWaitConditions = () => {
if (typeof waitConditions.totalCount === 'number' && this.#captureCache.size >= waitConditions.totalCount) {
capturedResolve(this.captures)
}
}

if (typeof waitConditions.timeout === 'number' && waitConditions.timeout > 0) {
deferred.timeout = setTimeout(() => {
capturedResolve(this.captures)
}, waitConditions.timeout)
}
promise.finally(() => {
if (deferred.timeout) {
clearTimeout(deferred.timeout)
}

this.#deferredCache?.delete(deferred)
})

deferred.checkWaitConditions()
this.#deferredCache.add(deferred)
return deferred
}
}
Loading

0 comments on commit 5372438

Please sign in to comment.