diff --git a/jest-puppeteer.config.js b/jest-puppeteer.config.js new file mode 100644 index 0000000..46dcc31 --- /dev/null +++ b/jest-puppeteer.config.js @@ -0,0 +1,8 @@ +const isDebug = process.env.PPTR_DEBUG === "true"; + +module.exports = { + launch: { + headless: !isDebug, + devtools: isDebug + } +}; diff --git a/package-lock.json b/package-lock.json index ad2a6bb..4f28d12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@rollup/plugin-terser": "^0.4.3", "@types/jest": "^29.0.0", "babel-jest": "^29.6.2", + "cross-env": "^7.0.3", "html-minifier": "^4.0.0", "husky": "^8.0.3", "jest": "^29.6.2", @@ -4199,6 +4200,24 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -11069,6 +11088,7 @@ "license": "MIT", "dependencies": { "classcat": "^4.1.0", + "mitt": "^2.1.0", "preact": "^10.4.4", "preact-router": "^3.2.1", "prismjs": "^1.23.0", @@ -11451,6 +11471,11 @@ "node": ">=10" } }, + "scripts/node_modules/mitt": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz", + "integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==" + }, "scripts/node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -14897,6 +14922,15 @@ "browserslist": "^4.21.9" } }, + "cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.1" + } + }, "cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -18981,6 +19015,7 @@ "classcat": "^4.1.0", "cssnano": "^6.0.0", "gzip-size": "^7.0.0", + "mitt": "^2.1.0", "nodemon": "^3.0.0", "postcss": "^8.0.0", "postcss-reporter": "^7.0.5", @@ -19233,6 +19268,11 @@ "brace-expansion": "^2.0.1" } }, + "mitt": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz", + "integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==" + }, "nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", diff --git a/package.json b/package.json index 8609817..b876d4a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "node ./scripts/dev.js", "serve": "sirv --dev dist", "test": "jest", + "test:debug": "cross-env PPTR_DEBUG=true jest --runInBand", "prepare": "husky install", "lint-staged": "lint-staged", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,html,vue}\"" @@ -39,6 +40,7 @@ "@rollup/plugin-terser": "^0.4.3", "@types/jest": "^29.0.0", "babel-jest": "^29.6.2", + "cross-env": "^7.0.3", "html-minifier": "^4.0.0", "husky": "^8.0.3", "jest": "^29.6.2", diff --git a/scripts/bundles/controls/jsx.d.ts b/scripts/bundles/controls/jsx.d.ts new file mode 100644 index 0000000..5a168ba --- /dev/null +++ b/scripts/bundles/controls/jsx.d.ts @@ -0,0 +1,27 @@ +type Children = undefined | string | JSX.Element | Array; + +interface JSXAttributes { + children?: Children; + + // Attributes that this JSX allows + class?: string; + for?: string; + id?: string; +} + +type JSXHTMLElement = Partial>; + +type HTMLElementsMap = { + [K in keyof HTMLElementTagNameMap]: JSXHTMLElement & JSXAttributes; +}; + +declare namespace JSX { + interface Element extends HTMLElement {} + interface ElementChildrenAttribute { + children?: Children; + } + interface IntrinsicElements extends HTMLElementsMap { + // Custom elements + "draggable-dialog": JSXAttributes; + } +} diff --git a/scripts/bundles/controls/mockFetch.d.ts b/scripts/bundles/controls/mockFetch.d.ts new file mode 100644 index 0000000..332ab14 --- /dev/null +++ b/scripts/bundles/controls/mockFetch.d.ts @@ -0,0 +1,51 @@ +interface Timer { + expiresAt: number; + timeoutId: number; +} + +type RequestId = string; + +interface Request { + id: RequestId; + /** When this request should resolve. If null, request is paused and not scheduled to complete */ + expiresAt: number | null; + /** Total time this request should wait */ + duration: number; + /** Tracks how much time of duration has elapsed when a request is paused/resumed */ + elapsedTime: number; + /** Display name of request */ + name: string; + url: string; + options: RequestInit; + promise: Promise; + resolve: () => void; + reject: () => void; +} + +type MockFetchEventType = "update"; + +interface Config { + durationMs: number; + areNewRequestsPaused: boolean; + mode: "auto" | "manual"; + timer: Timer | null; + requests: Map; + newId(): string; + pause(id: RequestId): void; + resume(id: RequestId): void; + on(type: MockFetchEventType, handler: () => void): void; + off(type: MockFetchEventType, handler: () => void): void; + log(...msg: any[]): void; + _emit(type: MockFetchEventType): void; +} + +declare class MockFetchDebugger extends HTMLElement { + config: Config; + show: boolean; + dialog: boolean; +} + +export function createMockFetchConfig(): Config; +export function createMockFetch( + config: Config +): (url: string, options?: RequestInit) => Promise; diff --git a/scripts/bundles/controls/mockFetch.js b/scripts/bundles/controls/mockFetch.js index ad2ffd7..0b74e53 100644 --- a/scripts/bundles/controls/mockFetch.js +++ b/scripts/bundles/controls/mockFetch.js @@ -1,64 +1,33 @@ -/** - * @typedef Timer - * @property {number} expiresAt - * @property {number} timeoutId - */ - -/** - * @typedef {string} RequestId - * - * @typedef Request - * @property {RequestId} id - * @property {number | null} expiresAt When this request should resolve. If - * null, request is paused and not scheduled to complete - * @property {number} duration Total time this request should wait - * @property {number | null} elapsedTime Tracks how much time of duration has - * elapsed when a request is paused/resumed - * @property {string} name Display name of request - * @property {string} url - * @property {RequestInit} options - * @property {Promise} promise - * @property {() => void} resolve - * @property {() => void} reject - */ - -/** - * @typedef Config - * @property {number} durationMs - * @property {boolean} areNewRequestsPaused - * @property {() => string} newId - * @property {Timer | null} timer - * @property {Map} requests - * @property {(id: RequestId) => void} pause - * @property {(id: RequestId) => void} resume - * @property {(...msg: any[]) => void} log - */ +import mitt from "mitt"; -/** @returns {Config} */ +/** @returns {import('./mockFetch').Config} */ export function createMockFetchConfig() { let id = 0; + /** @type {'auto' | 'manual'} */ + let currentMode = "auto"; + + const events = mitt(); + return { durationMs: 3 * 1000, areNewRequestsPaused: false, - newId: () => `${++id}`, + get mode() { + return currentMode; + }, + set mode(newMode) { + if (newMode !== currentMode) { + if (newMode !== "auto" && newMode !== "manual") { + throw new Error(`Unsupported mockFetch mode: ${newMode}.`); + } + + currentMode = newMode; + scheduleUpdate(this); + } + }, - // TODO: Build request editing module - // - // Normal mode: - // - // Only keep one timeout for when the next request will need to resolved. When - // it expires, loop through all requests and resolve all that have completed. - // When a new request arrives, check if it will expire before the current - // timeout. If so, replace the current timeout with a new one for the new - // request. - // - // Interactive mode: - // - // If the UI control is displayed, instead of relying to timers to resolve - // requests, we will run a animation loop to animate the UI control and expire - // requests in progress. + newId: () => `${++id}`, timer: null, @@ -78,6 +47,15 @@ export function createMockFetchConfig() { request.elapsedTime = request.duration - (request.expiresAt - now); request.expiresAt = null; + + // Reset the timer if necessary + if (this.mode == "auto") { + // Ensure timer is properly set with the request with the next expiration + // which could be the request we just updated + resolveRequests(this, now); + } + + this._emit("update"); }, resume(id) { @@ -94,19 +72,40 @@ export function createMockFetchConfig() { const remainingTime = request.duration - request.elapsedTime; request.expiresAt = now + remainingTime; - // Ensure timer is properly set with the request with the next expiration - // which could be the request we just updated - resolveRequests(this); + if (this.mode == "auto") { + // Ensure timer is properly set with the request with the next expiration + // which could be the request we just updated + resolveRequests(this, now); + } + + this._emit("update"); + }, + + on(type, handler) { + events.on(type, handler); + }, + + off(type, handler) { + events.off(type, handler); }, - log(...msg) {} + _emit(type) { + events.emit(type); + }, + + log() { + // By default, log nothing? + } }; } /** - * @param {Config} config + * @param {import('./mockFetch').Config} config + * @returns {(url: string, options?: RequestInit) => Promise} */ export function createMockFetch(config) { + // Actual fetch signature: + // declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise; /** * @param {string} url Mock URL * @param {RequestInit} [options] Mock request options @@ -115,7 +114,9 @@ export function createMockFetch(config) { function mockFetch(url, options = { method: "GET" }) { const name = `${options.method} ${url}`; - let resolver, rejecter; + let resolver = () => {}; + let rejecter = () => {}; + /** @type {Promise} */ const promise = new Promise((resolve, reject) => { resolver = () => { config.log(`Resolving ${name}`); @@ -129,7 +130,7 @@ export function createMockFetch(config) { // }; }); - /** @type {Request} */ + /** @type {import('./mockFetch').Request} */ const request = { id: config.newId(), duration: config.durationMs, @@ -146,17 +147,11 @@ export function createMockFetch(config) { config.requests.set(request.id, request); if (!config.areNewRequestsPaused) { - const now = Date.now(); - const expiresAt = now + request.duration; - request.expiresAt = expiresAt; - - // If there is no timer or this request finishes faster than the current - // timer, setup a new timer - if (config.timer == null || request.expiresAt < config.timer.expiresAt) { - setTimer(config, request, now); - } + request.expiresAt = Date.now() + request.duration; + scheduleUpdate(config); } + config._emit("update"); return promise; } @@ -164,32 +159,64 @@ export function createMockFetch(config) { } /** - * @param {Config} config - * @param {Request} request - * @param {number} now + * @param {import('./mockFetch').Config} config + */ +function scheduleUpdate(config) { + if (config.requests.size > 0 && config.mode == "auto") { + setTimer(config); + } +} + +/** + * @param {import('./mockFetch').Config} config */ -function setTimer(config, request, now) { - if (config.timer) { +function setTimer(config) { + /** @type {number | null} The expiration time of the request that will expire soonest */ + let nextExpiration = null; + for (let request of config.requests.values()) { + if ( + request.expiresAt != null && + (nextExpiration == null || request.expiresAt < nextExpiration) + ) { + nextExpiration = request.expiresAt; + } + } + + if ( + config.timer && + (nextExpiration == null || nextExpiration !== config.timer.expiresAt) + ) { + // If there is an existing timer, and no next request or the timer expires a + // different time than the next request, clear the exiting timer. window.clearTimeout(config.timer.timeoutId); config.timer = null; } - const timeoutId = window.setTimeout( - () => resolveRequests(config), - request.expiresAt - now - ); - config.timer = { timeoutId, expiresAt: request.expiresAt }; + if ( + nextExpiration == null || + (config.timer && nextExpiration === config.timer.expiresAt) + ) { + return; + } + + const timeout = nextExpiration - Date.now(); + const timeoutId = window.setTimeout(() => { + config.timer = null; + resolveRequests(config, Date.now()); + config._emit("update"); + }, timeout); + config.timer = { timeoutId, expiresAt: nextExpiration }; } /** - * @param {Config} config + * `now` is a paramter to assist in debugging so that time doesn't continue when + * debugging. If it did, requests could "expire" while stepping through code + * @param {import('./mockFetch').Config} config + * @param {number} now */ -function resolveRequests(config) { - const now = Date.now(); +function resolveRequests(config, now) { const toRemove = []; - /** @type {Request} Request with the next expiration */ - let nextRequest = null; for (let [id, request] of config.requests.entries()) { if (request.expiresAt == null) { continue; @@ -198,11 +225,6 @@ function resolveRequests(config) { // then go ahead and resolve it request.resolve(); toRemove.push(id); - } else if ( - nextRequest == null || - request.expiresAt < nextRequest.expiresAt - ) { - nextRequest = request; } } @@ -210,9 +232,5 @@ function resolveRequests(config) { config.requests.delete(id); } - if (nextRequest) { - setTimer(config, nextRequest, now); - } else { - config.timer = null; - } + scheduleUpdate(config); } diff --git a/scripts/bundles/controls/mockFetchDebugger.js b/scripts/bundles/controls/mockFetchDebugger.js new file mode 100644 index 0000000..394ced1 --- /dev/null +++ b/scripts/bundles/controls/mockFetchDebugger.js @@ -0,0 +1,545 @@ +/// + +/** @jsx h */ + +const hasOwn = Object.prototype.hasOwnProperty; + +/** + * @param {string} tag + * @param {Record} attributes + * @param {Array} children + * @returns {HTMLElement} + */ +function h(tag, attributes, ...children) { + const element = document.createElement(tag); + + for (let attr in attributes) { + if (hasOwn.call(attributes, attr)) { + if (attr in element) { + element[attr] = attributes[attr]; + } else { + element.setAttribute(attr, attributes[attr]); + } + } + } + + for (let child of children) { + if (typeof child == "string") { + element.appendChild(document.createTextNode(child)); + } else { + element.appendChild(child); + } + } + + return element; +} + +class DraggableDialog extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + const style = document.createElement("style"); + style.innerHTML = ` + :host { + display: block; + position: fixed; + top: 0; + left: 0; + z-index: 9999; + + border: 1px solid black; + border-radius: 8px; + background-color: white; + + /* https://getcssscan.com/css-box-shadow-examples */ + box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px; + } + + :host([hidden]) { display: none; } + + button { + display: inline-block; + border: 0; + padding: 0; + background: none; + font-size: inherit; + font-family: -apple-system,system-ui,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,sans-serif; + } + + .drag-handle { + display: block; + width: 100%; + height: 1.1rem; + text-align: center; + cursor: move; + border-radius: 8px 8px 0 0; + } + + .drag-handle:hover, + .drag-handle.moving { + background-color: #ccc; + } + + .drag-handle-icon { + display: inline-block; + transform: rotate(90deg); + } + + `; + this.shadowRoot.appendChild(style); + + const body = ( + + ); + + this.shadowRoot.appendChild(body); + this.shadowRoot.appendChild(document.createElement("slot")); + } + + connectedCallback() { + const defaultX = window.innerWidth - 200 - 24; + const defaultY = 24; + let { x: translateX, y: translateY } = this.#getTransform(); + + const host = /** @type {HTMLElement} */ (this.shadowRoot.host); + // If the transform value isn't set or if the dialog is positioned off + // screen due to a screen resize, reconnecting the dialog should reset it's + // position + if ( + host.style.transform == "" || + translateX + 24 > window.innerWidth || + translateY + 24 > window.innerHeight + ) { + this.#setTransform(defaultX, defaultY); + } + } + + /** @param {PointerEvent} initialEvent */ + #onInitializeMove(initialEvent) { + initialEvent.preventDefault(); + + const dragHandle = this.shadowRoot.querySelector(".drag-handle"); + dragHandle.classList.add("moving"); + + const prevCursor = document.body.style.cursor; + document.body.style.cursor = "move"; + + let prevClientX = initialEvent.clientX; + let prevClientY = initialEvent.clientY; + let { x: prevTranslateX, y: prevTranslateY } = this.#getTransform(); + + /** @param {PointerEvent} moveEvent */ + const onMove = moveEvent => { + moveEvent.preventDefault(); + + let moveX = moveEvent.clientX - prevClientX; + let moveY = moveEvent.clientY - prevClientY; + + let newTranslateX = prevTranslateX + moveX; + let newTranslateY = prevTranslateY + moveY; + + if ( + // Outside bottom/right edge + moveEvent.clientX + 24 < window.innerWidth && + newTranslateY + 24 < window.innerHeight && + // Outside top/left edge + moveEvent.clientX - 24 > 0 && + newTranslateY > 0 + ) { + this.#setTransform(newTranslateX, newTranslateY); + } + + prevClientX = moveEvent.clientX; + prevClientY = moveEvent.clientY; + prevTranslateX = newTranslateX; + prevTranslateY = newTranslateY; + }; + + const onMoveEnd = () => { + document.body.style.cursor = prevCursor; + this.shadowRoot.querySelector(".drag-handle").classList.remove("moving"); + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onMoveEnd); + }; + + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onMoveEnd); + } + + #getTransform() { + const host = /** @type {HTMLElement} */ (this); + const transform = host.style.transform; + + const match = transform.match( + /translate3d\((-?[0-9.]+)px, (-?[0-9.]+)px, 0px\)/ + ); + if (match) { + return { + x: parseInt(match[1], 10), + y: parseInt(match[2], 10) + }; + } else { + return { x: 0, y: 0 }; + } + } + + /** @param {number} x @param {number} y */ + #setTransform(x, y) { + const host = /** @type {HTMLElement} */ (this); + host.style.transform = `translate3d(${x}px, ${y}px, 0)`; + } +} + +const fadeOutDuration = 7000; +function afterNextFrame(cb) { + requestAnimationFrame(() => requestAnimationFrame(cb)); +} + +class MockFetchDebugger extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + /** @type {import('./mockFetch').Config} */ + this._config = null; + + const style = document.createElement("style"); + style.innerHTML = ` + :host { + display: block; + padding: 0.125rem 0.5rem 1.1rem 0.5rem + } + + :host([hidden]) { display: none; } + + button { + display: inline-block; + border: 0; + padding: 0; + background: none; + font-size: inherit; + font-family: -apple-system,system-ui,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,sans-serif; + } + + label { + display: block; + } + + input#latency { + display: block; + width: 100%; + } + + h2 { + font-size: 20px; + margin: 0; + margin-top: 0.5rem; + } + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + ul:empty::after { + content: "(Empty)"; + display: block; + } + + #inflight .request { + display: grid; + margin: 0.15rem 0; + } + + #inflight .request-btn { + display: flex; + grid-row: 1; + grid-column: 1; + padding: 0 4px; + text-align: left; + cursor: pointer; + } + + #inflight .request-label { + margin-right: auto; + } + + #inflight .status { + font-family: Segoe UI Symbol,-apple-system,system-ui,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,sans-serif; + } + + #inflight progress { + display: block; + height: 100%; + width: 100%; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border: none; + grid-row: 1; + grid-column: 1; + } + + #inflight progress::-webkit-progress-bar { + background-color: #eee; + /* border-radius: 2px; */ + /* box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25) inset; */ + } + + #inflight progress::-webkit-progress-value { + background-color: lightblue; + border-radius: 2px; + background-size: 35px 20px, 100% 100%, 100% 100%; + } + + #inflight progress::-moz-progress-bar { + background-color: lightblue; + border-radius: 2px; + background-size: 35px 20px, 100% 100%, 100% 100%; + } + + #completed li { + transition: opacity 3s ease-in ${fadeOutDuration}ms; + opacity: 1; + } + `; + this.shadowRoot.appendChild(style); + + const body = [ + , +
+ this.updateLatency()} + /> +
, + , +

