diff --git a/.eslintrc.json b/.eslintrc.json index 4b4a3db..316f28f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,7 +20,7 @@ "env": { "node": true, "jest": true } }, { - "files": ["generate-screenshots.js"], + "files": ["generate-screenshots.js", "scripts/**/*.js"], "env": { "node": true } } ] diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..bf13ee3 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,27 @@ +name: Validate extension +on: + push: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - run: ./release.sh --no-bump + - run: npx playwright install --with-deps chromium + - run: npm run test:browser + - id: version + run: echo "value=$(node -p 'require("./manifest.json").version')" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v4 + with: + name: quick-api-client + path: dist/quick-api-client-v${{ steps.version.outputs.value }}.zip + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 4892a21..e20bb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 1.1.5 - 2026-09-11 + +- Prevent duplicate sends during permission prompts and ignore stale callbacks after cancellation. +- Share request preparation across sending, preview and generated code, including GraphQL and environment values. +- Preserve raw request bodies and duplicate query parameters. Validate protocols, headers and import data. +- Bound request and response bodies to 5 MiB and skip syntax highlighting above 200,000 characters. +- Report actual response bytes and preserve raw response text when copying or downloading. +- Distinguish cancellations from timeouts and clean up request controllers and streams. +- Fix generated PHP, Java and C# quoting, empty Java POST bodies, and non-JSON Fetch responses. +- Preserve GraphQL variables during restoration and prevent settings reset from deleting favorites. +- Retain active environment selection when renaming, clear it when deleting, and report failed saves correctly. +- Include all popup modules in release archives and synchronize version metadata. +- Replace copied test implementations with source-based checks and add regression and browser package tests. + # Changelog - Quick API Client Welcome to the **Quick API Client** release log. Below is the list of changes, optimizations, and new features introduced for the Chrome Store and Edge Add-ons. diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index db3a7e9..224db4a 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,74 +1,33 @@ # Privacy Policy for Quick API Client -**Effective date:** 2025-01-01 +Effective date 2026-09-11 -This Privacy Policy describes how the browser extension **Quick API Client** (the "Extension") provided by **Hayk Jomardyan** ("we", "us", or "our") handles information when used in supported browsers, including Google Chrome and Microsoft Edge. +Quick API Client is provided by Hayk Jomardyan. This policy describes the extension's implemented data handling. -By installing or using the Extension, you acknowledge that you have read and understood this Privacy Policy. +## API requests -## 1. Scope +When you send a request, the extension transmits its URL, query parameters, headers and body directly to the selected API server. That server receives the connection's IP address and any credentials or personal information you include. Redirects follow browser networking rules. The extension omits browser cookies from its fetch requests. -This Privacy Policy applies solely to the Extension as distributed through: -- The Chrome Web Store for Google Chrome and other Chromium-based browsers. -- The Microsoft Edge Add-ons store for Microsoft Edge. +The extension does not send request data to a developer-operated relay, analytics service or advertising service. It does not monitor general browsing activity. -It does not apply to any websites, services, or applications that may be linked from the Extension but are operated by third parties under their own privacy policies. +## Browser storage -## 2. Data collection and processing +The extension saves the most recent request and enabled request history in local extension storage. These records may include URLs, headers, credentials and bodies entered by the user. -- The Extension does **not** collect, store, or process any personal data or personally identifiable information (PII). -- The Extension does **not** collect or transmit your browsing history, URLs, search queries, IP address, device identifiers, cookies, or similar identifiers. -- The Extension does **not** track your behavior across websites, and does **not** perform any profiling or analytics. +Settings, favorites and environment variables use browser sync storage. The browser provider may synchronize these records through the signed-in browser account, according to its settings and policies. The extension does not encrypt these values itself. The developer does not retrieve them. -All features of the Extension are designed to work without requiring any personal or sensitive user data, in line with data minimization expectations for Chrome and Edge extensions. +Response bodies are displayed in the request page. They are not added to saved history. You can copy or download a response explicitly. -## 3. Local processing and storage +## Sharing -- All processing performed by the Extension occurs locally within your browser. -- Any settings, preferences, or cached data used by the Extension (if any) are stored only in your browser's local storage or synchronized storage associated with your browser account. -- We do not have access to this locally stored information and do not retrieve it to our own servers. +Request exports include the request URL, headers, query parameters, body and GraphQL settings. Base64 encoding is not encryption. Copying an export to the clipboard and sharing it can disclose credentials or other information contained in that request. -## 4. No use of third-party services +## Permissions and controls -- The Extension does **not** use third-party analytics tools, advertising networks, or tracking pixels. -- The Extension does **not** embed third-party SDKs or scripts for the purpose of collecting user data. -- The Extension does **not** share any information with third parties, because no information is collected in the first place. +The extension uses the storage permission and asks for optional access to API hosts. It does not require access to browsing history or all open tabs. -## 5. Permissions +You can disable or clear request history, edit or delete favorites and environments, revoke host access in the browser, or uninstall the extension. Disabling history prevents new history entries but does not remove the separate last-request draft. Resetting preferences preserves favorites. Browser synchronization and API-server retention are controlled by their respective providers. -The Extension may request certain browser permissions that are technically required for its functionality (for example, access to specific sites, tabs, or storage). -Requesting a permission does **not** mean that personal data is collected; permissions are only used to provide the core features of the Extension and not for data harvesting or tracking, in accordance with Chrome Web Store and Microsoft Edge Add-ons policies. +## Policy updates and contact -## 6. Children's privacy - -Because the Extension does not collect, store, or process any personal information, it does not knowingly collect information from children of any age. -If this changes in a future version, this Privacy Policy will be updated to reflect the applicable requirements for children's data protection. - -## 7. User rights and controls - -Since the Extension does not collect or store personal data on our servers, there is no personal data for us to access, modify, export, or delete on your behalf. -You may at any time: -- Disable the Extension from your browser's extensions/add-ons page. -- Uninstall the Extension, which will remove it and any associated local data stored by the browser. - -For Chrome and Edge, you can manage or remove extensions from the respective extensions management pages provided by the browser. - -## 8. International data transfers - -Because we do not collect or store personal data, there are no international transfers of personal data related to the Extension. -If future versions begin to collect data, this section will be updated to describe any applicable transfers and safeguards. - -## 9. Changes to this Privacy Policy - -We may update this Privacy Policy from time to time, for example to reflect changes in: -- The functionality or behavior of the Extension. -- Applicable laws, regulations, or store policies for Chrome or Edge extensions. - -When changes are made, the "Effective date" at the top of this document will be updated. Material changes will be reflected in the latest version of the policy made available via the Chrome Web Store and Microsoft Edge Add-ons listing pages. - -## 10. Contact - -If you have any questions or concerns about this Privacy Policy or the Extension, you can contact us at: - -**Name:** Hayk Jomardyan -**Website:** https://github.com/jomardyan/Quick-API-Client +This file is updated when implemented data handling changes. For questions, contact Hayk Jomardyan through the [project repository](https://github.com/jomardyan/Quick-API-Client). diff --git a/README.md b/README.md index 1ef575c..37d32cf 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,49 @@ # Quick API Client -A rapid API testing and debugging tool for developers. +A Chrome and Edge extension for testing REST and GraphQL APIs. ## Features -- Quick API testing -- Request/response visualization -- Multiple HTTP methods support +- HTTP methods, headers, repeated query parameters and raw request bodies +- GraphQL query and variables editor +- Named environments, favorites and local request history +- Cancellation, configurable timeouts and response timing +- Request sharing and code generation for eight targets +- JSON, XML, HTML and CSS parsing checks -## ๐Ÿ‘จโ€๐Ÿ’ป Author +## Install -**Hayk Jomardyan** +Download the latest versioned ZIP from `dist`, extract it, and use Load unpacked in your browser's extension management page with developer mode enabled. Grant access to the API host when sending a request. Use the open-in-tab control for requests that should remain visible while switching browser tabs. -- ๐ŸŒ Website: [lolino.pl](https://lolino.pl) -- ๐Ÿ“ง Email: [hayk.jomardyan@outlook.com](mailto:hayk.jomardyan@outlook.com) -- ๐Ÿ’ผ GitHub: [@jomardyan](https://github.com/jomardyan) +## Development and verification -## License +Requires Node.js 22, npm, Bash, jq, zip and unzip. -MIT License - See LICENSE file for details +```sh +npm ci +npm run lint +npm test -- --runInBand +./release.sh --no-bump +npx playwright install chromium +npm run test:browser +``` + +The browser smoke test extracts the release ZIP and starts a local fixture server. Its temporary manifest pregrants only that fixture host. The distributed manifest continues to request optional permissions. The test exercises real extension messaging and HTTP requests. It requires an environment that permits Chromium processes and local sockets. + +The release script validates source before bumping versions. It updates the manifest, package and lockfile together and includes all runtime modules. GitHub Actions runs lint, regression tests, packaging and the Chromium smoke test, then provides the current ZIP as an artifact. + +## Operating limits + +Request and decoded response bodies are limited to 5 MiB. Larger responses fail explicitly. JSON responses above 200,000 characters display as plain text to keep the interface responsive. Copy and download retain the raw response text. Responses are text-oriented and do not provide lossless binary downloads. + +Timeouts range from 1 to 60 seconds. Chrome can terminate an extension service worker if response headers take over 30 seconds to arrive. The UI reports a disconnected background or recovers after the configured timeout plus five seconds. Failed or interrupted requests are never automatically retried because the server may already have processed them. See the [Chrome service worker lifecycle documentation](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle). + +Browser networking still controls restricted request headers, redirects and TLS validation. Cookies are omitted and the HTTP cache is bypassed. Closing the request page attempts to cancel its active request. Cancellation cannot reverse an action already performed by an API. + +Favorites, settings and environments use browser sync storage and remain subject to its quota. History and the last request use local extension storage. Request exports contain the entered headers and body. See [PRIVACY_POLICY.md](PRIVACY_POLICY.md) for details. + +## Author and license + +Hayk Jomardyan - [GitHub](https://github.com/jomardyan) + +The repository contains the Creative Commons Attribution-NoDerivatives 4.0 International license. See [LICENSE](LICENSE). diff --git a/background.js b/background.js index e927f3b..405dca1 100644 --- a/background.js +++ b/background.js @@ -1,97 +1,90 @@ const DEFAULT_TIMEOUT_MS = 15000; -const DEBUG_LOGGING = false; +const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; +const pendingRequests = new Map(); -// Track in-flight requests so cancellation can abort them -const pendingRequests = new Map(); // requestId -> AbortController +async function readResponse(res) { + if (!res.body) return { body: "", bodyBytes: 0 }; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const chunks = []; + let bodyBytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bodyBytes += value.byteLength; + if (bodyBytes > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error("Response exceeds the 5 MiB limit"); + } + chunks.push(decoder.decode(value, { stream: true })); + } + chunks.push(decoder.decode()); + return { body: chunks.join(""), bodyBytes }; + } finally { + reader.releaseLock(); + } +} -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { - // Handle cancellation from popup +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Only extension pages may use the network bridge. + if (sender.id !== chrome.runtime.id || !sender.url?.startsWith(chrome.runtime.getURL(""))) return; if (message?.type === "cancel-request") { - const controller = pendingRequests.get(message.payload?.requestId); - if (controller) { - controller.abort(); - pendingRequests.delete(message.payload.requestId); + const request = pendingRequests.get(message.payload?.requestId); + if (request && request.owner === sender.url) { + request.cancelled = true; + request.controller.abort(); } - return; // no async response needed + sendResponse({ ok: true }); + return; } - if (message?.type !== "api-request") return; - - const { url, method, headers, body, timeoutMs, requestId } = message.payload; - const effectiveTimeout = - typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), effectiveTimeout); - - if (requestId) pendingRequests.set(requestId, controller); - + const payload = message.payload; + let parsedUrl; + try { + if (!payload || typeof payload.requestId !== "string" || !payload.requestId || payload.requestId.length > 128) throw new Error("Invalid request ID"); + if (pendingRequests.has(payload.requestId)) throw new Error("Request ID already in use"); + if (pendingRequests.size >= 8) throw new Error("Too many active requests"); + parsedUrl = new URL(payload.url); + if (!["http:", "https:"].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password) throw new Error("Only HTTP and HTTPS URLs without embedded credentials are supported"); + if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"].includes(payload.method)) throw new Error("Unsupported HTTP method"); + if (payload.body != null && typeof payload.body !== "string") throw new Error("Invalid request body"); + if (payload.body && new TextEncoder().encode(payload.body).length > MAX_RESPONSE_BYTES) throw new Error("Request exceeds the 5 MiB limit"); + new Headers(payload.headers); // Validate before allocating request resources. + } catch (err) { + sendResponse({ ok: false, error: err.message }); + return; + } + const { method, headers, body, requestId } = payload; + const timeoutMs = Number.isFinite(payload.timeoutMs) && payload.timeoutMs > 0 + ? Math.min(60000, Math.max(1000, payload.timeoutMs)) : DEFAULT_TIMEOUT_MS; + const request = { controller: new AbortController(), cancelled: false, owner: sender.url }; + pendingRequests.set(requestId, request); + const timeout = setTimeout(() => request.controller.abort(), timeoutMs); const started = performance.now(); - (async () => { try { - // Validate URL - try { - new URL(url); - } catch (err) { - sendResponse({ - ok: false, - error: "Invalid URL format", - }); - return; - } - - const fetchOptions = { - method, - headers, - signal: controller.signal, - mode: 'cors', - credentials: 'omit' - }; - - if (body && !['GET', 'HEAD'].includes(method)) { - fetchOptions.body = body; - } - - if (DEBUG_LOGGING) console.log("Fetching", url, fetchOptions); - const res = await fetch(url, fetchOptions); - const text = await res.text(); - const elapsed = Math.round(performance.now() - started); - const headersEntries = Array.from(res.headers.entries()); - - if (DEBUG_LOGGING) console.log("Fetch success", res.status); + const options = { method, headers, signal: request.controller.signal, credentials: "omit", cache: "no-store" }; + if (!["GET", "HEAD"].includes(method) && body != null) options.body = body; + const res = await fetch(parsedUrl.href, options); + const result = await readResponse(res); sendResponse({ - ok: true, - status: res.status, - statusText: res.statusText, - url: res.url, - type: res.type, - elapsed, - headers: headersEntries, - body: text, + ok: true, status: res.status, statusText: res.statusText, url: res.url, + type: res.type, elapsed: Math.round(performance.now() - started), + headers: Array.from(res.headers.entries()), ...result, }); } catch (err) { - console.error('API request error:', err); - const elapsed = Math.round(performance.now() - started); sendResponse({ - ok: false, - error: - err.name === "AbortError" - ? `Request timed out after ${Math.round(effectiveTimeout / 1000)}s` - : err.name === "TypeError" && err.message.includes("fetch") - ? "Network error: Unable to reach the server. Check your connection or the URL." - : err.message || "Unknown error occurred", - elapsed, + ok: false, cancelled: request.cancelled, + error: request.cancelled ? "Request cancelled" : request.controller.signal.aborted + ? `Request timed out after ${timeoutMs / 1000}s` + : err.message || "Network request failed", + elapsed: Math.round(performance.now() - started), }); } finally { clearTimeout(timeout); - if (requestId) pendingRequests.delete(requestId); + if (pendingRequests.get(requestId) === request) pendingRequests.delete(requestId); } })(); - - return true; // keep the message channel open for async response + return true; }); - -// Log extension startup -if (DEBUG_LOGGING) { - console.log("Quick API Client: Background service worker started"); -} diff --git a/defaults.js b/defaults.js index 1abcd0e..97807f0 100644 --- a/defaults.js +++ b/defaults.js @@ -16,9 +16,13 @@ function clampHistorySize(size) { const num = Number(size); if (!Number.isFinite(num)) return defaults.historySize; - return Math.max(0, Math.min(50, num)); + return Math.max(0, Math.min(50, Math.floor(num))); } + window.clampTimeoutMs = (value) => { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.max(1000, Math.min(60000, Math.round(number))) : defaults.timeoutMs; + }; window.DEFAULT_OPTIONS = defaults; window.clampHistorySize = clampHistorySize; })(); diff --git a/dist/quick-api-client-v1.1.5.zip b/dist/quick-api-client-v1.1.5.zip new file mode 100644 index 0000000..ea3372c Binary files /dev/null and b/dist/quick-api-client-v1.1.5.zip differ diff --git a/generate-screenshots.js b/generate-screenshots.js index db926d2..b89f568 100644 --- a/generate-screenshots.js +++ b/generate-screenshots.js @@ -31,6 +31,8 @@ async function run() { ok: true, status: 200, statusText: "OK", + type: "basic", + url: "https://api.example.com/users", headers: [ ["content-type", "application/json; charset=utf-8"], ["cache-control", "max-age=43200"], diff --git a/manifest.json b/manifest.json index 3a0edbd..9432ced 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Quick API Client", - "version": "1.1.4", + "version": "1.1.5", "author": "Hayk Jomardyan", "homepage_url": "https://github.com/jomardyan/Quick-API-Client", "description": "Test REST APIs from a popup: configure method, URL, headers, query params, body, view response, and copy cURL.", diff --git a/options.js b/options.js index 9f65d3b..bdd26e3 100644 --- a/options.js +++ b/options.js @@ -4,7 +4,7 @@ const clampHistorySize = ((size) => { const num = Number(size); if (!Number.isFinite(num)) return DEFAULT_OPTIONS.historySize; - return Math.max(0, Math.min(50, num)); + return Math.max(0, Math.min(50, Math.floor(num))); }); const themeSelect = document.getElementById("themeSelect"); @@ -86,8 +86,8 @@ function parseKVText(text) { if (Array.isArray(parsed)) { return parsed .map((item) => ({ - key: item.key?.trim?.() || "", - value: item.value ?? "", + key: item?.key?.trim?.() || "", + value: String(item?.value ?? ""), })) .filter((kv) => kv.key); } @@ -127,7 +127,7 @@ function loadOptions() { } function saveOptions() { - const timeoutMs = Math.max(1000, Math.min(60000, Number(timeoutSeconds.value) * 1000)); + const timeoutMs = window.clampTimeoutMs(Number(timeoutSeconds.value) * 1000); const size = clampHistorySize(historySize.value); chrome.storage.sync.get("options", ({ options }) => { const existing = options || {}; @@ -157,10 +157,22 @@ function saveOptions() { } function resetOptions() { - chrome.storage.sync.set({ options: DEFAULT_OPTIONS }, () => { - loadOptions(); - statusEl.textContent = "Reset to defaults."; - setTimeout(() => (statusEl.textContent = ""), 1800); + chrome.storage.sync.get("options", ({ options }) => { + if (chrome.runtime.lastError) { + statusEl.textContent = "Reset failed - " + chrome.runtime.lastError.message; + return; + } + // Preferences reset must not delete saved requests. + const reset = { ...DEFAULT_OPTIONS, favorites: options?.favorites || [] }; + chrome.storage.sync.set({ options: reset }, () => { + if (chrome.runtime.lastError) { + statusEl.textContent = "Reset failed - " + chrome.runtime.lastError.message; + return; + } + loadOptions(); + statusEl.textContent = "Reset to defaults."; + setTimeout(() => (statusEl.textContent = ""), 1800); + }); }); } @@ -179,25 +191,43 @@ function clearHistory() { function loadEnvironments() { chrome.storage.sync.get("environments", ({ environments: stored }) => { - environments = stored || []; + environments = Array.isArray(stored) ? stored : []; renderEnvSelect(); }); } -function saveCurrentEnv() { +function saveCurrentEnv(callback) { if (selectedEnvIdx < 0 || selectedEnvIdx >= environments.length) return; - environments[selectedEnvIdx].name = envNameInput.value.trim() || environments[selectedEnvIdx].name; + const name = envNameInput.value.trim() || environments[selectedEnvIdx].name; + if (environments.some((env, idx) => idx !== selectedEnvIdx && env.name === name)) { + statusEl.textContent = "An environment with this name already exists."; + return; + } + const previousName = environments[selectedEnvIdx].name; + environments[selectedEnvIdx].name = name; environments[selectedEnvIdx].vars = parseKVText(envVarsInput.value); - persistEnvironments(); + persistEnvironments(callback, previousName, name); } -function persistEnvironments(callback) { - chrome.storage.sync.set({ environments }, () => { - if (chrome.runtime.lastError) { - statusEl.textContent = "Env save failed: " + chrome.runtime.lastError.message; - return; +function persistEnvironments(callback, previousName, nextName) { + const snapshot = environments.map(env => ({ ...env, vars: env.vars.map(item => ({ ...item })) })); + [addEnvBtn, deleteEnvBtn, saveEnvBtn].forEach(button => { button.disabled = true; }); + const fail = error => { + statusEl.textContent = "Env save failed - " + error.message; + loadEnvironments(); + [addEnvBtn, deleteEnvBtn, saveEnvBtn].forEach(button => { button.disabled = false; }); + }; + chrome.storage.sync.get("options", ({ options }) => { + if (chrome.runtime.lastError) { fail(chrome.runtime.lastError); return; } + const update = { environments: snapshot }; + if (previousName && options?.activeEnvironment === previousName) { + update.options = { ...options, activeEnvironment: nextName || "" }; } - if (callback) callback(); + chrome.storage.sync.set(update, () => { + if (chrome.runtime.lastError) { fail(chrome.runtime.lastError); return; } + [addEnvBtn, deleteEnvBtn, saveEnvBtn].forEach(button => { button.disabled = false; }); + if (callback) callback(); + }); }); } @@ -237,7 +267,9 @@ function renderEnvEditor() { } addEnvBtn.addEventListener("click", () => { - environments.push({ name: `Environment ${environments.length + 1}`, vars: [] }); + let number = environments.length + 1; + while (environments.some(env => env.name === `Environment ${number}`)) number++; + environments.push({ name: `Environment ${number}`, vars: [] }); selectedEnvIdx = environments.length - 1; persistEnvironments(() => { renderEnvSelect(); @@ -251,15 +283,16 @@ deleteEnvBtn.addEventListener("click", () => { showConfirm(`Delete environment "${name}"?`, () => { environments.splice(selectedEnvIdx, 1); selectedEnvIdx = Math.min(selectedEnvIdx, environments.length - 1); - persistEnvironments(renderEnvSelect); + persistEnvironments(renderEnvSelect, name, ""); }); }); saveEnvBtn.addEventListener("click", () => { - saveCurrentEnv(); - renderEnvSelect(); - statusEl.textContent = "Environment saved."; - setTimeout(() => (statusEl.textContent = ""), 1800); + saveCurrentEnv(() => { + renderEnvSelect(); + statusEl.textContent = "Environment saved."; + setTimeout(() => (statusEl.textContent = ""), 1800); + }); }); envNameSelect.addEventListener("change", () => { diff --git a/package-lock.json b/package-lock.json index 1d8a03b..cbfc3e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "quick-api-client", - "version": "1.1.4", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "quick-api-client", - "version": "1.1.4", + "version": "1.1.5", "devDependencies": { "@playwright/test": "^1.55.1", "eslint": "^8.57.0", diff --git a/package.json b/package.json index 4ebb8f5..ed51ae7 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "quick-api-client", - "version": "1.1.4", + "version": "1.1.5", "description": "Test REST/GraphQL APIs from a browser extension popup", "private": true, "scripts": { "test": "node --experimental-vm-modules node_modules/.bin/jest --coverage", "test:watch": "node --experimental-vm-modules node_modules/.bin/jest --watch", - "lint": "eslint ." + "lint": "eslint .", + "test:browser": "node scripts/browser-smoke.js" }, "devDependencies": { "@playwright/test": "^1.55.1", @@ -24,6 +25,10 @@ "transform": {}, "testMatch": [ "**/tests/**/*.test.js" + ], + "coveragePathIgnorePatterns": [ + "/node_modules/", + "/tests/" ] } } diff --git a/popup.html b/popup.html index 30a1f81..e5c1946 100644 --- a/popup.html +++ b/popup.html @@ -375,6 +375,7 @@