Inflight

, +
    , +

    Recently done

    , +
      + ]; + + body.forEach(child => this.shadowRoot.appendChild(child)); + this.update(); + } + + get config() { + return this._config; + } + set config(newConfig) { + if (this._config) { + // Reset old config to 'auto' + this._config.mode = "auto"; + } + + newConfig.mode = "manual"; + newConfig.on("update", () => this.update()); + + // @ts-ignore + this.shadowRoot.getElementById("latency").valueAsNumber = + newConfig.durationMs; + + // @ts-ignore + this.shadowRoot.getElementById("pause-new").checked = + newConfig.areNewRequestsPaused; + + this._config = newConfig; + + requestAnimationFrame(() => { + this.update(); + this.updateLatency(); + }); + } + + connectedCallback() { + this.update(); + } + + disconnectedCallback() { + this.update(); + } + + onToggleRequest(request) { + if (request.expiresAt == null) { + this.config.resume(request.id); + } else { + this.config.pause(request.id); + } + } + + updateLatency() { + /** @type {HTMLInputElement} */ + // @ts-expect-error + const latency = this.shadowRoot.getElementById("latency"); + this.config.durationMs = latency.valueAsNumber; + const latencySec = (latency.valueAsNumber / 1000).toFixed(1); + const latencyLabel = this.shadowRoot.getElementById("latency-label"); + const pluralEnding = latencySec == "1.0" ? "" : "s"; + latencyLabel.textContent = `${latencySec} second${pluralEnding}`; + } + + update() { + if (!this.isConnected || !this.config) { + return; + } + + const requests = this.config.requests; + if (requests.size == 0) { + return; + } + + /** @type {import("./mockFetch").Request[]} */ + const finished = []; + const now = Date.now(); + let isRunning = false; // Track if any requests are running + + // Update requests already in list + const inflightList = this.shadowRoot.getElementById("inflight"); + for (const listItem of Array.from(inflightList.children)) { + const requestId = listItem.getAttribute("data-req-id"); + if (requests.has(requestId)) { + const request = requests.get(requestId); + const isPaused = request.expiresAt == null; + + /** @type {HTMLElement} */ + const btn = listItem.querySelector(".request-btn"); + const progress = listItem.querySelector("progress"); + + if (btn.getAttribute("data-paused") !== isPaused.toString()) { + btn.setAttribute("data-paused", isPaused.toString()); + + if (isPaused) { + btn.title = "Resume request"; + btn.setAttribute("aria-label", `Resume request ${request.url}`); + btn.querySelector(".status").textContent = "▶"; + } else { + btn.title = "Pause request"; + btn.setAttribute("aria-label", `Pause request ${request.url}`); + btn.querySelector(".status").textContent = "⏸"; + } + } + + if (!isPaused) { + isRunning = true; + const timeLeft = request.expiresAt - now; + if (timeLeft < 16) { + // If this request will expire within 16 ms of now (or has already + // expired) then go ahead and mark it as finished + finished.push(request); + } else { + progress.value = + ((request.duration - timeLeft) / request.duration) * 100; + } + } + } else { + // Huh... shouldn't happen but let's go ahead and clean up the UI + listItem.remove(); + } + } + + // Add new requests + for (const request of requests.values()) { + const isPaused = request.expiresAt == null; + let existingItem = this.shadowRoot.querySelector( + `[data-req-id="${request.id}"]` + ); + + if (!existingItem) { + if (!isPaused) { + isRunning = true; + } + + inflightList.appendChild( +
    • + + +
    • + ); + } + } + + // Move finished requests + /** @type {JSX.Element[]} */ + const finishedItems = []; + const completedList = this.shadowRoot.getElementById("completed"); + for (let request of finished) { + this.shadowRoot.querySelector(`[data-req-id="${request.id}"]`).remove(); + request.resolve(); + requests.delete(request.id); + + const newItem = ( + // @ts-expect-error +
    • event.target.remove()}>{request.url}
    • + ); + + finishedItems.push(newItem); + completedList.appendChild(newItem); + } + + if (finishedItems.length) { + // Firefox requires at least one frame of 100% opacity before it will + // trigger the transition to 0 opacity + afterNextFrame(() => + finishedItems.forEach(li => (li.style.opacity = "0")) + ); + // If the debugger is hidden during the transition, the transition will + // cancel and the elements will never be removed from the DOM. So we'll go + // ahead and remove them ourselves after the transition is complete. + setTimeout(() => { + finishedItems.forEach(li => li.remove()); + }, fadeOutDuration + 100); + } + + if (isRunning) { + requestAnimationFrame(() => this.update()); + } + } +} + +export function installFetchDebugger() { + window.customElements.define("draggable-dialog", DraggableDialog); + window.customElements.define("mock-fetch-debugger", MockFetchDebugger); +} diff --git a/scripts/bundles/global.d.ts b/scripts/bundles/global.d.ts new file mode 100644 index 0000000..45ce49c --- /dev/null +++ b/scripts/bundles/global.d.ts @@ -0,0 +1,20 @@ +import { + Config, + createMockFetch, + createMockFetchConfig, + MockFetchDebugger +} from "./controls/mockFetch"; + +declare global { + interface HTMLElementTagNameMap { + "mock-fetch-debugger": MockFetchDebugger; + } + + interface Window { + createMockFetchConfig: typeof createMockFetchConfig; + createMockFetch: typeof createMockFetch; + mockFetchConfig: Config; + mockFetch: ReturnType; + fetchDebugger: MockFetchDebugger; + } +} diff --git a/scripts/bundles/jsconfig.json b/scripts/bundles/jsconfig.json new file mode 100644 index 0000000..dcd7a26 --- /dev/null +++ b/scripts/bundles/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "es2017", + "moduleResolution": "node", + "checkJs": true, + "jsx": "react", + "jsxFactory": "h" + }, + "include": ["**/*.js", "**/*.d.ts"] +} diff --git a/scripts/bundles/site.js b/scripts/bundles/site.js index 419ca6a..e099e4e 100644 --- a/scripts/bundles/site.js +++ b/scripts/bundles/site.js @@ -1,15 +1,43 @@ +/// + import { setupTabs } from "./controls/tabs"; import { setupToggle } from "./controls/toggle"; import { setupOffCanvas } from "./controls/offcanvas"; import { installPolyfill } from "./controls/details-polyfill"; import { createMockFetch, createMockFetchConfig } from "./controls/mockFetch"; +import { installFetchDebugger } from "./controls/mockFetchDebugger"; + +function setupFetchDebugger() { + installFetchDebugger(); + + window.createMockFetchConfig = createMockFetchConfig; + window.createMockFetch = createMockFetch; + + window.mockFetchConfig = createMockFetchConfig(); + window.mockFetchConfig.log = (...msgs) => console.log(...msgs); + + window.mockFetch = createMockFetch(window.mockFetchConfig); + + const fetchDebugger = document.createElement("mock-fetch-debugger"); + fetchDebugger.config = window.mockFetchConfig; + window.fetchDebugger = fetchDebugger; -window.mockFetchConfig = createMockFetchConfig(); -window.mockFetchConfig.log = (...msgs) => console.log(...msgs); + const draggableDialog = document.createElement("draggable-dialog"); + draggableDialog.appendChild(fetchDebugger); + if (!location.href.toLowerCase().includes("7guis-crud")) { + draggableDialog.setAttribute("hidden", ""); + } + document.body.appendChild(draggableDialog); -window.mockFetch = createMockFetch(window.mockFetchConfig); + window.addEventListener("keypress", e => { + if (e.key === "d") { + draggableDialog.toggleAttribute("hidden"); + } + }); +} installPolyfill(); setupTabs(); setupToggle(); setupOffCanvas(); +setupFetchDebugger(); diff --git a/scripts/package.json b/scripts/package.json index 1a593c4..03c93f6 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "classcat": "^4.1.0", + "mitt": "^2.1.0", "preact": "^10.4.4", "preact-router": "^3.2.1", "prismjs": "^1.23.0", diff --git a/tests/lib/mockFetch.test.js b/tests/lib/mockFetch.test.js index e1b2abc..3d2a3cc 100644 --- a/tests/lib/mockFetch.test.js +++ b/tests/lib/mockFetch.test.js @@ -14,12 +14,18 @@ describe("mockFetch library", () => { /** @type {ReturnType} */ let mockFetch; + /** @type {jest.Mock} */ + let updateCallback; + beforeEach(() => { jest.useFakeTimers("modern"); + updateCallback = jest.fn(); + // @ts-expect-error config = createMockFetchConfig(); config.log = jest.fn(); + config.on("update", updateCallback); mockFetch = createMockFetch(config); }); @@ -44,34 +50,41 @@ describe("mockFetch library", () => { it("should resolve two requests created closely together with same duration", async () => { const req1 = mockFetch("/test/req1").then(() => 1); + jest.advanceTimersByTime(1); const req2 = mockFetch("/test/req2").then(() => 2); + expect(updateCallback).toHaveBeenCalledTimes(2); jest.advanceTimersByTime(config.durationMs + 1); const [result1, result2] = await Promise.all([req1, req2]); expect(result1).toBe(1); expect(result2).toBe(2); + expect(updateCallback).toHaveBeenCalledTimes(3); }); it("should resolve two requests created at the same time with longer durations", async () => { // Create first request const req1Duration = config.durationMs; const req1 = mockFetch("/test/req1").then(() => 1); + expect(updateCallback).toHaveBeenCalledTimes(1); // Create second request with longer duration config.durationMs = req1Duration * 2; const req2 = mockFetch("/test/req2").then(() => 2); + expect(updateCallback).toHaveBeenCalledTimes(2); // Advance time enough such only first request should complete jest.advanceTimersByTime(req1Duration + 1); expect(config.log).toHaveBeenCalledTimes(1); expect(config.log).toHaveBeenNthCalledWith(1, "Resolving GET /test/req1"); + expect(updateCallback).toHaveBeenCalledTimes(3); expect(await req1).toBe(1); // Advance time by remaining time such that second request should complete jest.advanceTimersByTime(req1Duration + 1); expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /test/req2"); + expect(updateCallback).toHaveBeenCalledTimes(4); expect(await req2).toBe(2); }); @@ -79,31 +92,37 @@ describe("mockFetch library", () => { // Create first request const req1Duration = config.durationMs; const req1 = mockFetch("/test/req1").then(() => 1); + expect(updateCallback).toHaveBeenCalledTimes(1); // Create second request with shorter duration than first config.durationMs = req1Duration / 2; const req2 = mockFetch("/test/req2").then(() => 2); + expect(updateCallback).toHaveBeenCalledTimes(2); // Advance time such that only second request should complete jest.advanceTimersByTime(req1Duration / 2 + 1); expect(config.log).toHaveBeenCalledTimes(1); expect(config.log).toHaveBeenNthCalledWith(1, "Resolving GET /test/req2"); + expect(updateCallback).toHaveBeenCalledTimes(3); expect(await req2).toBe(2); // Advance time by remaining time such that first request should complete jest.advanceTimersByTime(req1Duration / 2 + 1); expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /test/req1"); + expect(updateCallback).toHaveBeenCalledTimes(4); expect(await req1).toBe(1); }); it("should resolve two requests created far apart", async () => { // Create initial request const req1 = mockFetch("/test/req1").then(() => 1); + expect(updateCallback).toHaveBeenCalledTimes(1); // Create another request some time later jest.advanceTimersByTime(config.durationMs / 2); const req2 = mockFetch("/test/req2").then(() => 2); + expect(updateCallback).toHaveBeenCalledTimes(2); // Advance time so that all requests should expire jest.advanceTimersByTime(config.durationMs + 1); @@ -111,12 +130,14 @@ describe("mockFetch library", () => { const [result1, result2] = await Promise.all([req1, req2]); expect(result1).toBe(1); expect(result2).toBe(2); + expect(updateCallback).toHaveBeenCalledTimes(4); }); it("should resolve three requests created far apart with varying durations", async () => { // Create first request const req1Duration = config.durationMs; const req1 = mockFetch("/test/req1").then(() => 1); + expect(updateCallback).toHaveBeenCalledTimes(1); // Create a second and third request some time later jest.advanceTimersByTime(req1Duration / 2); @@ -125,27 +146,32 @@ describe("mockFetch library", () => { const req2Duration = req1Duration * 2; config.durationMs = req2Duration; const req2 = mockFetch("/test/req2").then(() => 2); + expect(updateCallback).toHaveBeenCalledTimes(2); // Create third request with half duration than second config.durationMs = req2Duration / 2; const req3 = mockFetch("/test/req3").then(() => 3); + expect(updateCallback).toHaveBeenCalledTimes(3); // Advance time such that only first request should complete jest.advanceTimersByTime(req1Duration / 2 + 1); expect(config.log).toHaveBeenCalledTimes(1); expect(config.log).toHaveBeenNthCalledWith(1, "Resolving GET /test/req1"); + expect(updateCallback).toHaveBeenCalledTimes(4); expect(await req1).toBe(1); // Advance time such that only the third request should complete jest.advanceTimersByTime(req1Duration / 2 + 1); expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /test/req3"); + expect(updateCallback).toHaveBeenCalledTimes(5); expect(await req3).toBe(3); // Advance time by remaining time such that second request should complete jest.advanceTimersByTime(req1Duration + 1); expect(config.log).toHaveBeenCalledTimes(3); expect(config.log).toHaveBeenNthCalledWith(3, "Resolving GET /test/req2"); + expect(updateCallback).toHaveBeenCalledTimes(6); expect(await req2).toBe(2); }); @@ -153,6 +179,7 @@ describe("mockFetch library", () => { // Create first request const req1Duration = config.durationMs; const req1 = mockFetch("/test/req1").then(() => 1); + expect(updateCallback).toHaveBeenCalledTimes(1); // Create a second and third request some time later jest.advanceTimersByTime(req1Duration / 2); @@ -161,27 +188,32 @@ describe("mockFetch library", () => { const req2Duration = req1Duration; config.durationMs = req2Duration; const req2 = mockFetch("/test/req2").then(() => 2); + expect(updateCallback).toHaveBeenCalledTimes(2); // Create third request with half duration than second config.durationMs = req2Duration * 2; const req3 = mockFetch("/test/req3").then(() => 3); + expect(updateCallback).toHaveBeenCalledTimes(3); // Advance time such that only first request should complete jest.advanceTimersByTime(req1Duration / 2 + 1); expect(config.log).toHaveBeenCalledTimes(1); expect(config.log).toHaveBeenNthCalledWith(1, "Resolving GET /test/req1"); + expect(updateCallback).toHaveBeenCalledTimes(4); expect(await req1).toBe(1); // Advance time such that only the second request should complete jest.advanceTimersByTime(req1Duration / 2 + 1); expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /test/req2"); + expect(updateCallback).toHaveBeenCalledTimes(5); expect(await req2).toBe(2); // Advance time by remaining time such that third request should complete jest.advanceTimersByTime(req1Duration + 1); expect(config.log).toHaveBeenCalledTimes(3); expect(config.log).toHaveBeenNthCalledWith(3, "Resolving GET /test/req3"); + expect(updateCallback).toHaveBeenCalledTimes(6); expect(await req3).toBe(3); }); @@ -189,16 +221,23 @@ describe("mockFetch library", () => { it("should pause a request past previous duration and resume request with remaining duration", async () => { // Request: ==== ==== const req1 = mockFetch("/test/req1").then(() => 1); + expect(updateCallback).toHaveBeenCalledTimes(1); + config.pause("1"); + expect(updateCallback).toHaveBeenCalledTimes(2); jest.advanceTimersByTime(config.durationMs + 1); expect(config.log).not.toHaveBeenCalled(); + expect(updateCallback).toHaveBeenCalledTimes(2); config.resume("1"); + expect(updateCallback).toHaveBeenCalledTimes(3); + jest.advanceTimersByTime(config.durationMs + 1); expect(config.log).toHaveBeenCalledTimes(1); expect(config.log).toHaveBeenCalledWith("Resolving GET /test/req1"); + expect(updateCallback).toHaveBeenCalledTimes(4); expect(await req1).toBe(1); }); @@ -213,20 +252,29 @@ describe("mockFetch library", () => { it("calling pause twice still properly resumes", async () => { const req1 = mockFetch("/test/req1").then(() => 1); + expect(updateCallback).toHaveBeenCalledTimes(1); + config.pause("1"); + expect(updateCallback).toHaveBeenCalledTimes(2); jest.advanceTimersByTime(config.durationMs); config.pause("1"); expect(config.log).not.toHaveBeenCalled(); + expect(updateCallback).toHaveBeenCalledTimes(2); jest.advanceTimersByTime(config.durationMs); expect(config.log).not.toHaveBeenCalled(); + expect(updateCallback).toHaveBeenCalledTimes(2); config.resume("1"); + expect(updateCallback).toHaveBeenCalledTimes(3); + jest.advanceTimersByTime(config.durationMs + 1); + expect(updateCallback).toHaveBeenCalledTimes(4); expect(config.log).toHaveBeenCalledTimes(1); expect(config.log).toHaveBeenCalledWith("Resolving GET /test/req1"); + expect(updateCallback).toHaveBeenCalledTimes(4); expect(await req1).toBe(1); }); @@ -256,6 +304,8 @@ describe("mockFetch library", () => { expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenCalledWith("Resolving GET /test/req2"); expect(await req2).toBe(2); + + expect(updateCallback).toHaveBeenCalledTimes(6); }); it("immediate pause should not block new requests", async () => { @@ -278,6 +328,8 @@ describe("mockFetch library", () => { expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenCalledWith("Resolving GET /test/req1"); expect(await req1).toBe(1); + + expect(updateCallback).toHaveBeenCalledTimes(6); }); it("request1 pauses before request2, and resumes and completes within the request2", async () => { @@ -316,6 +368,8 @@ describe("mockFetch library", () => { expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /req2"); expect(await req2).toBe(2); + + expect(updateCallback).toHaveBeenCalledTimes(6); }); it("request1 pauses before request2, resumes within the request2, and completes after request2", async () => { @@ -352,6 +406,8 @@ describe("mockFetch library", () => { expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /req1"); expect(await req1).toBe(1); + + expect(updateCallback).toHaveBeenCalledTimes(6); }); it("delayed pause should not block new requests", async () => { @@ -376,6 +432,8 @@ describe("mockFetch library", () => { expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenCalledWith("Resolving GET /test/req1"); expect(await req1).toBe(1); + + expect(updateCallback).toHaveBeenCalledTimes(6); }); it("request pauses, resumes, and completes within another requests expiration", async () => { @@ -415,6 +473,8 @@ describe("mockFetch library", () => { expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /req1"); expect(await req1).toBe(1); + + expect(updateCallback).toHaveBeenCalledTimes(6); }); it("request pauses and resumes within another request but completes after", async () => { @@ -454,6 +514,8 @@ describe("mockFetch library", () => { expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /req2"); expect(await req2).toBe(2); + + expect(updateCallback).toHaveBeenCalledTimes(6); }); it("delayed pause should not block existing requests", async () => { @@ -479,6 +541,8 @@ describe("mockFetch library", () => { expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenCalledWith("Resolving GET /test/req2"); expect(await req2).toBe(2); + + expect(updateCallback).toHaveBeenCalledTimes(6); }); }); @@ -490,32 +554,38 @@ describe("mockFetch library", () => { // create two requests const req1 = mockFetch("/req1").then(() => 1); const req2 = mockFetch("/req2").then(() => 2); + expect(updateCallback).toHaveBeenCalledTimes(2); // verified paused jest.advanceTimersByTime(config.durationMs + 1); expect(config.log).not.toHaveBeenCalled(); + expect(updateCallback).toHaveBeenCalledTimes(2); // turn off config.areNewRequestsPaused = false; // create another request const req3 = mockFetch("/req3").then(() => 3); + expect(updateCallback).toHaveBeenCalledTimes(3); // verify it resolves jest.advanceTimersByTime(config.durationMs + 1); expect(config.log).toHaveBeenCalledTimes(1); expect(config.log).toHaveBeenNthCalledWith(1, "Resolving GET /req3"); + expect(updateCallback).toHaveBeenCalledTimes(4); expect(await req3).toBe(3); // resume two paused requests config.resume("1"); config.resume("2"); + expect(updateCallback).toHaveBeenCalledTimes(6); // verify they resolve jest.advanceTimersByTime(config.durationMs + 1); expect(config.log).toHaveBeenCalledTimes(3); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /req1"); expect(config.log).toHaveBeenNthCalledWith(3, "Resolving GET /req2"); + expect(updateCallback).toHaveBeenCalledTimes(7); expect(await req1).toBe(1); expect(await req2).toBe(2); }); @@ -540,9 +610,9 @@ describe("mockFetch library", () => { expect(config.log).toHaveBeenCalledTimes(2); expect(config.log).toHaveBeenNthCalledWith(2, "Resolving GET /req2"); expect(await req2).toBe(2); + + expect(updateCallback).toHaveBeenCalledTimes(5); }); }); }); - - describe("interactive mode", () => {}); }); diff --git a/tests/lib/mockFetchDebugger.test.js b/tests/lib/mockFetchDebugger.test.js new file mode 100644 index 0000000..c0c1dc8 --- /dev/null +++ b/tests/lib/mockFetchDebugger.test.js @@ -0,0 +1,288 @@ +/// +import path from "path"; +import { pathToFileURL } from "url"; + +// For debugging +// jest.setTimeout(5 * 60 * 1000); + +/** + * @typedef {import('../../scripts/bundles/controls/mockFetch').Config} MockFetchConfig + * @typedef {import('../../scripts/bundles/controls/mockFetch').MockFetchDebugger} MockFetchDebugger + */ +/** + * @typedef {import('puppeteer').ElementHandle} ElementHandle + * @template {Node} T + */ + +/** + * @param {string} selector + * @param {import('puppeteer').Page | ElementHandle} [parent] + * @returns {Promise>} + */ +async function getElement(selector, parent = page) { + const el = await parent.$(selector); + if (!el) { + throw new Error(`Could not find element with selector "${selector}"`); + } + + return el; +} + +/** + * @param {string} selector + * @returns {Promise[]>} + */ +async function getElements(selector) { + return page.$$(selector); +} + +describe("MockFetchDebugger", () => { + const defaultDuration = 500; + + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + + async function getDebuggerEl() { + return /** @type {ElementHandle}*/ ( + await getElement("mock-fetch-debugger") + ); + } + + /** @returns {Promise} */ + function getConfig() { + return page.evaluate(() => window.mockFetchConfig); + } + + async function getInflightList() { + return getElements("pierce/.request"); + } + + async function getCompletedList() { + return getElements("pierce/#completed li"); + } + + async function newRequest(url) { + await page.evaluate(url => { + window.mockFetch(url); + }, url); + } + + async function toggleRequest(index) { + const requestEl = (await getInflightList())[index]; + const toggleEl = await getElement(".request-btn", requestEl); + await toggleEl.click(); + } + + async function getPauseNew() { + return /** @type {ElementHandle} */ ( + await getElement("pierce/#pause-new") + ); + } + + async function getPausedNewChecked() { + const el = await getPauseNew(); + return el.evaluate(el => el.checked); + } + + /** @type {(config: Partial) => Promise} */ + async function setupNewDebugger(customConfig) { + await page.evaluate(config => { + document.querySelectorAll("mock-fetch-debugger").forEach(el => { + el.remove(); + }); + + window.mockFetchConfig = { ...window.createMockFetchConfig(), ...config }; + window.mockFetchConfig.log = (...msgs) => console.log(...msgs); + + window.mockFetch = window.createMockFetch(window.mockFetchConfig); + + const fetchDebugger = document.createElement("mock-fetch-debugger"); + fetchDebugger.config = window.mockFetchConfig; + document.body.appendChild(fetchDebugger); + window.fetchDebugger = fetchDebugger; + }, customConfig); + } + + beforeEach(async () => { + const htmlPath = path.join(__dirname, "mockFetchDebuggerTest.html"); + const htmlUrl = pathToFileURL(htmlPath).toString(); + await page.goto(htmlUrl); + + // Create new mock fetch debugger for each test + await setupNewDebugger({ durationMs: defaultDuration }); + }); + + it("initializes mock fetch debugger", async () => { + const debuggerEl = await getDebuggerEl(); + expect(debuggerEl).toBeTruthy(); + + const config = await getConfig(); + expect(config.durationMs).toBe(defaultDuration); + }); + + it("new requests complete as expected", async () => { + await newRequest("/req1"); + await newRequest("/req2"); + + let inflightList = await getInflightList(); + expect(inflightList).toHaveLength(2); + + await delay(defaultDuration + 10); + + inflightList = await getInflightList(); + expect(inflightList).toHaveLength(0); + + const completedList = await getCompletedList(); + expect(completedList).toHaveLength(2); + + const firstReq = await completedList[0].evaluate(el => el.textContent); + expect(firstReq).toBe("/req1"); + + const secondReq = await completedList[1].evaluate(el => el.textContent); + expect(secondReq).toBe("/req2"); + }); + + it("pauses and resumes requests as expected", async () => { + await newRequest("/req1"); + await newRequest("/req2"); + + await toggleRequest(0); + + let inflightList = await getInflightList(); + let completedList = await getCompletedList(); + expect(inflightList).toHaveLength(2); + expect(completedList).toHaveLength(0); + + await delay(defaultDuration + 10); + + inflightList = await getInflightList(); + completedList = await getCompletedList(); + expect(inflightList).toHaveLength(1); + expect(completedList).toHaveLength(1); + + await toggleRequest(0); + await delay(defaultDuration + 10); + + inflightList = await getInflightList(); + completedList = await getCompletedList(); + expect(inflightList).toHaveLength(0); + expect(completedList).toHaveLength(2); + + const firstCompleted = await completedList[0].evaluate( + el => el.textContent + ); + const secondCompleted = await completedList[1].evaluate( + el => el.textContent + ); + expect(firstCompleted).toBe("/req2"); + expect(secondCompleted).toBe("/req1"); + }); + + describe("latency control", () => { + async function getLatencyRange() { + return /** @type {ElementHandle} */ ( + await getElement("pierce/#latency") + ); + } + + async function getLatency() { + const latencyEl = await getLatencyRange(); + return latencyEl.evaluate(el => el.valueAsNumber); + } + + async function getLatencyLabel() { + const latencyEl = await page.$("pierce/#latency-label"); + return latencyEl?.evaluate(el => el.textContent); + } + + it("adjust latency range", async () => { + await delay(100); // Give a beat for the label to sync with the value + + await expect(getConfig()).resolves.toHaveProperty( + "durationMs", + defaultDuration + ); + await expect(getLatency()).resolves.toBe(defaultDuration); + await expect(getLatencyLabel()).resolves.toBe("0.5 seconds"); + + await (await getLatencyRange()).click(); + + await expect(getConfig()).resolves.toHaveProperty("durationMs", 5000); + await expect(getLatency()).resolves.toBe(5000); + await expect(getLatencyLabel()).resolves.toBe("5.0 seconds"); + }); + }); + + it("pauses new requests when enabled", async () => { + await expect(getPausedNewChecked()).resolves.toBe(false); + await expect(getConfig()).resolves.toHaveProperty( + "areNewRequestsPaused", + false + ); + + await (await getPauseNew()).click(); + + await expect(getPausedNewChecked()).resolves.toBe(true); + await expect(getConfig()).resolves.toHaveProperty( + "areNewRequestsPaused", + true + ); + + await newRequest("/req1"); + + let inflightList = await getInflightList(); + let firstReq = await inflightList[0].evaluate(el => el.textContent); + expect(inflightList).toHaveLength(1); + expect(firstReq).toBe("/req1▶"); + + await delay(defaultDuration + 100); + + inflightList = await getInflightList(); + firstReq = await inflightList[0].evaluate(el => el.textContent); + expect(inflightList).toHaveLength(1); + expect(firstReq).toBe("/req1▶"); + }); + + describe("0 latency", () => { + beforeEach(async () => { + await setupNewDebugger({ durationMs: 0 }); + await expect(getConfig()).resolves.toHaveProperty("durationMs", 0); + }); + + it("requests are immediately completed", async () => { + await newRequest("/req1"); + await delay(10); + + const inflightList = await getInflightList(); + expect(inflightList).toHaveLength(0); + + const completedList = await getCompletedList(); + expect(completedList).toHaveLength(1); + + const firstReq = await completedList[0].evaluate(el => el.textContent); + expect(firstReq).toBe("/req1"); + }); + + it("requested are paused until clicked when areNewRequestPaused is enabled", async () => { + await (await getPauseNew()).click(); + + await newRequest("/req1"); + + const inflightList = await getInflightList(); + expect(inflightList).toHaveLength(1); + + const firstReq = await inflightList[0].evaluate(el => el.textContent); + expect(firstReq).toBe("/req1▶"); + + await toggleRequest(0); + await delay(10); + + const completedList = await getCompletedList(); + expect(completedList).toHaveLength(1); + + const firstCompleted = await completedList[0].evaluate( + el => el.textContent + ); + expect(firstCompleted).toBe("/req1"); + }); + }); +}); diff --git a/tests/lib/mockFetchDebuggerTest.html b/tests/lib/mockFetchDebuggerTest.html new file mode 100644 index 0000000..7e76536 --- /dev/null +++ b/tests/lib/mockFetchDebuggerTest.html @@ -0,0 +1,13 @@ + + + + + + Mock Fetch Debugger Test Harness + + + + + + +