Share Request

+ diff --git a/popup.js b/popup.js index a2ab125..de69af9 100644 --- a/popup.js +++ b/popup.js @@ -88,6 +88,7 @@ const gqlVariables = document.getElementById("gqlVariables"); const isBodyless = (method) => ["GET", "HEAD"].includes(method); let maxHistory = 8; let favorites = []; +let favoriteMutationPending = false; // โ”€โ”€ Debounce utility โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function debounce(fn, ms) { @@ -111,6 +112,7 @@ let gqlMode = false; function disableGqlMode() { gqlMode = false; + methodEl.disabled = false; gqlToggleBtn.classList.remove("primary"); gqlToggleBtn.classList.add("ghost"); gqlVarsRow.style.display = "none"; @@ -119,6 +121,8 @@ function disableGqlMode() { function setGqlMode(enabled) { gqlMode = enabled; + methodEl.disabled = enabled; + if (!enabled) gqlVariables.value = ""; gqlToggleBtn.classList.toggle("primary", enabled); gqlToggleBtn.classList.toggle("ghost", !enabled); gqlVarsRow.style.display = enabled ? "" : "none"; @@ -136,18 +140,6 @@ function setGqlMode(enabled) { // โ”€โ”€ Environment variable substitution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -/** - * Replace {{VAR_NAME}} tokens with values from the active environment. - * Unresolved tokens are left as-is so the user sees the problem clearly. - */ -function substituteVars(text, vars) { - if (!text || !vars.length) return text; - return text.replace(/\{\{([^}]+)\}\}/g, (match, name) => { - const entry = vars.find((v) => v.key === name.trim()); - return entry !== undefined ? entry.value : match; - }); -} - function getActiveEnvVars() { const envName = currentOptions.activeEnvironment || ""; if (!envName || !currentEnvironments.length) return []; @@ -156,7 +148,6 @@ function getActiveEnvVars() { } function renderEnvSelect() { - const current = envSelect.value; envSelect.innerHTML = ''; currentEnvironments.forEach((env) => { const opt = document.createElement("option"); @@ -165,7 +156,7 @@ function renderEnvSelect() { envSelect.appendChild(opt); }); // Restore prior selection or active env from options - const desired = current || currentOptions.activeEnvironment || ""; + const desired = currentOptions.activeEnvironment || ""; envSelect.value = desired; updateEnvVarCount(); } @@ -266,21 +257,6 @@ function readKV(container) { .filter(Boolean); } -function buildUrl(rawUrl, queryParams) { - let normalized = rawUrl.trim(); - if (normalized && !/^https?:\/\//i.test(normalized)) { - normalized = `https://${normalized}`; - } - let urlObj; - try { - urlObj = new URL(normalized); - } catch (err) { - return null; - } - queryParams.forEach(({ key, value }) => urlObj.searchParams.set(key, value)); - return urlObj.toString(); -} - function prettifyJsonMaybe(text) { try { return JSON.stringify(JSON.parse(text), null, 2); @@ -323,8 +299,8 @@ function highlightHeaders(lines) { .join("\n"); } -function saveState() { - const state = { +function snapshotRequest() { + return { method: methodEl.value, url: urlEl.value, query: readKV(queryListEl), @@ -333,7 +309,12 @@ function saveState() { gqlMode, gqlVariables: gqlVariables.value, }; - chrome.storage.local.set({ lastRequest: state }); +} + +function saveState() { + chrome.storage.local.set({ lastRequest: snapshotRequest() }, () => { + if (chrome.runtime.lastError) showToast("Could not save request - " + chrome.runtime.lastError.message); + }); } function applyTheme(themeChoice) { @@ -356,13 +337,13 @@ function loadOptions() { chrome.storage.sync.get(["options", "environments"], ({ options, environments }) => { currentOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; maxHistory = clampHistorySize(currentOptions.historySize ?? DEFAULT_OPTIONS.historySize); - favorites = currentOptions.favorites || []; - currentEnvironments = environments || []; + favorites = Array.isArray(currentOptions.favorites) ? currentOptions.favorites : []; + currentEnvironments = Array.isArray(environments) ? environments : []; applyTheme(currentOptions.theme); renderFavorites(); renderEnvSelect(); chrome.storage.local.get("history", ({ history }) => { - historyItems = history || []; + historyItems = Array.isArray(history) ? history : []; renderHistory(); resolve(currentOptions); }); @@ -397,32 +378,30 @@ function restoreState() { bodyEl.value = base.body || ""; if (base.gqlMode) { - setGqlMode(true); gqlVariables.value = base.gqlVariables || ""; + setGqlMode(true); } updatePreview(); }); } function updatePreview() { - const method = methodEl.value; - const query = readKV(queryListEl); - const headers = readKV(headersListEl); - const url = buildUrl(urlEl.value, query); - - const headerLines = headers.map(({ key, value }) => `${key}: ${value}`); - const parts = [ - `${method} ${url || "(invalid URL)"} HTTP/1.1`, - headerLines.length ? headerLines.join("\n") : "", - isBodyless(method) ? "" : bodyEl.value.trim(), - ].filter(Boolean); - - requestPreviewEl.textContent = parts.join("\n\n"); + try { + const request = prepareRequest(); + requestPreviewEl.textContent = [ + `${request.method} ${request.url} HTTP/1.1`, + request.headers.map(({ key, value }) => `${key}: ${value}`).join("\n"), + request.body, + ].filter(Boolean).join("\n\n"); + } catch (err) { + requestPreviewEl.textContent = err.message; + } document.getElementById("bodyHint").textContent = gqlMode - ? "GraphQL query string" - : isBodyless(method) - ? "Ignored for GET/HEAD" - : "Sends raw text; JSON is auto-formatted if valid"; + ? "GraphQL query string" : isBodyless(methodEl.value) ? "Ignored for GET/HEAD" : "Sends the body exactly as entered"; +} + +function prepareRequest() { + return window.QuickRequest.prepare(snapshotRequest(), getActiveEnvVars()); } function originFromUrl(url) { @@ -436,9 +415,11 @@ function originFromUrl(url) { function ensureOriginPermission(origin) { return new Promise((resolve) => { if (!origin) return resolve(false); - chrome.permissions.contains({ origins: [origin] }, (has) => { - if (has) return resolve(true); - chrome.permissions.request({ origins: [origin] }, (granted) => resolve(Boolean(granted))); + // Request directly from the click gesture. Already granted access resolves without another prompt. + chrome.permissions.request({ origins: [origin] }, (granted) => { + const error = chrome.runtime.lastError; + if (error) showToast(error.message); + resolve(!error && Boolean(granted)); }); }); } @@ -459,6 +440,7 @@ function renderFavorites() { } function saveFavorite(name) { + if (favoriteMutationPending) return; const method = methodEl.value; const query = readKV(queryListEl); const headers = readKV(headersListEl); @@ -470,16 +452,23 @@ function saveFavorite(name) { const hasCredential = headers.some(({ key }) => sensitivePatterns.includes(key.toLowerCase())); const favorite = { name, method, url, query, headers, body, gqlMode, gqlVariables: gqlVariables.value }; - favorites.push(favorite); - + favoriteMutationPending = true; chrome.storage.sync.get("options", ({ options }) => { - const newOptions = { ...DEFAULT_OPTIONS, ...(options || {}), favorites }; + if (chrome.runtime.lastError) { + favoriteMutationPending = false; + showToast("Save failed - " + chrome.runtime.lastError.message); + return; + } + const nextFavorites = [...(Array.isArray(options?.favorites) ? options.favorites : []), favorite]; + const newOptions = { ...DEFAULT_OPTIONS, ...(options || {}), favorites: nextFavorites }; chrome.storage.sync.set({ options: newOptions }, () => { if (chrome.runtime.lastError) { - favorites.pop(); // roll back optimistic push - showToast("Save failed: storage quota exceeded"); + favoriteMutationPending = false; + showToast("Save failed - " + chrome.runtime.lastError.message); return; } + favoriteMutationPending = false; + favorites = nextFavorites; renderFavorites(); showToast(hasCredential ? "Saved โš  contains credentials" : "Favorite saved"); }); @@ -503,8 +492,8 @@ function applyFavorite() { ); bodyEl.value = fav.body || ""; if (fav.gqlMode) { - setGqlMode(true); gqlVariables.value = fav.gqlVariables || ""; + setGqlMode(true); } else { disableGqlMode(); } @@ -513,6 +502,7 @@ function applyFavorite() { } function deleteFavorite() { + if (favoriteMutationPending) return; const idx = favoriteSelect.value; if (!idx || !favorites[Number(idx)]) { showToast("Select a favorite to delete"); @@ -520,14 +510,24 @@ function deleteFavorite() { } showConfirm(`Delete "${favorites[Number(idx)].name}"?`, () => { - favorites.splice(Number(idx), 1); + if (favoriteMutationPending) return; + favoriteMutationPending = true; + const nextFavorites = favorites.filter((_, index) => index !== Number(idx)); chrome.storage.sync.get("options", ({ options }) => { - const newOptions = { ...DEFAULT_OPTIONS, ...(options || {}), favorites }; + if (chrome.runtime.lastError) { + favoriteMutationPending = false; + showToast("Delete failed - " + chrome.runtime.lastError.message); + return; + } + const newOptions = { ...DEFAULT_OPTIONS, ...(options || {}), favorites: nextFavorites }; chrome.storage.sync.set({ options: newOptions }, () => { if (chrome.runtime.lastError) { + favoriteMutationPending = false; showToast("Delete failed: " + chrome.runtime.lastError.message); return; } + favoriteMutationPending = false; + favorites = nextFavorites; renderFavorites(); showToast("Favorite deleted"); }); @@ -582,6 +582,12 @@ function closeHelpModalFn() { helpBtn.focus(); } +function upsertAuth(container, key, value) { + const rows = Array.from(container.querySelectorAll(".kv-row")); + rows.filter(row => row.querySelector(".kv-key").value.trim().toLowerCase() === key.toLowerCase()).forEach(row => row.remove()); + createKVRow(container, key, value); +} + function applyAuthTemplate() { const type = authType.value; @@ -591,12 +597,12 @@ function applyAuthTemplate() { showToast("Enter a token"); return; } - createKVRow(headersListEl, "Authorization", `Bearer ${token}`); + upsertAuth(headersListEl, "Authorization", `Bearer ${token}`); showToast("Bearer auth added"); } else if (type === "basic") { - const username = basicUsername.value.trim(); - const password = basicPassword.value.trim(); - if (!username || !password) { + const username = basicUsername.value; + const password = basicPassword.value; + if (!username) { showToast("Enter username and password"); return; } @@ -609,7 +615,7 @@ function applyAuthTemplate() { // Use encodeURIComponent/unescape to safely handle non-ASCII characters // before passing to btoa, per RFC 7617 UTF-8 encoding for Basic auth. const encoded = btoa(unescape(encodeURIComponent(`${username}:${password}`))); - createKVRow(headersListEl, "Authorization", `Basic ${encoded}`); + upsertAuth(headersListEl, "Authorization", `Basic ${encoded}`); showToast("Basic auth added"); } catch (err) { showToast("Invalid characters in credentials"); @@ -622,7 +628,7 @@ function applyAuthTemplate() { showToast("Enter key name and value"); return; } - createKVRow(headersListEl, keyName, keyValue); + upsertAuth(headersListEl, keyName, keyValue); showToast("API key added"); } else if (type === "apikey-query") { const keyName = apiKeyName.value.trim(); @@ -631,7 +637,7 @@ function applyAuthTemplate() { showToast("Enter key name and value"); return; } - createKVRow(queryListEl, keyName, keyValue); + upsertAuth(queryListEl, keyName, keyValue); showToast("API key added"); } @@ -656,7 +662,7 @@ function renderHistory() { historyListEl.appendChild(hint); return; } - historyItems.forEach((item) => { + historyItems.slice(0, maxHistory).forEach((item) => { const el = document.createElement("div"); el.className = "history-item"; @@ -719,14 +725,16 @@ function clearSendingState() { cancelBtn.style.display = "none"; } +function abortBackgroundRequest(requestId) { + if (!requestId) return; + chrome.runtime.sendMessage({ type: "cancel-request", payload: { requestId } }, () => { + void chrome.runtime.lastError; + }); +} + function cancelCurrentRequest() { if (!isRequestInFlight) return; - if (activeRequestId) { - chrome.runtime.sendMessage({ - type: "cancel-request", - payload: { requestId: activeRequestId }, - }); - } + abortBackgroundRequest(activeRequestId); clearSendingState(); statusBadge.textContent = "Cancelled"; statusBadge.className = "badge warn"; @@ -736,87 +744,28 @@ function cancelCurrentRequest() { async function sendRequest() { if (isRequestInFlight) return; // prevent concurrent sends + const requestId = crypto.randomUUID(); + activeRequestId = requestId; + isRequestInFlight = true; + sendBtn.disabled = sendBtnBottom.disabled = true; + cancelBtn.style.display = ""; try { - const method = methodEl.value; - const envVars = getActiveEnvVars(); - const query = readKV(queryListEl); - const headers = readKV(headersListEl); - - // Apply environment variable substitution to URL components before parsing - const rawUrl = substituteVars(urlEl.value, envVars); - const substitutedQuery = query.map(({ key, value }) => ({ - key, - value: substituteVars(value, envVars), - })); - let finalUrl = buildUrl(rawUrl, substitutedQuery); - - if (!finalUrl) { - statusBadge.textContent = "Invalid URL"; - statusBadge.className = "badge err"; - showToast("Enter a valid URL"); - return; - } - - // Validate URL format - try { - new URL(finalUrl); - } catch (err) { - statusBadge.textContent = "Invalid URL"; - statusBadge.className = "badge err"; - showToast("URL format is invalid"); - return; - } - - const origin = originFromUrl(finalUrl); - const allowed = await ensureOriginPermission(origin); + const snapshot = snapshotRequest(); + const { method, url: finalUrl, headers, body } = prepareRequest(); + const headersObj = Object.fromEntries(headers.map(({ key, value }) => [key, value])); + const allowed = await ensureOriginPermission(originFromUrl(finalUrl)); + if (activeRequestId !== requestId) return; if (!allowed) { + clearSendingState(); statusBadge.textContent = "Permission denied"; statusBadge.className = "badge err"; responseMeta.textContent = "Allow host permission to send this request."; - showToast("Permission denied"); return; } - // Apply env var substitution to header values - const headersObj = headers.reduce((acc, { key, value }) => { - acc[key] = substituteVars(value, envVars); - return acc; - }, {}); - - let body = substituteVars(bodyEl.value, envVars); - if (isBodyless(method)) { - body = undefined; - } else if (gqlMode) { - // In GraphQL mode, build the standard {query, variables} envelope - const query_str = body || ""; - let variables = {}; - const rawVars = substituteVars(gqlVariables.value.trim(), envVars); - if (rawVars) { - try { - variables = JSON.parse(rawVars); - } catch (err) { - statusBadge.textContent = "GQL Error"; - statusBadge.className = "badge err"; - showToast("GraphQL variables: invalid JSON"); - return; - } - } - body = JSON.stringify({ query: query_str, variables }); - headersObj["Content-Type"] = "application/json"; - } else if ( - headersObj["Content-Type"]?.includes("application/json") && - body && body.trim() - ) { - try { - body = JSON.stringify(JSON.parse(body)); - } catch (err) { - // Keep as-is if not valid JSON. - } - } - // Show loading state and mark in-flight isRequestInFlight = true; - activeRequestId = crypto.randomUUID(); + activeRequestId = requestId; sendBtn.disabled = true; sendBtnBottom.disabled = true; sendBtn.classList.add("loading"); @@ -828,11 +777,15 @@ async function sendRequest() { responseMeta.textContent = ""; responseHeaders.textContent = ""; responseBody.textContent = ""; + delete responseBody.dataset.raw; + delete responseBody.dataset.bytes; // Guard: if the service worker is killed mid-request, the callback never fires. // After timeout + 5 s we recover the UI instead of hanging forever. - const effectiveTimeout = currentOptions.timeoutMs || 15000; + const effectiveTimeout = window.clampTimeoutMs(currentOptions.timeoutMs); swGuardTimeoutId = setTimeout(() => { + if (activeRequestId !== requestId) return; + abortBackgroundRequest(requestId); clearSendingState(); statusBadge.textContent = "SW Error"; statusBadge.className = "badge err"; @@ -853,12 +806,14 @@ async function sendRequest() { }, }, (res) => { + const runtimeError = chrome.runtime.lastError; + if (activeRequestId !== requestId) return; clearSendingState(); - if (chrome.runtime.lastError) { + if (runtimeError) { statusBadge.textContent = "Error"; statusBadge.className = "badge err"; responseMeta.textContent = "Connection error"; - responseBody.textContent = chrome.runtime.lastError.message; + responseBody.textContent = runtimeError.message; showToast("Connection error"); return; } @@ -881,6 +836,8 @@ async function sendRequest() { return; } + responseBody.dataset.raw = res.body || ""; + responseBody.dataset.bytes = String(res.bodyBytes ?? new Blob([res.body || ""]).size); const statusClass = res.status >= 200 && res.status < 300 ? "ok" @@ -893,7 +850,7 @@ async function sendRequest() { const headerLines = (res.headers || []).map(([k, v]) => `${k}: ${v}`); const headerBlock = [`HTTP ${res.status} ${res.statusText}`, ...headerLines]; responseHeaders.innerHTML = highlightHeaders(headerBlock); - const isJson = + const isJson = (res.body || "").length <= 200000 && ( (res.headers || []).some( ([k, v]) => k.toLowerCase() === "content-type" && v.toLowerCase().includes("json") ) || @@ -904,7 +861,7 @@ async function sendRequest() { } catch (err) { return false; } - })(); + })()); if (isJson) { const pretty = prettifyJsonMaybe(res.body || ""); responseBody.innerHTML = highlightJson(pretty); @@ -921,18 +878,11 @@ async function sendRequest() { const timestamp = `${now.toLocaleDateString()} ${now .toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) .toString()}`; - const entry = { - method, - url: finalUrl, - headers, - query, - body: bodyEl.value, - gqlMode, - gqlVariables: gqlVariables.value, - timestamp, - }; + const entry = { ...snapshot, timestamp }; historyItems = [entry, ...historyItems].slice(0, maxHistory); - chrome.storage.local.set({ history: historyItems }); + chrome.storage.local.set({ history: historyItems }, () => { + if (chrome.runtime.lastError) showToast("Response received but history could not be saved"); + }); renderHistory(); } } @@ -940,7 +890,7 @@ async function sendRequest() { saveState(); } catch (err) { - console.error("sendRequest error:", err); + if (activeRequestId !== requestId) return; clearSendingState(); statusBadge.textContent = "Client Error"; statusBadge.className = "badge err"; @@ -954,24 +904,16 @@ function shellEscape(str) { } function buildCurl() { - const method = methodEl.value; - const query = readKV(queryListEl); - const headers = readKV(headersListEl); - const finalUrl = buildUrl(urlEl.value, query) || urlEl.value; - const lines = [`curl -X ${method} ${shellEscape(finalUrl)}`]; - + const { method, url, headers, body } = prepareRequest(); + const lines = [`curl -X ${method} ${shellEscape(url)}`]; headers.forEach(({ key, value }) => lines.push(` -H ${shellEscape(`${key}: ${value}`)}`)); - - if (!isBodyless(method) && bodyEl.value.trim()) { - lines.push(` --data ${shellEscape(bodyEl.value)}`); - } - + if (body) lines.push(` --data-raw ${shellEscape(body)}`); return lines.join(" \\\n"); } async function copyCurl() { - const text = buildCurl(); try { + const text = buildCurl(); await navigator.clipboard.writeText(text); statusBadge.textContent = "cURL copied"; statusBadge.className = "badge ok"; @@ -979,11 +921,12 @@ async function copyCurl() { } catch (err) { statusBadge.textContent = "Clipboard blocked"; statusBadge.className = "badge warn"; - showToast("Clipboard blocked"); + showToast(err.message || "Clipboard blocked"); } } function resetForm() { + cancelCurrentRequest(); methodEl.value = "GET"; urlEl.value = currentOptions.defaultUrl || ""; queryListEl.innerHTML = ""; @@ -1003,6 +946,8 @@ function resetForm() { responseMeta.textContent = ""; responseHeaders.textContent = ""; responseBody.textContent = ""; + delete responseBody.dataset.raw; + delete responseBody.dataset.bytes; updatePreview(); saveState(); @@ -1061,7 +1006,7 @@ async function copyText(text) { } function downloadBody() { - const blob = new Blob([responseBody.innerText || ""], { type: "text/plain" }); + const blob = new Blob([responseBody.dataset.raw ?? responseBody.textContent ?? ""], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -1127,7 +1072,7 @@ themeBtn.addEventListener("click", cycleTheme); applyPresetBtn.addEventListener("click", applyPreset); clearHistoryBtn.addEventListener("click", clearHistory); copyHeadersBtn.addEventListener("click", () => copyText(responseHeaders.innerText)); -copyBodyBtn.addEventListener("click", () => copyText(responseBody.innerText)); +copyBodyBtn.addEventListener("click", () => copyText(responseBody.dataset.raw ?? responseBody.textContent)); saveBodyBtn.addEventListener("click", downloadBody); // Favorites @@ -1166,6 +1111,7 @@ envSelect.addEventListener("change", () => { chrome.storage.sync.set({ options: newOptions }); }); updateEnvVarCount(); + updatePreview(); }); // Auth @@ -1218,7 +1164,7 @@ confirmModal.addEventListener("click", (e) => { // Keyboard shortcuts document.addEventListener("keydown", (e) => { // Ctrl+Enter or Cmd+Enter: Send request - if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && !document.querySelector(".modal.show")) { e.preventDefault(); if (!isRequestInFlight) sendRequest(); return; @@ -1294,17 +1240,26 @@ chrome.storage.onChanged.addListener((changes, area) => { const newOpts = changes.options.newValue || {}; currentOptions = { ...DEFAULT_OPTIONS, ...newOpts }; maxHistory = clampHistorySize(currentOptions.historySize ?? DEFAULT_OPTIONS.historySize); - favorites = currentOptions.favorites || []; + favorites = Array.isArray(currentOptions.favorites) ? currentOptions.favorites : []; applyTheme(currentOptions.theme); renderFavorites(); renderEnvSelect(); + renderHistory(); + updatePreview(); } if (area === "sync" && changes.environments) { currentEnvironments = changes.environments.newValue || []; renderEnvSelect(); + renderHistory(); + updatePreview(); } if (area === "local" && changes.history) { historyItems = changes.history.newValue || []; renderHistory(); } }); + +window.addEventListener("pagehide", () => { + saveState(); + abortBackgroundRequest(activeRequestId); +}); diff --git a/popup/codegen.js b/popup/codegen.js index 3c7d3db..ee0b730 100644 --- a/popup/codegen.js +++ b/popup/codegen.js @@ -25,43 +25,9 @@ // โ”€โ”€ DOM helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - /** - * Read the current request form fields and return a normalised state object. - * This mirrors popup.js's form-reading pattern, but is intentionally copied - * here so codegen.js has zero dependency on popup.js internals. - */ + // Use the same prepared payload as the network request and preview. function getRequestState() { - const method = document.getElementById("method")?.value || "GET"; - const urlInput = (document.getElementById("url")?.value || "").trim(); - const bodyText = document.getElementById("body")?.value || ""; - - const query = Array.from( - document.querySelectorAll("#queryParams .kv-row") - ).map((row) => ({ - key: (row.querySelector(".kv-key")?.value || "").trim(), - value: row.querySelector(".kv-value")?.value || "", - })).filter((p) => p.key); - - const headers = Array.from( - document.querySelectorAll("#headers .kv-row") - ).map((row) => ({ - key: (row.querySelector(".kv-key")?.value || "").trim(), - value: row.querySelector(".kv-value")?.value || "", - })).filter((h) => h.key); - - // Build the final URL (with scheme + query params) - let finalUrl = urlInput; - if (finalUrl && !/^https?:\/\//i.test(finalUrl)) { - finalUrl = "https://" + finalUrl; - } - try { - const u = new URL(finalUrl); - query.forEach(({ key, value }) => u.searchParams.set(key, value)); - finalUrl = u.toString(); - } catch (_) { /* keep as-is if URL is invalid */ } - - const isBodyless = ["GET", "HEAD"].includes(method); - return { method, url: finalUrl, headers, body: isBodyless ? "" : bodyText }; + return window.prepareRequest(); } // โ”€โ”€ Snippet generators โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -74,7 +40,7 @@ return headers.reduce((acc, { key, value }) => { acc[key] = value; return acc; - }, {}); + }, Object.create(null)); } function getContentType(headers) { @@ -82,13 +48,17 @@ return ct ? ct.value : "text/plain"; } + function phpString(value) { + return "'" + value.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'"; + } + const generators = { curl({ method, url, headers, body }) { const lines = ["curl -X " + method + " " + shellEscape(url)]; headers.forEach(({ key, value }) => lines.push(" -H " + shellEscape(key + ": " + value)) ); - if (body.trim()) { + if (body.length) { lines.push(" --data-raw " + shellEscape(body)); } return lines.join(" \\\n"); @@ -104,7 +74,7 @@ ); args.push("headers=headers"); } - if (body.trim()) { + if (body.length) { lines.push("payload = " + JSON.stringify(body), ""); args.push("data=payload"); } @@ -122,10 +92,10 @@ "javascript-fetch"({ method, url, headers, body }) { const opts = { method }; if (headers.length) opts.headers = toHeadersObj(headers); - if (body.trim()) opts.body = body; + if (body.length) opts.body = body; return [ "fetch(" + JSON.stringify(url) + ", " + JSON.stringify(opts, null, 2) + ")", - " .then(res => res.json())", + " .then(res => res.text())", " .then(data => console.log(data))", " .catch(err => console.error('Error:', err));", ].join("\n"); @@ -140,7 +110,7 @@ " headers: " + JSON.stringify(toHeadersObj(headers), null, 4) + "," ); } - if (body.trim()) { + if (body.length) { lines.push(" data: " + JSON.stringify(body) + ","); } lines.push(" success: function(data) { console.log(data); },"); @@ -153,19 +123,19 @@ const lines = [ " " '" + key + ": " + value + "'") + .map(({ key, value }) => " " + phpString(key + ": " + value)) .join(",\n"); lines.push("curl_setopt($ch, CURLOPT_HTTPHEADER, [\n" + hs + "\n]);"); } - if (body.trim()) { + if (body.length) { lines.push( - "curl_setopt($ch, CURLOPT_POSTFIELDS, " + JSON.stringify(body) + ");" + "curl_setopt($ch, CURLOPT_POSTFIELDS, " + phpString(body) + ");" ); } lines.push( @@ -189,9 +159,9 @@ ({ key }) => key.toLowerCase() !== "content-type" ); let bodyVar = "null"; - if (body.trim()) { + if (body.length || ["POST", "PUT", "PATCH"].includes(method)) { const ct = getContentType(headers); - lines.push('MediaType mediaType = MediaType.parse("' + ct + '");'); + lines.push('MediaType mediaType = MediaType.parse(' + JSON.stringify(ct) + ');'); lines.push( "RequestBody body = RequestBody.create(" + JSON.stringify(body) + @@ -203,7 +173,7 @@ lines.push("Request request = new Request.Builder()"); lines.push(" .url(" + JSON.stringify(url) + ")"); requestHeaders.forEach(({ key, value }) => - lines.push(' .addHeader("' + key + '", "' + value + '")') + lines.push(' .addHeader(' + JSON.stringify(key) + ', ' + JSON.stringify(value) + ')') ); // .method() works for every HTTP verb (including HEAD/OPTIONS, which have // no dedicated builder shorthand), unlike .get()/.post()/etc. @@ -231,17 +201,17 @@ ); requestHeaders.forEach(({ key, value }) => lines.push( - 'request.Headers.TryAddWithoutValidation("' + key + '", "' + value + '");' + 'request.Headers.TryAddWithoutValidation(' + JSON.stringify(key) + ', ' + JSON.stringify(value) + ');' ) ); - if (body.trim()) { - const ct = getContentType(headers); + if (body.length) { + const ct = getContentType(headers).split(";")[0].trim(); lines.push( "request.Content = new StringContent(" + JSON.stringify(body) + - ', System.Text.Encoding.UTF8, "' + - ct + - '");' + ', System.Text.Encoding.UTF8, ' + + JSON.stringify(ct) + + ');' ); } lines.push( @@ -264,7 +234,7 @@ " headers: " + JSON.stringify(toHeadersObj(headers), null, 4) + "," ); } - if (body.trim()) { + if (body.length) { cfg.push(" data: " + JSON.stringify(body)); } lines.push("axios({", ...cfg, "})"); @@ -277,9 +247,11 @@ // โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function generate(lang) { - const state = getRequestState(); - const gen = generators[lang]; - return gen ? gen(state) : "(unsupported language: " + lang + ")"; + try { + const state = getRequestState(); + const gen = generators[lang]; + return gen ? gen(state) : "(unsupported language: " + lang + ")"; + } catch (err) { return err.message; } } window.QuickCodegen = { generate, LANGS, generators }; diff --git a/popup/request.js b/popup/request.js new file mode 100644 index 0000000..9404b2e --- /dev/null +++ b/popup/request.js @@ -0,0 +1,61 @@ +/* Shared request preparation for sending, previewing and code generation. */ +(function () { + "use strict"; + const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; + function buildUrl(raw, query = []) { + let value = String(raw || "").trim(); + if (!value || /\{\{[^}]+\}\}/.test(value)) return null; + if (!/^https?:\/\//i.test(value)) { + // A host with a numeric port is not a URI scheme. + if (/^[a-z][a-z\d+.-]*:/i.test(value) && !/^[^/:]+:\d+(?:[/?#]|$)/.test(value)) return null; + value = `https://${value}`; + } + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null; + const replaced = new Set(); + for (const { key, value } of query) { + if (!replaced.has(key)) url.searchParams.delete(key); + replaced.add(key); + url.searchParams.append(key, value); + } + url.hash = ""; + return url.toString(); + } catch (_) { return null; } + } + function substituteVars(text, vars = []) { + const values = new Map(vars.map(({ key, value }) => [key, value])); + return String(text || "").replace(/\{\{([^}]+)\}\}/g, + (match, name) => values.has(name.trim()) ? String(values.get(name.trim())) : match); + } + function prepare(state, vars = []) { + const method = state.gqlMode ? "POST" : state.method; + if (!METHODS.includes(method)) throw new Error("Unsupported HTTP method"); + const replace = text => substituteVars(text, vars); + const query = (state.query || []).map(({ key, value }) => ({ key: replace(key), value: replace(value) })); + const url = buildUrl(replace(state.url), query); + if (!url) throw new Error("Enter a valid HTTP or HTTPS URL and resolve its environment variables"); + const headers = []; + for (const item of state.headers || []) { + const key = replace(item.key).trim(); + const value = replace(item.value); + if (!/^[!#$%&'*+.^_`|~\w-]+$/.test(key) || /[\r\n\0]/.test(value)) throw new Error("Invalid request header"); + const index = headers.findIndex(h => h.key.toLowerCase() === key.toLowerCase()); + if (index >= 0) headers[index] = { key, value }; + else headers.push({ key, value }); + } + let body = ["GET", "HEAD"].includes(method) ? "" : replace(state.body); + if (state.gqlMode) { + let variables; + try { variables = JSON.parse(replace(state.gqlVariables).trim() || "{}"); } + catch (_) { throw new Error("GraphQL variables must be valid JSON"); } + if (!variables || Array.isArray(variables) || typeof variables !== "object") throw new Error("GraphQL variables must be a JSON object"); + body = JSON.stringify({ query: body, variables }); + const index = headers.findIndex(h => h.key.toLowerCase() === "content-type"); + if (index >= 0) headers.splice(index, 1); + headers.push({ key: "Content-Type", value: "application/json" }); + } + return { method, url, headers, body }; + } + window.QuickRequest = { buildUrl, substituteVars, prepare, METHODS }; +})(); diff --git a/popup/share.js b/popup/share.js index e5642c3..6630cbf 100644 --- a/popup/share.js +++ b/popup/share.js @@ -66,9 +66,18 @@ */ function decode(encoded) { try { + if (typeof encoded !== "string" || encoded.length > 2 * 1024 * 1024) return null; const json = decodeURIComponent(escape(atob(encoded.trim()))); const obj = JSON.parse(json); - if (typeof obj !== "object" || !obj.method || !obj.url) return null; + if (!obj || Array.isArray(obj) || typeof obj !== "object") return null; + if (obj.v !== FORMAT_VERSION || !["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"].includes(obj.method)) return null; + if (typeof obj.url !== "string" || !obj.url.trim()) return null; + for (const key of ["query", "headers"]) { + if (obj[key] !== undefined && (!Array.isArray(obj[key]) || obj[key].length > 200 || obj[key].some(item => + !item || typeof item.key !== "string" || typeof item.value !== "string"))) return null; + } + if (["body", "gqlVariables"].some(key => obj[key] !== undefined && typeof obj[key] !== "string")) return null; + if (obj.gqlMode !== undefined && typeof obj.gqlMode !== "boolean") return null; return obj; } catch (_) { return null; @@ -83,6 +92,7 @@ * (all defined at window scope in popup.js and available to subsequent scripts). */ function applySnapshot(snap) { + if (!decode(encode(snap))) return false; const methodEl = document.getElementById("method"); const urlEl = document.getElementById("url"); const bodyEl = document.getElementById("body"); @@ -109,8 +119,8 @@ // GraphQL mode if (snap.gqlMode && typeof setGqlMode === "function") { - setGqlMode(true); if (gqlVarEl) gqlVarEl.value = snap.gqlVariables || ""; + setGqlMode(true); } else if (typeof setGqlMode === "function") { setGqlMode(false); } diff --git a/popup/timing.js b/popup/timing.js index ed93ec6..1d5a6c8 100644 --- a/popup/timing.js +++ b/popup/timing.js @@ -48,10 +48,10 @@ // โ”€โ”€ Render the timing panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - function render(panel, elapsed, bodyText) { + function render(panel, elapsed, bodyText, bodyBytes) { const tier = getTier(elapsed); const barPct = Math.min(100, (elapsed / 5000) * 100).toFixed(1); - const size = new Blob([bodyText]).size; + const size = Number.isFinite(bodyBytes) ? bodyBytes : new Blob([bodyText]).size; const throughput = elapsed > 0 ? ((size / 1024) / (elapsed / 1000)).toFixed(1) : "โ€”"; @@ -135,7 +135,8 @@ panel.innerHTML = ""; return; } - render(panel, elapsed, bodyEl ? (bodyEl.innerText || "") : ""); + render(panel, elapsed, bodyEl ? (bodyEl.dataset.raw ?? bodyEl.textContent ?? "") : "", + bodyEl?.dataset.bytes === undefined ? undefined : Number(bodyEl.dataset.bytes)); }); observer.observe(metaEl, { diff --git a/popup/validators.js b/popup/validators.js index bc0b96b..5a2547b 100644 --- a/popup/validators.js +++ b/popup/validators.js @@ -108,37 +108,18 @@ function validateCSS(text) { const trimmed = text.trim(); if (!trimmed) return { valid: false, message: "Empty input." }; - let el = null; try { - el = document.createElement("style"); - el.setAttribute("data-codegen-temp", "1"); - // Use a shadow host to avoid polluting page styles - const host = document.createElement("div"); - host.style.display = "none"; - document.body.appendChild(host); - const shadow = host.attachShadow({ mode: "closed" }); - shadow.appendChild(el); - el.textContent = trimmed; - - const rules = el.sheet ? el.sheet.cssRules.length : 0; - document.body.removeChild(host); - - if (rules === 0 && trimmed.includes("{")) { - return { - valid: false, - message: "CSS may contain errors.", - detail: - "0 valid rules found โ€” check for missing braces, unknown at-rules, or malformed selectors.", - }; - } + // Detached stylesheet prevents response CSS from styling the extension or loading URLs. + const sheet = new CSSStyleSheet(); + sheet.replaceSync(trimmed); + const rules = sheet.cssRules.length; return { - valid: true, - message: "CSS parsed successfully.", - detail: rules + " rule" + (rules !== 1 ? "s" : "") + " found.", + valid: rules > 0, + message: rules ? "CSS parsed. Invalid declarations may have been discarded." : "No CSS rules could be parsed.", + detail: rules + " rules parsed. This is a browser parse check, not a full CSS validator.", }; } catch (err) { - if (el && el.parentNode) el.parentNode.removeChild(el); - return { valid: false, message: "CSS validation threw an exception.", detail: err.message }; + return { valid: false, message: "CSS parsing failed.", detail: err.message }; } } @@ -150,17 +131,17 @@ */ function detectContentType() { const headerText = ( - document.getElementById("responseHeaders")?.innerText || "" + document.getElementById("responseHeaders")?.textContent || "" ).toLowerCase(); - if (headerText.includes("application/json") || headerText.includes("text/json")) return "json"; + if (headerText.includes("+json") || headerText.includes("application/json") || headerText.includes("text/json")) return "json"; if (headerText.includes("application/xml") || headerText.includes("text/xml") || headerText.includes("+xml")) return "xml"; if (headerText.includes("text/html")) return "html"; if (headerText.includes("text/css")) return "css"; // Fallback: try to guess from body content - const body = (document.getElementById("responseBody")?.innerText || "").trimStart(); + const body = (document.getElementById("responseBody")?.dataset.raw ?? document.getElementById("responseBody")?.textContent ?? "").trimStart(); if (body.startsWith("{") || body.startsWith("[")) return "json"; - if (body.startsWith("])/i.test(body)) return "html"; + if (body.startsWith("<")) return "xml"; return "json"; // safe default } @@ -187,7 +168,7 @@ if (!validateBtn || !modal) return; function getBodyText() { - return document.getElementById("responseBody")?.innerText || ""; + return document.getElementById("responseBody")?.dataset.raw ?? document.getElementById("responseBody")?.textContent ?? ""; } function runValidation() { diff --git a/release.sh b/release.sh index d85172f..3020f52 100755 --- a/release.sh +++ b/release.sh @@ -7,6 +7,7 @@ # ./release.sh --no-bump # skip version bump set -euo pipefail +cd "$(dirname "$0")" # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Config @@ -27,6 +28,9 @@ INCLUDE_FILES=( options.css background.js defaults.js + popup/ + LICENSE + PRIVACY_POLICY.md icons/ ) @@ -69,38 +73,31 @@ echo "Current version : $CURRENT_VERSION" NEW_VERSION=$(bump_semver "$CURRENT_VERSION" "$BUMP_TYPE") echo "New version : $NEW_VERSION" -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Bump version in manifest.json and package.json -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -if [[ "$BUMP_TYPE" != "--no-bump" ]]; then - TMP_MANIFEST=$(mktemp) - TMP_PKG=$(mktemp) +# Validate source before changing version metadata. +npm run lint +npm test -- --runInBand - jq --arg v "$NEW_VERSION" '.version = $v' "$MANIFEST" > "$TMP_MANIFEST" - mv "$TMP_MANIFEST" "$MANIFEST" - - jq --arg v "$NEW_VERSION" '.version = $v' "$PKG" > "$TMP_PKG" - mv "$TMP_PKG" "$PKG" - - echo "Bumped version in $MANIFEST and $PKG" +if [[ "$BUMP_TYPE" != "--no-bump" ]]; then + node - "$NEW_VERSION" <<'NODE' +const fs = require("fs"); +const version = process.argv[2]; +for (const file of ["manifest.json", "package.json", "package-lock.json"]) { + const json = JSON.parse(fs.readFileSync(file, "utf8")); + json.version = version; + if (json.packages && json.packages[""]) json.packages[""].version = version; + fs.writeFileSync(file, JSON.stringify(json, null, 2) + "\n"); +} +NODE fi -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Run tests โ€” abort on failure -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -echo "" -echo "Running tests..." -npm test --silent -echo "All tests passed." - # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Build output directory and zip archive # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ZIP_NAME="quick-api-client-v${NEW_VERSION}.zip" ZIP_PATH="${DIST_DIR}/${ZIP_NAME}" -rm -rf "$DIST_DIR" mkdir -p "$DIST_DIR" +rm -f "$ZIP_PATH" # Build the zip directly from source โ€” no staging copy needed zip -r "$ZIP_PATH" "${INCLUDE_FILES[@]}" \ @@ -119,7 +116,7 @@ if [[ "$BUMP_TYPE" != "--no-bump" ]] && command -v git >/dev/null 2>&1; then echo "" read -r -p "Create git tag v${NEW_VERSION} and commit version bump? [y/N] " CONFIRM if [[ "${CONFIRM,,}" == "y" ]]; then - git add "$MANIFEST" "$PKG" + git add "$MANIFEST" "$PKG" package-lock.json git commit -m "chore: bump version to v${NEW_VERSION}" git tag "v${NEW_VERSION}" echo "Tagged: v${NEW_VERSION}" diff --git a/scripts/browser-smoke.js b/scripts/browser-smoke.js new file mode 100644 index 0000000..3af379e --- /dev/null +++ b/scripts/browser-smoke.js @@ -0,0 +1,102 @@ +/* Runs the packaged extension against a local HTTP fixture, without external API traffic. */ +const { chromium } = require('playwright'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); +const assert = require('assert/strict'); +const { execFileSync } = require('child_process'); + +(async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'quick-api-smoke-')); + const extension = path.join(temp, 'extension'); + fs.mkdirSync(extension); + const manifest = require('../manifest.json'); + const archive = path.resolve(__dirname, `../dist/quick-api-client-v${manifest.version}.zip`); + execFileSync('unzip', ['-q', archive, '-d', extension]); + // Pregrant only the local fixture host so browser permission UI does not block automation. + const testManifest = JSON.parse(fs.readFileSync(path.join(extension, 'manifest.json'))); + testManifest.host_permissions = ['http://127.0.0.1/*']; + fs.writeFileSync(path.join(extension, 'manifest.json'), JSON.stringify(testManifest)); + const received = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + received.push({ path: req.url, body, headers: req.headers, method: req.method }); + if (req.url === '/slow') { + const timer = setTimeout(() => res.end('late'), 1500); + res.on('close', () => clearTimeout(timer)); + } else if (req.url === '/empty') { + res.writeHead(204); res.end(); + } else if (req.url === '/error') { + res.writeHead(422, { 'Content-Type': 'application/json' }); res.end('{"error":"validation"}'); + } else { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ body, method: req.method, url: req.url })); + } + }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + let context; + try { + context = await chromium.launchPersistentContext(path.join(temp, 'profile'), { + executablePath: chromium.executablePath(), headless: true, + ignoreDefaultArgs: ["--disable-extensions"], + args: [`--disable-extensions-except=${extension}`, `--load-extension=${extension}`, '--no-sandbox'], + }); + const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker'); + const id = new URL(worker.url()).host; + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`chrome-extension://${id}/popup.html?tab=1`); + await page.waitForFunction(() => document.getElementById('url').value !== ''); + await page.locator('#url').fill(base + '/echo'); + await page.locator('#method').selectOption('POST'); + await page.locator('#body').fill(' { "n": 9007199254740993 } '); + await page.locator('#sendBtn').click(); + await page.waitForFunction(() => document.getElementById('statusBadge').textContent.startsWith('200')); + assert.equal(received.at(-1).body, ' { "n": 9007199254740993 } '); + assert.equal(await page.locator('#timingPanel').isVisible(), true); + + await page.locator('#gqlToggleBtn').click(); + await page.locator('#body').fill('query { user { id } }'); + await page.locator('#gqlVariables').fill('{"id":42}'); + await page.locator('#sendBtn').click(); + await page.waitForFunction(() => document.getElementById('sendBtn').disabled === false); + assert.deepEqual(JSON.parse(received.at(-1).body), { query: 'query { user { id } }', variables: { id: 42 } }); + assert.equal(received.at(-1).headers['content-type'], 'application/json'); + const snippets = await page.evaluate(() => ({ curl: window.buildCurl(), generated: window.QuickCodegen.generate('curl') })); + assert.equal(snippets.curl, snippets.generated); + const roundtrip = await page.evaluate(() => { + const snapshot = window.QuickShare.snapshot(); + const decoded = window.QuickShare.decode(window.QuickShare.encode(snapshot)); + return window.QuickShare.applySnapshot(decoded) && document.getElementById('gqlVariables').value; + }); + assert.equal(roundtrip, '{"id":42}'); + + await page.locator('#gqlToggleBtn').click(); + await page.locator('#url').fill(base + '/slow'); + await page.locator('#sendBtn').click(); + await page.locator('#cancelBtn').click(); + await page.locator('#url').fill(base + '/error'); + await page.locator('#sendBtn').click(); + await page.waitForFunction(() => document.getElementById('statusBadge').textContent.startsWith('422')); + await page.locator('#url').fill(base + '/empty'); + await page.locator('#sendBtn').click(); + await page.waitForFunction(() => document.getElementById('statusBadge').textContent.startsWith('204')); + assert.equal(await page.locator('#responseBody').textContent(), ''); + const css = await page.evaluate(() => window.QuickValidators.validateCSS('body { color: red; }')); + assert.equal(css.valid, true); + assert.equal(await page.locator('style[data-codegen-temp]').count(), 0); + assert.deepEqual(errors, []); + console.log('Chromium package smoke passed - real worker requests, raw body, GraphQL, cURL, sharing, cancel/resend, HTTP 422/204, timing, CSS and no page errors'); + } finally { + if (context) await context.close(); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + fs.rmSync(temp, { recursive: true, force: true }); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/background.test.js b/tests/background.test.js new file mode 100644 index 0000000..3663a49 --- /dev/null +++ b/tests/background.test.js @@ -0,0 +1,67 @@ +/** @jest-environment node */ +const fs = require('fs'); +const vm = require('vm'); +const path = require('path'); +let listener, fetchMock; +const sender = { id: 'test-extension', url: 'chrome-extension://test-extension/popup.html' }; +const payload = { requestId: 'one', method: 'GET', url: 'https://example.com', headers: {}, timeoutMs: 1000 }; +beforeEach(() => { + fetchMock = jest.fn(); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../background.js'), 'utf8'), { + chrome: { runtime: { id: sender.id, getURL: () => 'chrome-extension://test-extension/', onMessage: { addListener: fn => { listener = fn; } } } }, + fetch: fetchMock, URL, Headers, TextEncoder, TextDecoder, AbortController, performance, setTimeout, clearTimeout, + }); +}); +const send = (overrides = {}) => new Promise(resolve => listener({ type: 'api-request', payload: { ...payload, ...overrides } }, sender, resolve)); +test('rejects malformed messages and unsupported protocols without fetching', async () => { + const reply = jest.fn(); + listener({ type: 'api-request' }, sender, reply); + expect(reply.mock.calls[0][0].ok).toBe(false); + expect((await send({ url: 'file:///etc/passwd' })).ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); +}); +test('ignores messages from web pages', () => { + listener({ type: 'api-request', payload }, { ...sender, url: 'https://evil.example' }, jest.fn()); + expect(fetchMock).not.toHaveBeenCalled(); +}); +test('returns HTTP errors as responses and measures Unicode bytes', async () => { + fetchMock.mockResolvedValue(new Response('ฤ…', { status: 400 })); + const result = await send(); + expect(result).toMatchObject({ ok: true, status: 400, body: 'ฤ…', bodyBytes: 2 }); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: 'omit', cache: 'no-store' }); +}); +test('cancellation differs from timeout and releases the request ID', async () => { + fetchMock.mockImplementation((url, { signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))))); + const pending = send(); + listener({ type: 'cancel-request', payload: { requestId: 'one' } }, sender, jest.fn()); + expect(await pending).toMatchObject({ ok: false, cancelled: true, error: 'Request cancelled' }); + fetchMock.mockResolvedValue(new Response('next')); + expect((await send()).body).toBe('next'); +}); +test('timeout aborts a stalled request', async () => { + jest.useFakeTimers(); + // Reload with fake timers in the worker context. + const code = fs.readFileSync(path.join(__dirname, '../background.js'), 'utf8'); + vm.runInNewContext(code, { chrome: { runtime: { id: sender.id, getURL: () => 'chrome-extension://test-extension/', onMessage: { addListener: fn => { listener = fn; } } } }, fetch: fetchMock, URL, Headers, TextEncoder, TextDecoder, AbortController, performance, setTimeout, clearTimeout }); + fetchMock.mockImplementation((url, { signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))))); + const pending = send(); + await jest.advanceTimersByTimeAsync(1000); + expect(await pending).toMatchObject({ ok: false, cancelled: false, error: 'Request timed out after 1s' }); + jest.useRealTimers(); +}); +test('rejects duplicate request IDs without replacing the original controller', async () => { + fetchMock.mockImplementation((url, { signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))))); + const first = send(); + expect((await send()).error).toMatch(/already in use/); + listener({ type: 'cancel-request', payload: { requestId: 'one' } }, sender, jest.fn()); + expect((await first).cancelled).toBe(true); +}); +test('bounds streamed responses and cancels oversized bodies', async () => { + const cancel = jest.fn(); + let chunks = 0; + fetchMock.mockResolvedValue(new Response(new ReadableStream({ pull(controller) { chunks++; controller.enqueue(new Uint8Array(1024 * 1024)); }, cancel }))); + const result = await send(); + expect(result).toMatchObject({ ok: false, error: 'Response exceeds the 5 MiB limit' }); + expect(cancel).toHaveBeenCalled(); + expect(chunks).toBeLessThan(9); +}); diff --git a/tests/codegen.test.js b/tests/codegen.test.js index fb203b9..5eb39c9 100644 --- a/tests/codegen.test.js +++ b/tests/codegen.test.js @@ -1,162 +1,8 @@ -/** - * Tests for popup/codegen.js โ€” pure generator functions. - * We extract the pure generator internals for unit testing. - */ - -// โ”€โ”€ Helpers (mirrors codegen.js internals) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function shellEscape(str) { - return "'" + str.replace(/'/g, "'\"'\"'") + "'"; -} - -function toHeadersObj(headers) { - return headers.reduce((acc, { key, value }) => { acc[key] = value; return acc; }, {}); -} - -function getContentType(headers) { - const ct = headers.find((h) => h.key.toLowerCase() === "content-type"); - return ct ? ct.value : "text/plain"; -} - -function codegenCurl({ method, url, headers, body }) { - const lines = ["curl -X " + method + " " + shellEscape(url)]; - headers.forEach(({ key, value }) => - lines.push(" -H " + shellEscape(key + ": " + value)) - ); - if (body.trim()) lines.push(" --data-raw " + shellEscape(body)); - return lines.join(" \\\n"); -} - -function codegenPython({ method, url, headers, body }) { - const lines = ["import requests", ""]; - const args = [JSON.stringify(url)]; - if (headers.length) { lines.push("headers = " + JSON.stringify(toHeadersObj(headers), null, 4), ""); args.push("headers=headers"); } - if (body.trim()) { lines.push("payload = " + JSON.stringify(body), ""); args.push("data=payload"); } - lines.push("response = requests." + method.toLowerCase() + "(" + args.join(", ") + ")"); - lines.push("print(response.status_code)", "print(response.text)"); - return lines.join("\n"); -} - -function codegenFetch({ method, url, headers, body }) { - const opts = { method }; - if (headers.length) opts.headers = toHeadersObj(headers); - if (body.trim()) opts.body = body; - return [ - "fetch(" + JSON.stringify(url) + ", " + JSON.stringify(opts, null, 2) + ")", - " .then(res => res.json())", - " .then(data => console.log(data))", - " .catch(err => console.error('Error:', err));", - ].join("\n"); -} - -function codegenPhp({ method, url, headers, body }) { - const lines = [ - " " '" + key + ": " + value + "'").join(",\n"); - lines.push("curl_setopt($ch, CURLOPT_HTTPHEADER, [\n" + hs + "\n]);"); - } - if (body.trim()) lines.push("curl_setopt($ch, CURLOPT_POSTFIELDS, " + JSON.stringify(body) + ");"); - lines.push("$response = curl_exec($ch);", "curl_close($ch);", "echo $response;"); - return lines.join("\n"); -} - -function codegenJava({ method, url, headers, body }) { - const lines = ["import okhttp3.*;", "", "OkHttpClient client = new OkHttpClient();", ""]; - const requestHeaders = headers.filter(({ key }) => key.toLowerCase() !== "content-type"); - let bodyVar = "null"; - if (body.trim()) { - const ct = getContentType(headers); - lines.push('MediaType mediaType = MediaType.parse("' + ct + '");'); - lines.push("RequestBody body = RequestBody.create(" + JSON.stringify(body) + ", mediaType);"); - lines.push(""); - bodyVar = "body"; - } - lines.push("Request request = new Request.Builder()"); - lines.push(" .url(" + JSON.stringify(url) + ")"); - requestHeaders.forEach(({ key, value }) => lines.push(' .addHeader("' + key + '", "' + value + '")')); - lines.push(' .method("' + method + '", ' + bodyVar + ")"); - lines.push(" .build();", ""); - lines.push("try (Response response = client.newCall(request).execute()) {"); - lines.push(" System.out.println(response.body().string());"); - lines.push("}"); - return lines.join("\n"); -} - -function codegenCsharp({ method, url, headers, body }) { - const requestHeaders = headers.filter(({ key }) => key.toLowerCase() !== "content-type"); - const lines = ["using var client = new HttpClient();", ""]; - lines.push( - "var request = new HttpRequestMessage(new HttpMethod(" + JSON.stringify(method) + "), " + JSON.stringify(url) + ");" - ); - requestHeaders.forEach(({ key, value }) => - lines.push('request.Headers.TryAddWithoutValidation("' + key + '", "' + value + '");') - ); - if (body.trim()) { - const ct = getContentType(headers); - lines.push( - "request.Content = new StringContent(" + JSON.stringify(body) + ', System.Text.Encoding.UTF8, "' + ct + '");' - ); - } - lines.push("", "var response = await client.SendAsync(request);"); - lines.push("var result = await response.Content.ReadAsStringAsync();", "Console.WriteLine(result);"); - return lines.join("\n"); -} - -function codegenNodeAxios({ method, url, headers, body }) { - const lines = ["const axios = require('axios');", ""]; - const cfg = [" method: '" + method.toLowerCase() + "',", " url: " + JSON.stringify(url) + ","]; - if (headers.length) cfg.push(" headers: " + JSON.stringify(toHeadersObj(headers), null, 4) + ","); - if (body.trim()) cfg.push(" data: " + JSON.stringify(body)); - lines.push("axios({", ...cfg, "})"); - lines.push(" .then(res => console.log(JSON.stringify(res.data)))"); - lines.push(" .catch(err => console.error(err));"); - return lines.join("\n"); -} - -// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -describe("shellEscape", () => { - test("wraps bare string in single quotes", () => { - expect(shellEscape("hello world")).toBe("'hello world'"); - }); - test("escapes embedded single quotes", () => { - expect(shellEscape("it's")).toBe("'it'\"'\"'s'"); - }); - test("handles empty string", () => { - expect(shellEscape("")).toBe("''"); - }); -}); - -describe("toHeadersObj", () => { - test("converts array to object", () => { - const result = toHeadersObj([ - { key: "Accept", value: "application/json" }, - { key: "X-Token", value: "abc" }, - ]); - expect(result).toEqual({ Accept: "application/json", "X-Token": "abc" }); - }); - test("returns empty object for empty array", () => { - expect(toHeadersObj([])).toEqual({}); - }); -}); - -describe("getContentType", () => { - test("returns header value when present", () => { - expect(getContentType([{ key: "Content-Type", value: "application/json" }])).toBe("application/json"); - }); - test("is case-insensitive for key lookup", () => { - expect(getContentType([{ key: "content-type", value: "text/xml" }])).toBe("text/xml"); - }); - test("defaults to text/plain", () => { - expect(getContentType([])).toBe("text/plain"); - }); -}); +require("../popup/codegen.js"); +const { + curl: codegenCurl, python: codegenPython, "javascript-fetch": codegenFetch, + php: codegenPhp, java: codegenJava, csharp: codegenCsharp, "node-axios": codegenNodeAxios, +} = window.QuickCodegen.generators; describe("codegenCurl", () => { const base = { method: "GET", url: "https://api.example.com/users", headers: [], body: "" }; @@ -231,7 +77,7 @@ describe("codegenFetch", () => { test("generates fetch call", () => { const output = codegenFetch({ method: "GET", url: "https://api.example.com", headers: [], body: "" }); expect(output).toContain("fetch("); - expect(output).toContain(".then(res => res.json())"); + expect(output).toContain(".then(res => res.text())"); }); test("includes body in opts for POST", () => { @@ -350,3 +196,18 @@ describe("codegenNodeAxios", () => { expect(output).toContain("data:"); }); }); + +test('Java creates an empty body for methods that require one', () => { + expect(codegenJava({ method: 'POST', url: 'https://example.com', headers: [], body: '' })).toContain('.method("POST", body)'); +}); +test('Java and C# escape quoted header values', () => { + const request = { method: 'GET', url: 'https://example.com', headers: [{ key: 'If-Match', value: '"etag"' }], body: '' }; + expect(codegenJava(request)).toContain('"\\"etag\\""'); + expect(codegenCsharp(request)).toContain('"\\"etag\\""'); +}); +test('PHP prevents variable interpolation and escapes literal quotes', () => { + const output = codegenPhp({ method: 'POST', url: 'https://example.com/$id', headers: [{ key: 'X-Name', value: "O'Reilly" }], body: '$secret' }); + expect(output).toContain("'https://example.com/$id'"); + expect(output).toContain("'X-Name: O\\'Reilly'"); + expect(output).toContain("CURLOPT_POSTFIELDS, '$secret'"); +}); diff --git a/tests/load-popup.js b/tests/load-popup.js new file mode 100644 index 0000000..2aa2778 --- /dev/null +++ b/tests/load-popup.js @@ -0,0 +1,17 @@ +const fs = require('fs'); +const path = require('path'); + +module.exports = function loadPopup() { + document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, '../popup.html'), 'utf8'); + window.matchMedia = jest.fn(() => ({ matches: false, addEventListener: jest.fn() })); + window.crypto.randomUUID = require('crypto').randomUUID; + chrome.storage.sync.get.mockImplementation((keys, cb) => cb({})); + chrome.storage.local.get.mockImplementation((keys, cb) => cb({})); + chrome.storage.local.set.mockImplementation((data, cb) => { if (cb) cb(); }); + chrome.storage.sync.set.mockImplementation((data, cb) => { if (cb) cb(); }); + chrome.permissions.request.mockImplementation((origins, cb) => cb(true)); + for (const file of ['defaults.js', 'popup/request.js', 'popup.js']) { + window.eval(fs.readFileSync(path.join(__dirname, '..', file), 'utf8')); + } + return window; +}; diff --git a/tests/options.test.js b/tests/options.test.js new file mode 100644 index 0000000..693e94d --- /dev/null +++ b/tests/options.test.js @@ -0,0 +1,47 @@ +const fs = require('fs'); +const path = require('path'); +document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, '../options.html'), 'utf8'); +window.matchMedia = jest.fn(() => ({ matches: false, addEventListener: jest.fn() })); +let data; +chrome.storage.sync.get.mockImplementation((key, cb) => cb(data)); +chrome.storage.sync.set.mockImplementation((update, cb) => { data = { ...data, ...update }; if (cb) cb(); }); +window.eval(fs.readFileSync(path.join(__dirname, '../defaults.js'), 'utf8')); +window.eval(fs.readFileSync(path.join(__dirname, '../options.js'), 'utf8')); +const el = id => document.getElementById(id); +beforeEach(() => { + data = { options: { activeEnvironment: 'Test', favorites: [{ name: 'Keep me' }] }, environments: [{ name: 'Test', vars: [{ key: 'HOST', value: 'https://example.com' }] }] }; + window.loadOptions(); + window.loadEnvironments(); +}); +test('renaming an active environment keeps it selected', () => { + el('envNameInput').value = 'Renamed'; + el('saveEnvBtn').click(); + expect(data.options.activeEnvironment).toBe('Renamed'); + expect(data.environments[0].name).toBe('Renamed'); + expect(el('status').textContent).toBe('Environment saved.'); +}); +test('duplicate environment names do not get saved', () => { + data.environments.push({ name: 'Other', vars: [] }); + window.loadEnvironments(); + el('envNameInput').value = 'Other'; + el('saveEnvBtn').click(); + expect(el('status').textContent).toMatch(/already exists/); + expect(data.environments[0].name).toBe('Test'); +}); +test('failed saves report failure without a success message', () => { + chrome.storage.sync.set.mockImplementationOnce((update, cb) => { + chrome.runtime.lastError = { message: 'Quota exceeded' }; + cb(); + delete chrome.runtime.lastError; + }); + el('saveEnvBtn').click(); + expect(el('status').textContent).toMatch(/Env save failed/); +}); +test('reset preferences preserves favorites', () => { + window.resetOptions(); + expect(data.options.favorites).toEqual([{ name: 'Keep me' }]); +}); +test('invalid numeric settings normalize to finite defaults', () => { + expect(window.clampTimeoutMs(NaN)).toBe(15000); + expect(window.clampHistorySize(3.8)).toBe(3); +}); diff --git a/tests/package.test.js b/tests/package.test.js new file mode 100644 index 0000000..f174c12 --- /dev/null +++ b/tests/package.test.js @@ -0,0 +1,24 @@ +/** @jest-environment node */ +const fs = require('fs'); +const path = require('path'); +const root = path.join(__dirname, '..'); +test('release includes every script, stylesheet and declared entry point', () => { + const script = fs.readFileSync(path.join(root, 'release.sh'), 'utf8'); + const included = script.match(/INCLUDE_FILES=\(([\s\S]*?)\)/)[1].trim().split(/\s+/); + const manifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'))); + const resources = ['manifest.json', manifest.background.service_worker, manifest.action.default_popup, manifest.options_page, ...Object.values(manifest.icons)]; + for (const file of ['popup.html', 'options.html']) { + const html = fs.readFileSync(path.join(root, file), 'utf8'); + for (const match of html.matchAll(/(?:src|href)="([^"?#]+\.(?:js|css))"/g)) resources.push(match[1]); + } + for (const resource of resources) { + expect(fs.existsSync(path.join(root, resource))).toBe(true); + expect(included.some(entry => entry === resource || entry.endsWith('/') && resource.startsWith(entry))).toBe(true); + } +}); +test('package and manifest version metadata agree', () => { + const manifest = require('../manifest.json'); + expect(require('../package.json').version).toBe(manifest.version); + expect(require('../package-lock.json').version).toBe(manifest.version); + expect(require('../package-lock.json').packages[''].version).toBe(manifest.version); +}); diff --git a/tests/popup-lifecycle.test.js b/tests/popup-lifecycle.test.js new file mode 100644 index 0000000..ea004b7 --- /dev/null +++ b/tests/popup-lifecycle.test.js @@ -0,0 +1,77 @@ +const win = require('./load-popup')(); +const element = id => document.getElementById(id); +const callbacks = []; +const response = body => ({ ok: true, status: 200, statusText: 'OK', type: 'basic', elapsed: 10, url: 'https://example.com/', headers: [], body }); +beforeEach(() => { + win.cancelCurrentRequest(); + callbacks.length = 0; + chrome.runtime.sendMessage.mockImplementation((message, callback) => { + if (message.type === 'api-request') callbacks.push({ message, callback }); + else callback({ ok: true }); + }); + element('url').value = 'https://example.com/'; + win.setGqlMode(false); + element('method').value = 'POST'; + element('body').value = 'original'; + chrome.permissions.request.mockImplementation((origins, cb) => cb(true)); +}); +afterAll(() => win.cancelCurrentRequest()); + +test('late cancelled response cannot replace a new request or re-enable Send', async () => { + await win.sendRequest(); + const old = callbacks[0]; + win.cancelCurrentRequest(); + await win.sendRequest(); + old.callback(response('stale')); + expect(element('sendBtn').disabled).toBe(true); + expect(element('responseBody').textContent).not.toBe('stale'); + callbacks[1].callback(response('latest')); + expect(element('responseBody').textContent).toBe('latest'); + expect(element('sendBtn').disabled).toBe(false); +}); +test('permission wait is locked against duplicate sends and can be cancelled', async () => { + let grant; + chrome.permissions.request.mockImplementation((origins, cb) => { grant = cb; }); + const pending = win.sendRequest(); + await win.sendRequest(); + win.cancelCurrentRequest(); + grant(true); + await pending; + expect(callbacks).toHaveLength(0); + expect(element('sendBtn').disabled).toBe(false); +}); +test('history stores the submitted draft, not edits made while waiting', async () => { + await win.sendRequest(); + element('body').value = 'edited'; + callbacks[0].callback(response('ok')); + const historyWrite = chrome.storage.local.set.mock.calls.filter(([data]) => data.history).pop()[0]; + expect(historyWrite.history[0].body).toBe('original'); +}); +test('GraphQL cURL and outgoing request have the same body', async () => { + element('gqlVariables').value = '{"id":1}'; + win.setGqlMode(true); + element('body').value = '{ users { id } }'; + await win.sendRequest(); + const payload = callbacks[0].message.payload; + expect(win.buildCurl()).toContain(win.shellEscape(payload.body)); + expect(payload.method).toBe('POST'); + expect(element('method').disabled).toBe(true); +}); +test('large JSON uses plain text rendering and keeps the raw response', async () => { + await win.sendRequest(); + const raw = JSON.stringify({ value: 'a'.repeat(210000) }); + callbacks[0].callback(response(raw)); + expect(element('responseBody').querySelectorAll('span')).toHaveLength(0); + expect(element('responseBody').dataset.raw).toBe(raw); +}); +test('authentication replacement preserves password spaces and avoids duplicate headers', () => { + element('authType').value = 'basic'; + element('basicUsername').value = 'user'; + element('basicPassword').value = ' password '; + win.applyAuthTemplate(); + element('authType').value = 'bearer'; + element('bearerToken').value = 'new-token'; + win.applyAuthTemplate(); + const headers = win.readKV(element('headers')).filter(h => h.key.toLowerCase() === 'authorization'); + expect(headers).toEqual([{ key: 'Authorization', value: 'Bearer new-token' }]); +}); diff --git a/tests/request.test.js b/tests/request.test.js new file mode 100644 index 0000000..fdc4b36 --- /dev/null +++ b/tests/request.test.js @@ -0,0 +1,26 @@ +require('../popup/request.js'); +const { prepare, buildUrl } = window.QuickRequest; +const base = { method: 'POST', url: 'https://example.com', body: ' { "n": 9007199254740993 } ', headers: [], query: [] }; + +test('raw JSON and whitespace remain byte-for-byte intact', () => { + expect(prepare({ ...base, headers: [{ key: 'content-type', value: 'application/json' }] }).body).toBe(base.body); +}); +test.each(['javascript:alert(1)', 'file:///tmp/data', 'ftp://example.com', 'https://user:pass@example.com', '{{HOST}}/a'])('rejects unsupported URL %s', url => { + expect(buildUrl(url)).toBeNull(); +}); +test('preserves duplicate query values while replacing URL values', () => { + expect(buildUrl('http://localhost:3000/a?tag=old&keep=1#hash', [{ key: 'tag', value: 'one' }, { key: 'tag', value: 'two' }])).toBe('http://localhost:3000/a?keep=1&tag=one&tag=two'); +}); +test('GraphQL mode shares substituted envelope with exports and overrides content type once', () => { + const result = prepare({ ...base, method: 'GET', gqlMode: true, url: '{{HOST}}/gql', body: 'query { users }', gqlVariables: '{"id":"{{ID}}"}', headers: [{ key: 'content-type', value: 'text/plain' }] }, [{ key: 'HOST', value: 'https://example.com' }, { key: 'ID', value: '42' }]); + expect(result.method).toBe('POST'); + expect(JSON.parse(result.body)).toEqual({ query: 'query { users }', variables: { id: '42' } }); + expect(result.headers).toEqual([{ key: 'Content-Type', value: 'application/json' }]); +}); +test.each(['null', '[]', '42', 'invalid'])('rejects invalid GraphQL variables %s', gqlVariables => { + expect(() => prepare({ ...base, gqlMode: true, gqlVariables })).toThrow(/GraphQL/); +}); +test('headers reject CRLF and handle duplicate names case insensitively', () => { + expect(() => prepare({ ...base, headers: [{ key: 'X-Token', value: 'value\r\nInjected: yes' }] })).toThrow(); + expect(prepare({ ...base, headers: [{ key: 'Authorization', value: 'old' }, { key: 'authorization', value: 'new' }] }).headers).toEqual([{ key: 'authorization', value: 'new' }]); +}); diff --git a/tests/share.test.js b/tests/share.test.js index 80f9fdb..98dd2da 100644 --- a/tests/share.test.js +++ b/tests/share.test.js @@ -1,29 +1,7 @@ -/** - * Tests for popup/share.js โ€” encode / decode functions. - */ - -// โ”€โ”€ encode / decode (mirrors share.js internals) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - +require("../popup/share.js"); +const { encode, decode } = window.QuickShare; const FORMAT_VERSION = 1; -function encode(snap) { - const json = JSON.stringify(snap); - return btoa(unescape(encodeURIComponent(json))); -} - -function decode(encoded) { - try { - const json = decodeURIComponent(escape(atob(encoded.trim()))); - const obj = JSON.parse(json); - if (typeof obj !== "object" || !obj.method || !obj.url) return null; - return obj; - } catch (_) { - return null; - } -} - -// โ”€โ”€ Fixture โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const BASE_SNAP = { v: FORMAT_VERSION, method: "POST", @@ -115,3 +93,16 @@ describe("decode with invalid input", () => { expect(decode(encoded)).not.toBeNull(); }); }); + +test.each([ + { ...BASE_SNAP, headers: 'bad' }, + { ...BASE_SNAP, headers: [null] }, + { ...BASE_SNAP, query: [{ key: {}, value: 'bad' }] }, + { ...BASE_SNAP, body: {} }, + { ...BASE_SNAP, v: 999 }, + { ...BASE_SNAP, method: 'INVALID' }, + { ...BASE_SNAP, gqlMode: 'true' }, +])('rejects malformed imports without partially applying them', snap => { + expect(decode(encode(snap))).toBeNull(); + expect(window.QuickShare.applySnapshot(snap)).toBe(false); +}); diff --git a/tests/timing.test.js b/tests/timing.test.js index ee2dc17..a68d0bf 100644 --- a/tests/timing.test.js +++ b/tests/timing.test.js @@ -1,34 +1,5 @@ -/** - * Tests for popup/timing.js โ€” pure utility functions. - */ - -// โ”€โ”€ Utilities (mirrors timing.js internals) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -const TIERS = [ - { max: 200, label: "Excellent", color: "#4ade80" }, - { max: 500, label: "Good", color: "#a3e635" }, - { max: 1000, label: "Fair", color: "#facc15" }, - { max: 3000, label: "Slow", color: "#fb923c" }, - { max: Infinity, label: "Very slow", color: "#f87171" }, -]; - -function getTier(ms) { - return TIERS.find((t) => ms < t.max) || TIERS[TIERS.length - 1]; -} - -function formatBytes(bytes) { - if (bytes === 0) return "0 B"; - if (bytes < 1024) return bytes + " B"; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; - return (bytes / (1024 * 1024)).toFixed(2) + " MB"; -} - -function parseElapsed(metaText) { - const match = (metaText || "").match(/^(\d+)ms\b/); - return match ? parseInt(match[1], 10) : null; -} - -// โ”€โ”€ getTier โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +require("../popup/timing.js"); +const { parseElapsed, getTier, formatBytes } = window.QuickTiming; describe("getTier", () => { test("< 200 ms = Excellent", () => { diff --git a/tests/utils.test.js b/tests/utils.test.js index af40984..2d930d1 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -1,85 +1,14 @@ -/** - * Unit tests for pure utility functions extracted from popup.js and options.js. - * These functions have no DOM or chrome API dependencies and can be tested directly. - */ - -// โ”€โ”€ Functions under test (copy-tested from source) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// We duplicate the pure functions here to keep tests independent of the -// browser extension DOM context. When a bundler is introduced these will -// be importable from a shared lib module instead. - -function buildUrl(rawUrl, queryParams) { - let normalized = rawUrl.trim(); - if (normalized && !/^https?:\/\//i.test(normalized)) { - normalized = `https://${normalized}`; - } - let urlObj; - try { - urlObj = new URL(normalized); - } catch (err) { - return null; - } - queryParams.forEach(({ key, value }) => urlObj.searchParams.set(key, value)); - return urlObj.toString(); -} - -function prettifyJsonMaybe(text) { - try { - return JSON.stringify(JSON.parse(text), null, 2); - } catch (err) { - return text; - } -} - -function shellEscape(str) { - return `'${str.replace(/'/g, `'"'"'`)}'`; -} - -function isBodyless(method) { - return ["GET", "HEAD"].includes(method); -} - -function substituteVars(text, vars) { - if (!text || !vars.length) return text; - return text.replace(/\{\{([^}]+)\}\}/g, (match, name) => { - const entry = vars.find((v) => v.key === name.trim()); - return entry !== undefined ? entry.value : match; - }); -} - -function clampHistorySize(size) { - const num = Number(size); - if (!Number.isFinite(num)) return 8; - return Math.max(0, Math.min(50, num)); -} - -function parseKVText(text) { - const trimmed = text.trim(); - if (!trimmed) return []; - try { - const parsed = JSON.parse(trimmed); - if (Array.isArray(parsed)) { - return parsed - .map((item) => ({ - key: item.key?.trim?.() || "", - value: item.value ?? "", - })) - .filter((kv) => kv.key); - } - } catch (err) { - // fallback - } - return trimmed - .split("\n") - .map((line) => { - const [key, ...rest] = line.split(":"); - if (!key) return null; - return { key: key.trim(), value: rest.join(":").trim() }; - }) - .filter((kv) => kv && kv.key); -} - -// โ”€โ”€ buildUrl โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const fs = require('fs'); +const path = require('path'); +require('./load-popup')(); +const { buildUrl, substituteVars } = window.QuickRequest; +const { prettifyJsonMaybe, shellEscape, clampHistorySize } = window; +// Read the actual options parser in its own context, without bootstrapping its UI. +const source = fs.readFileSync(path.join(__dirname, '../options.js'), 'utf8'); +const parser = source.slice(source.indexOf('function parseKVText('), source.indexOf('function kvToDisplay(')); +window.eval(parser); +const { parseKVText } = window; +const isBodyless = method => window.QuickRequest.prepare({ method, url: "https://example.com", body: "payload" }).body === ""; describe("buildUrl", () => { test("returns full URL with query params appended", () => { @@ -223,8 +152,8 @@ describe("substituteVars", () => { }); test("handles null/undefined text gracefully", () => { - expect(substituteVars(null, vars)).toBeNull(); - expect(substituteVars(undefined, vars)).toBeUndefined(); + expect(substituteVars(null, vars)).toBe(""); + expect(substituteVars(undefined, vars)).toBe(""); }); test("substitutes variable in header value", () => { diff --git a/tests/validators.test.js b/tests/validators.test.js index 5eb39ce..816bb93 100644 --- a/tests/validators.test.js +++ b/tests/validators.test.js @@ -1,61 +1,5 @@ -/** - * Tests for popup/validators.js โ€” pure validation functions. - * Uses jsdom test environment (set in package.json). - */ - -// โ”€โ”€ Validators (copied from validators.js for isolation) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function validateJSON(text) { - const trimmed = text.trim(); - if (!trimmed) return { valid: false, message: "Empty input." }; - try { - JSON.parse(trimmed); - const bytes = new Blob([trimmed]).size; - const kb = (bytes / 1024).toFixed(1); - return { - valid: true, - message: "Valid JSON โ€” no errors found.", - detail: bytes < 1024 ? bytes + " bytes" : kb + " KB", - }; - } catch (err) { - const location = err.message.match(/position (\d+)/i); - const pos = location ? " at position " + location[1] : ""; - return { valid: false, message: "Invalid JSON", detail: err.message + pos }; - } -} - -function validateXML(text) { - const trimmed = text.trim(); - if (!trimmed) return { valid: false, message: "Empty input." }; - const parser = new DOMParser(); - const doc = parser.parseFromString(trimmed, "text/xml"); - const errNode = doc.querySelector("parsererror"); - if (errNode) { - return { valid: false, message: "Invalid XML", detail: (errNode.textContent || "").trim() }; - } - const elCount = doc.getElementsByTagName("*").length; - return { - valid: true, - message: "Valid XML โ€” no errors found.", - detail: elCount + " element" + (elCount !== 1 ? "s" : "") + " parsed.", - }; -} - -function validateHTML(text) { - const trimmed = text.trim(); - if (!trimmed) return { valid: false, message: "Empty input." }; - const parser = new DOMParser(); - const doc = parser.parseFromString(trimmed, "text/html"); - const elCount = doc.querySelectorAll("*").length; - const title = doc.title ? '"' + doc.title + '"' : "(no title)"; - return { - valid: true, - message: "HTML parsed successfully.", - detail: elCount + " element" + (elCount !== 1 ? "s" : "") + " ยท title: " + title, - }; -} - -// โ”€โ”€ JSON validator tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +require("../popup/validators.js"); +const { validateJSON, validateXML, validateHTML } = window.QuickValidators; describe("validateJSON", () => { test("accepts a valid JSON object", () => {