diff --git a/plugins/har-import/README.md b/plugins/har-import/README.md new file mode 100644 index 00000000..e966cb61 --- /dev/null +++ b/plugins/har-import/README.md @@ -0,0 +1,11 @@ +# HTTP Archive (HAR) Importer for Voiden + +Import HTTP Archive (`.har`) files captured from browser Developer Tools and automatically convert network requests into native Voiden `.void` request files. + +## Features +- Import `.har` files +- Group requests by page title or domain hostname +- Extract headers, cookies, query parameters, and request bodies +- Supports JSON, XML, Form Data (URL-encoded), Multipart, and Plain Text bodies +- Option to ignore static assets (images, CSS, JS, fonts, etc.) +- Progress tracking and cancellation support diff --git a/plugins/har-import/dist/har-import.js b/plugins/har-import/dist/har-import.js new file mode 100644 index 00000000..e5f9c664 --- /dev/null +++ b/plugins/har-import/dist/har-import.js @@ -0,0 +1,454 @@ +const ne = 2, ie = { id: "har-import", name: "HTTP Archive (HAR) Importer", description: "Import HTTP Archive (.har) files and automatically convert captured browser network requests into native Voiden request files with full support for headers, cookies, query parameters, request bodies, and optional static asset filtering.", version: "1.0.0", voidenVersion: ">=2.0.0", author: "Voiden Team", enabled: !0, priority: 50, readme: "Import HTTP Archive (.har) files and automatically convert them into native Voiden .void request files. Supports headers, cookies, query parameters, request bodies (JSON, XML, Form Data, Multipart, Text), folder/page grouping, and static asset filtering.", capabilities: { ui: { buttons: [{ id: "har-import-btn", location: "sidebar-left", icon: "PackageImport", tooltip: "Import HAR Collection", description: "Opens file picker to select and import HTTP Archive (.har) JSON files" }], description: "Adds import button for HAR files" }, fileSystem: { operations: ["create-directory", "write-file"], description: "Creates folder structure and .void files from HAR network capture" }, integration: { dependencies: [{ extension: "voiden-rest-api", reason: "Uses voiden-rest-api helpers to generate compatible REST API blocks", required: !0 }], description: "Depends on voiden-rest-api extension for REST block generation" } }, dependencies: { core: "^1.0.0", sdk: "^1.0.0", "voiden-rest-api": "^1.0.0" }, features: ["Import HTTP Archive (.har) JSON files captured from browser Developer Tools", "Automatically create folder structure matching page titles or domain hostnames", "Convert each HTTP request entry into a native .void file", "Preserve request headers and convert to headers-table blocks", "Preserve cookies and merge into headers or auth details", "Extract query parameters and convert to query-table blocks", "Support for all HTTP methods (GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, etc.)", "Import JSON request bodies and convert to json_body blocks", "Import XML request bodies and convert to xml_body blocks", "Import form-data bodies and convert to multipart-table blocks", "Import URL-encoded bodies and convert to urlencoded-table blocks", "Import Plain Text bodies", "Optional static asset filtering (ignore images, stylesheets, scripts, fonts)", "Sanitize file and folder names for filesystem compatibility", "Progress tracking and cancel support during import", "Batch file creation with throttling to prevent system overload", "Uses voiden-rest-api helpers for consistent block generation"] }, O = window.__voiden_shims__.react, { + useState: I, + useEffect: D, + useCallback: ae, + useMemo: ce, + useRef: F, + useContext: le, + createContext: ue, + forwardRef: de, + memo: pe, + Fragment: me, + createElement: fe, + cloneElement: he, + Children: ge, + StrictMode: we, + Suspense: ye, + lazy: ve, + isValidElement: xe, + Component: be, + PureComponent: Ce, + createRef: Ie, + startTransition: Te, + useReducer: ke, + useLayoutEffect: Se, + useImperativeHandle: _e, + useDebugValue: Ae, + useTransition: Ne, + useDeferredValue: qe, + useId: Ee +} = O, L = window.__voiden_shims__["react/jsx-runtime"], y = L.jsx, T = L.jsxs; +L.Fragment; +const V = window.__voiden_shims__["@tanstack/react-query"], { + useQuery: Pe, + useMutation: je, + useQueryClient: B, + useInfiniteQuery: He, + QueryClient: Re, + QueryClientProvider: Le, + QueryCache: Me, + MutationCache: De, + useIsFetching: $e, + useIsMutating: Oe, + useSuspenseQuery: Ue, + useSuspenseInfiniteQuery: Fe, + useSuspenseQueries: Ve, + useQueries: Be, + HydrationBoundary: Qe, + dehydrate: We, + hydrate: Xe, + focusManager: Je, + onlineManager: ze, + replaceEqualDeep: Ge, + hashKey: Ke +} = V; +function Q(r) { + return r && typeof r == "object" && r.log && typeof r.log == "object" && Array.isArray(r.log.entries); +} +const $ = /* @__PURE__ */ new Set([ + "png", + "jpg", + "jpeg", + "gif", + "svg", + "webp", + "ico", + "avif", + "bmp", + "tiff", + "css", + "scss", + "less", + "js", + "mjs", + "cjs", + "jsx", + "ts", + "tsx", + "woff", + "woff2", + "ttf", + "eot", + "otf", + "mp3", + "mp4", + "wav", + "ogg", + "webm", + "aac", + "flac", + "pdf", + "map", + "swf" +]), W = [ + "image/", + "font/", + "audio/", + "video/" +], X = /* @__PURE__ */ new Set([ + "text/css", + "application/javascript", + "text/javascript", + "application/x-javascript", + "application/font-woff", + "application/font-woff2", + "application/x-font-ttf", + "application/vnd.ms-fontobject" +]); +function J(r, o) { + if (o) { + const t = o.split(";")[0].trim().toLowerCase(); + if (X.has(t) || W.some((e) => t.startsWith(e))) return !0; + } + try { + const e = new URL(r).pathname, i = e.lastIndexOf("."); + if (i !== -1 && i < e.length - 1) { + const a = e.slice(i + 1).toLowerCase(); + if ($.has(a)) + return !0; + } + } catch { + const t = r.split("?")[0].split("#")[0].toLowerCase(), e = t.lastIndexOf("."); + if (e !== -1 && e < t.length - 1) { + const i = t.slice(e + 1); + if ($.has(i)) + return !0; + } + } + return !1; +} +function z() { + var t; + const o = (t = (typeof window < "u" ? window : globalThis).__voidenHelpers__) == null ? void 0 : t["voiden-wrapper-api-extension"]; + if (!o) + throw new Error( + "Voiden API helpers not found. Make sure voiden-wrapper-api-extension is loaded before har-import." + ); + return o; +} +function j(r) { + return !r || !r.trim() ? "unnamed-request" : r.trim().replace(/\/+/g, "-").replace(/[^a-zA-Z0-9-\s_.]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+/, "").replace(/-+$/, ""); +} +function G(r) { + const o = (r.mimeType || "").toLowerCase(); + if (o.includes("application/json") || o.includes("+json")) return "json"; + if (o.includes("application/xml") || o.includes("text/xml") || o.includes("+xml")) return "xml"; + if (o.includes("text/html")) return "html"; + if (r.text) { + const t = r.text.trim(); + if (t.startsWith("{") || t.startsWith("[")) return "json"; + if (t.startsWith("<")) + return t.toLowerCase().includes(" 0 ? e[e.length - 1] : o.hostname; + return `${r.request.method} ${i}`; + } catch { + return `${r.request.method} ${r.request.url.slice(0, 30)}`; + } +} +const K = async (r) => { + const o = z(), t = r.request, e = [], i = (t.method || "GET").toUpperCase(), a = { + type: "request", + content: [ + o.createMethodNode(i), + o.createUrlNode(t.url) + ] + }; + e.push(a); + const c = [], n = (t.headers || []).some( + (s) => s.name.toLowerCase() === "cookie" + ); + if (t.headers && t.headers.length > 0) { + for (const s of t.headers) + if (s.name && s.value !== void 0) { + if (s.name.startsWith(":")) continue; + c.push([s.name, s.value]); + } + } + if (!n && t.cookies && t.cookies.length > 0) { + const s = t.cookies.map((u) => `${u.name}=${u.value}`).join("; "); + c.push(["Cookie", s]); + } + if (c.length > 0 && e.push(o.createHeadersTableNode(c)), t.queryString && t.queryString.length > 0) { + const s = t.queryString.map((u) => [ + u.name, + u.value || "" + ]); + e.push(o.createQueryTableNode(s)); + } + if (t.postData) { + const s = t.postData, u = (s.mimeType || "").toLowerCase(); + if (u.includes("application/x-www-form-urlencoded") && s.params && s.params.length > 0) { + const p = s.params.map((d) => [ + d.name, + d.value || "" + ]); + e.push(o.createUrlTableNode(p)); + } else if (u.includes("multipart/form-data") && s.params && s.params.length > 0) { + const p = s.params.map((d) => [ + d.name, + d.value || d.fileName || "" + ]); + e.push(o.createMultipartTableNode(p)); + } else if (s.text) { + const p = G(s); + p === "json" ? e.push(o.createJsonBodyNode(s.text, "json")) : p === "xml" ? e.push(o.createXMLBodyNode(s.text, "xml")) : p === "html" ? e.push(o.createXMLBodyNode(s.text, "html")) : e.push(o.createJsonBodyNode(s.text, "text")); + } + } + const l = M(r); + return o.convertBlocksToVoidFile(l, e); +}; +function Z(r, o = !0) { + var i, a, c; + const t = {}, e = /* @__PURE__ */ new Map(); + if (r.pages && r.pages.length > 0) + for (const n of r.pages) + e.set(n.id, j(n.title || n.id)); + for (const n of r.entries) { + if (!n.request || !n.request.url) continue; + if (o) { + const s = ((a = (i = n.response) == null ? void 0 : i.content) == null ? void 0 : a.mimeType) || ((c = n.request.postData) == null ? void 0 : c.mimeType); + if (J(n.request.url, s)) + continue; + } + let l = "general-requests"; + if (n.pageref && e.has(n.pageref)) + l = e.get(n.pageref); + else + try { + const s = new URL(n.request.url); + l = j(s.hostname) || "general-requests"; + } catch { + l = "general-requests"; + } + t[l] || (t[l] = []), t[l].push(n); + } + return t; +} +const Y = async (r, o) => { + var a, c, n, l; + const t = await K(r), e = j(M(r)), i = await ((c = (a = window.electron) == null ? void 0 : a.files) == null ? void 0 : c.createVoid( + o, + e + )); + i != null && i.path && await ((l = (n = window.electron) == null ? void 0 : n.files) == null ? void 0 : l.write(i.path, t)); +}, ee = async (r, o, t = {}, e, i, a) => { + var k, S, _, A, N; + const c = JSON.parse(r), n = Q(c) ? c.log : c; + if (!n || !Array.isArray(n.entries)) + throw new Error("Invalid HAR file format: missing log entries"); + const l = t.ignoreStaticAssets ?? !0, s = Z(n, l); + let u = 0; + for (const g in s) + u += s[g].length; + if (u === 0) + return { + success: !0, + message: "No requests found to import (check static asset filter settings)." + }; + const p = j( + (k = n.creator) != null && k.name ? `har-${n.creator.name}` : "har-import" + ), d = await ((_ = (S = window.electron) == null ? void 0 : S.files) == null ? void 0 : _.createDirectory( + o, + p + )) || p, v = `${o}/${d}`; + let b = 0; + for (const g in s) { + if (a != null && a.cancelled) return; + const m = s[g], x = await ((N = (A = window.electron) == null ? void 0 : A.files) == null ? void 0 : N.createDirectory( + v, + g + )) || g, h = `${v}/${x}`; + for (const w of m) { + if (a != null && a.cancelled) return; + const q = M(w); + try { + await Y(w, h); + } catch (f) { + i == null || i(q, f); + } finally { + b++, e == null || e(b, u); + } + await new Promise((f) => setTimeout(f, 15)); + } + } + return { + success: !0, + message: `Imported ${b} requests successfully` + }; +}, te = window.__voiden_shims__["lucide-react"] || {}, { AlertCircle: Ze, ArrowDown: Ye, ArrowDownLeft: et, ArrowLeft: tt, ArrowLeftRight: rt, ArrowRight: ot, ArrowUp: st, ArrowUpRight: nt, BookOpen: it, Check: at, CheckCheck: ct, ChevronDown: lt, ChevronRight: ut, ChevronsDownUp: dt, ChevronsUpDown: pt, Circle: mt, CircleAlert: ft, CircleX: ht, Clock: gt, Columns2: wt, Copy: yt, CornerDownLeft: vt, CornerDownRight: xt, Download: bt, ExternalLink: Ct, Eye: It, FileDown: Tt, FileText: kt, Folder: St, FolderOpen: _t, History: At, Info: Nt, Link: qt, Loader: Et, Loader2: Pt, Mouse: jt, Pen: Ht, Pencil: Rt, Play: Lt, Plus: Mt, Radio: Dt, RefreshCw: $t, Rows: Ot, Search: Ut, SkipForward: Ft, Sparkles: Vt, Square: Bt, Trash2: Qt, Unlink: Wt, Wifi: Xt, WifiOff: Jt, WrapText: zt, X: re, XCircle: oe } = te, P = /* @__PURE__ */ new Map(), R = /* @__PURE__ */ new Map(), se = ({ tab: r, showToast: o }) => { + const t = P.get(r.tabId), [e, i] = I((t == null ? void 0 : t.progress) ?? { current: 0, total: 0 }), [a, c] = I((t == null ? void 0 : t.isImporting) ?? !1), [n, l] = I((t == null ? void 0 : t.error) ?? null), [s, u] = I((t == null ? void 0 : t.ignoreStaticAssets) ?? !0), [p, d] = I(!1), v = F(null); + D(() => { + P.set(r.tabId, { isImporting: a, progress: e, error: n, ignoreStaticAssets: s }); + }, [r.tabId, a, e, n, s]); + const b = B(); + D(() => { + if (n) { + d(!0); + const m = setTimeout(() => { + d(!1); + const x = setTimeout(() => l(null), 300); + return () => clearTimeout(x); + }, 5e3); + return () => clearTimeout(m); + } + }, [n]); + const k = () => { + v.current && (v.current.cancelled = !0), c(!1), i({ current: 0, total: 0 }), P.delete(r.tabId), R.delete(r.tabId); + }, S = async () => { + var m, x; + try { + l(null), d(!1), c(!0), i({ current: 0, total: 0 }); + const h = { cancelled: !1 }; + v.current = h, R.set(r.tabId, h); + const w = b.getQueryData(["projects"]), q = w == null ? void 0 : w.activeProject; + if (!q) { + l("No active project found"), c(!1); + return; + } + let f = r.content; + if ((!f || f.trim() === "") && r.source && (f = await ((x = (m = window.electron) == null ? void 0 : m.files) == null ? void 0 : x.read(r.source)) ?? ""), !f || f.trim() === "") { + l("HAR file content is empty"), c(!1); + return; + } + try { + JSON.parse(f); + } catch { + l("Invalid HAR JSON format"), c(!1); + return; + } + const E = await ee( + f, + q, + { ignoreStaticAssets: s }, + (H, C) => { + i({ current: H, total: C }); + }, + (H, C) => { + const U = C instanceof Error ? C.message : String(C); + o == null || o(`Failed to import "${H}": ${U}`, "error"); + }, + h + ); + if (h.cancelled) return; + E != null && E.message && o && o(E.message, "success"), i({ current: 0, total: 0 }), c(!1), P.delete(r.tabId), R.delete(r.tabId); + } catch (h) { + console.error("Failed to import HAR file:", h); + const w = h instanceof Error ? h.message : "Failed to import HAR file"; + l(w), i({ current: 0, total: 0 }), c(!1); + } + }, _ = () => { + d(!1), setTimeout(() => l(null), 300); + }, A = () => a && e.current > 0 && e.current < e.total ? `Generating files... ${e.current}/${e.total}` : e.current === e.total && e.total > 0 ? `Generated ${e.total} files` : "Generate Voiden files", N = () => { + const m = "px-2 py-0.5 rounded-sm text-sm transition-all duration-200"; + return a && e.current > 0 && e.current < e.total ? `${m} bg-yellow-500 hover:bg-yellow-600 text-black cursor-wait` : e.current === e.total && e.total > 0 ? `${m} bg-green-500 hover:bg-green-600 text-white` : `${m} bg-panel hover:bg-active text-foreground`; + }, g = a && e.current > 0 && e.current < e.total; + return /* @__PURE__ */ T("div", { className: "flex flex-col gap-1", children: [ + !n && /* @__PURE__ */ T("div", { className: "flex items-center gap-3", children: [ + /* @__PURE__ */ y( + "button", + { + className: N(), + onClick: S, + disabled: g, + title: g ? "Import in progress..." : "Import HAR file", + children: A() + } + ), + /* @__PURE__ */ T("label", { className: "flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none", children: [ + /* @__PURE__ */ y( + "input", + { + type: "checkbox", + checked: s, + onChange: (m) => u(m.target.checked), + disabled: g, + className: "rounded border-gray-400 text-primary focus:ring-primary" + } + ), + "Ignore static assets" + ] }), + a && /* @__PURE__ */ y( + "button", + { + onClick: k, + title: "Cancel", + className: "text-muted hover:text-red-500 transition-colors", + children: /* @__PURE__ */ y(oe, { size: 15 }) + } + ), + a && e.current > 0 && e.total > 0 && /* @__PURE__ */ T("div", { className: "text-xs text-gray-500", children: [ + Math.round(e.current / e.total * 100), + "%" + ] }) + ] }), + n && /* @__PURE__ */ y( + "div", + { + className: `transition-all duration-300 overflow-hidden ${p ? "max-h-20 opacity-100" : "max-h-0 opacity-0"}`, + children: /* @__PURE__ */ T("div", { className: "flex items-center justify-between border border-red-200 rounded px-2 py-1", children: [ + /* @__PURE__ */ y("span", { className: "text-red-600 dark:text-red-400 text-xs", children: n }), + /* @__PURE__ */ y( + "button", + { + onClick: _, + className: "text-red-500 hover:text-red-700 text-xs ml-2", + title: "Dismiss error", + children: /* @__PURE__ */ y(re, { size: 12 }) + } + ) + ] }) + } + ) + ] }); +}, Gt = (r) => { + var t; + const o = (t = r == null ? void 0 : r.ui) == null ? void 0 : t.showToast; + return { + onload: () => { + r.registerEditorAction({ + id: "har-import-button", + component: (e) => O.createElement(se, { + ...e, + showToast: o + }), + predicate: (e) => { + if (!e || !e.title) return !1; + const i = e.title.toLowerCase(); + return i.endsWith(".har") ? !0 : i.endsWith(".json") && e.content ? e.content.indexOf('"log"') > -1 && e.content.indexOf('"entries"') > -1 : !1; + } + }); + }, + onunload: () => { + } + }; +}; +export { + se as HarImportButton, + ne as __voiden_bundle_version__, + ie as __voiden_manifest__, + K as convertHarEntryToVoidenSchema, + Y as createSingleHarFile, + Gt as default, + M as getEntryName, + Z as groupEntries, + ee as importHarLog, + Q as isHarObject, + J as isStaticAsset, + j as sanitizeName +}; diff --git a/plugins/har-import/manifest.json b/plugins/har-import/manifest.json new file mode 100644 index 00000000..c086df9f --- /dev/null +++ b/plugins/har-import/manifest.json @@ -0,0 +1,71 @@ +{ + "id": "har-import", + "name": "HTTP Archive (HAR) Importer", + "description": "Import HTTP Archive (.har) files and automatically convert captured browser network requests into native Voiden request files with full support for headers, cookies, query parameters, request bodies, and optional static asset filtering.", + "version": "1.0.0", + "voidenVersion": ">=2.0.0", + "author": "Voiden Team", + "enabled": true, + "priority": 50, + "readme": "Import HTTP Archive (.har) files and automatically convert them into native Voiden .void request files. Supports headers, cookies, query parameters, request bodies (JSON, XML, Form Data, Multipart, Text), folder/page grouping, and static asset filtering.", + + "capabilities": { + "ui": { + "buttons": [ + { + "id": "har-import-btn", + "location": "sidebar-left", + "icon": "PackageImport", + "tooltip": "Import HAR Collection", + "description": "Opens file picker to select and import HTTP Archive (.har) JSON files" + } + ], + "description": "Adds import button for HAR files" + }, + + "fileSystem": { + "operations": [ + "create-directory", + "write-file" + ], + "description": "Creates folder structure and .void files from HAR network capture" + }, + + "integration": { + "dependencies": [ + { + "extension": "voiden-rest-api", + "reason": "Uses voiden-rest-api helpers to generate compatible REST API blocks", + "required": true + } + ], + "description": "Depends on voiden-rest-api extension for REST block generation" + } + }, + + "dependencies": { + "core": "^1.0.0", + "sdk": "^1.0.0", + "voiden-rest-api": "^1.0.0" + }, + + "features": [ + "Import HTTP Archive (.har) JSON files captured from browser Developer Tools", + "Automatically create folder structure matching page titles or domain hostnames", + "Convert each HTTP request entry into a native .void file", + "Preserve request headers and convert to headers-table blocks", + "Preserve cookies and merge into headers or auth details", + "Extract query parameters and convert to query-table blocks", + "Support for all HTTP methods (GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, etc.)", + "Import JSON request bodies and convert to json_body blocks", + "Import XML request bodies and convert to xml_body blocks", + "Import form-data bodies and convert to multipart-table blocks", + "Import URL-encoded bodies and convert to urlencoded-table blocks", + "Import Plain Text bodies", + "Optional static asset filtering (ignore images, stylesheets, scripts, fonts)", + "Sanitize file and folder names for filesystem compatibility", + "Progress tracking and cancel support during import", + "Batch file creation with throttling to prevent system overload", + "Uses voiden-rest-api helpers for consistent block generation" + ] +} diff --git a/plugins/har-import/package.json b/plugins/har-import/package.json new file mode 100644 index 00000000..dd16c2fc --- /dev/null +++ b/plugins/har-import/package.json @@ -0,0 +1,20 @@ +{ + "name": "@voiden/plugin-har-import", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "node ../../scripts/build-plugins.mjs har-import" + }, + "peerDependencies": { + "@voiden/sdk": "^1.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@voiden/sdk": "^1.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/plugins/har-import/src/__tests__/converter.test.ts b/plugins/har-import/src/__tests__/converter.test.ts new file mode 100644 index 00000000..dcd72bce --- /dev/null +++ b/plugins/har-import/src/__tests__/converter.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + sanitizeName, + getEntryName, + groupEntries, + convertHarEntryToVoidenSchema, +} from '../utils/converter'; +import { isStaticAsset } from '../utils/staticAssets'; +import type { HarEntry, HarLog } from '../utils/types'; + +// Mock voiden API helpers +const mockHelpers = { + createMethodNode: vi.fn((method: string) => ({ type: 'method', method })), + createUrlNode: vi.fn((url: string) => ({ type: 'url', url })), + createHeadersTableNode: vi.fn((headers: [string, string][]) => ({ + type: 'headers-table', + headers, + })), + createQueryTableNode: vi.fn((params: [string, string][]) => ({ + type: 'query-table', + params, + })), + createJsonBodyNode: vi.fn((body: string, contentType: string) => ({ + type: 'json_body', + body, + contentType, + })), + createXMLBodyNode: vi.fn((body: string, contentType: string) => ({ + type: 'xml_body', + body, + contentType, + })), + createUrlTableNode: vi.fn((formData: [string, string][]) => ({ + type: 'url_table', + formData, + })), + createMultipartTableNode: vi.fn((formData: [string, string][]) => ({ + type: 'multipart_table', + formData, + })), + convertBlocksToVoidFile: vi.fn((title: string, blocks: any[]) => + JSON.stringify({ title, blocks }, null, 2) + ), + convertToVoidMarkdown: vi.fn(), + insertParagraphAfterRequestBlocks: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + (window as any).__voidenHelpers__ = { + 'voiden-wrapper-api-extension': mockHelpers, + }; +}); + +describe('HAR Importer - Sanitization & Asset Filtering', () => { + it('sanitizeName sanitizes filenames correctly', () => { + expect(sanitizeName(' My / Folder / Name! ')).toBe('My-Folder-Name'); + expect(sanitizeName('api/v1/users?id=123')).toBe('api-v1-usersid123'); + expect(sanitizeName('')).toBe('unnamed-request'); + }); + + it('isStaticAsset correctly identifies static assets vs API requests', () => { + expect(isStaticAsset('https://example.com/logo.png')).toBe(true); + expect(isStaticAsset('https://example.com/styles.css')).toBe(true); + expect(isStaticAsset('https://example.com/script.js')).toBe(true); + expect(isStaticAsset('https://example.com/font.woff2')).toBe(true); + expect(isStaticAsset('https://example.com/api/v1/data', 'image/png')).toBe(true); + expect(isStaticAsset('https://example.com/api/v1/data', 'text/css')).toBe(true); + + expect(isStaticAsset('https://example.com/api/v1/users')).toBe(false); + expect(isStaticAsset('https://example.com/api/v1/users', 'application/json')).toBe(false); + expect(isStaticAsset('https://example.com/graphql', 'application/json')).toBe(false); + }); +}); + +describe('HAR Importer - Entry Names & Grouping', () => { + it('getEntryName formats request entry names properly', () => { + const entry: HarEntry = { + startedDateTime: '2026-08-06T10:00:00.000Z', + time: 100, + request: { + method: 'GET', + url: 'https://api.example.com/v1/users', + httpVersion: 'HTTP/1.1', + cookies: [], + headers: [], + queryString: [], + headersSize: 0, + bodySize: 0, + }, + }; + + expect(getEntryName(entry)).toBe('GET users'); + }); + + it('groupEntries groups entries by page and filters static assets', () => { + const log: HarLog = { + version: '1.2', + creator: { name: 'Chrome', version: '120.0' }, + pages: [{ id: 'page_1', startedDateTime: '', title: 'Dashboard' }], + entries: [ + { + pageref: 'page_1', + startedDateTime: '', + time: 50, + request: { + method: 'GET', + url: 'https://api.example.com/v1/users', + httpVersion: 'HTTP/1.1', + cookies: [], + headers: [], + queryString: [], + headersSize: 0, + bodySize: 0, + }, + response: { + status: 200, + statusText: 'OK', + httpVersion: 'HTTP/1.1', + cookies: [], + headers: [], + content: { size: 100, mimeType: 'application/json' }, + redirectURL: '', + headersSize: 0, + bodySize: 0, + }, + }, + { + pageref: 'page_1', + startedDateTime: '', + time: 10, + request: { + method: 'GET', + url: 'https://api.example.com/assets/app.css', + httpVersion: 'HTTP/1.1', + cookies: [], + headers: [], + queryString: [], + headersSize: 0, + bodySize: 0, + }, + response: { + status: 200, + statusText: 'OK', + httpVersion: 'HTTP/1.1', + cookies: [], + headers: [], + content: { size: 500, mimeType: 'text/css' }, + redirectURL: '', + headersSize: 0, + bodySize: 0, + }, + }, + ], + }; + + const filtered = groupEntries(log, true); + expect(filtered['Dashboard']).toBeDefined(); + expect(filtered['Dashboard'].length).toBe(1); + expect(filtered['Dashboard'][0].request.url).toBe('https://api.example.com/v1/users'); + + const unfiltered = groupEntries(log, false); + expect(unfiltered['Dashboard'].length).toBe(2); + }); +}); + +describe('HAR Importer - Entry Conversion to Schema', () => { + it('converts HAR entry with headers, cookies, query string, and JSON body', async () => { + const entry: HarEntry = { + startedDateTime: '2026-08-06T10:00:00.000Z', + time: 120, + request: { + method: 'POST', + url: 'https://api.example.com/v1/users', + httpVersion: 'HTTP/1.1', + cookies: [{ name: 'session_id', value: 'abc123xyz' }], + headers: [ + { name: 'Content-Type', value: 'application/json' }, + { name: 'Authorization', value: 'Bearer token456' }, + ], + queryString: [{ name: 'source', value: 'web' }], + postData: { + mimeType: 'application/json', + text: '{"name": "Alice", "role": "admin"}', + }, + headersSize: 100, + bodySize: 35, + }, + }; + + const voidContent = await convertHarEntryToVoidenSchema(entry); + const parsed = JSON.parse(voidContent); + + expect(parsed.title).toBe('POST users'); + expect(mockHelpers.createMethodNode).toHaveBeenCalledWith('POST'); + expect(mockHelpers.createUrlNode).toHaveBeenCalledWith('https://api.example.com/v1/users'); + expect(mockHelpers.createHeadersTableNode).toHaveBeenCalledWith([ + ['Content-Type', 'application/json'], + ['Authorization', 'Bearer token456'], + ['Cookie', 'session_id=abc123xyz'], + ]); + expect(mockHelpers.createQueryTableNode).toHaveBeenCalledWith([['source', 'web']]); + expect(mockHelpers.createJsonBodyNode).toHaveBeenCalledWith( + '{"name": "Alice", "role": "admin"}', + 'json' + ); + }); + + it('converts HAR entry with URL-encoded form data', async () => { + const entry: HarEntry = { + startedDateTime: '2026-08-06T10:00:00.000Z', + time: 80, + request: { + method: 'POST', + url: 'https://api.example.com/login', + httpVersion: 'HTTP/1.1', + cookies: [], + headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }], + queryString: [], + postData: { + mimeType: 'application/x-www-form-urlencoded', + params: [ + { name: 'username', value: 'alice' }, + { name: 'password', value: 'secret123' }, + ], + }, + headersSize: 50, + bodySize: 30, + }, + }; + + await convertHarEntryToVoidenSchema(entry); + expect(mockHelpers.createUrlTableNode).toHaveBeenCalledWith([ + ['username', 'alice'], + ['password', 'secret123'], + ]); + }); +}); diff --git a/plugins/har-import/src/__tests__/e2e.test.ts b/plugins/har-import/src/__tests__/e2e.test.ts new file mode 100644 index 00000000..ad9fd2d6 --- /dev/null +++ b/plugins/har-import/src/__tests__/e2e.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { describe, it, expect } from 'vitest'; +import { importHarLog } from '../utils/converter'; + +describe('End-to-End HAR Import Test', () => { + it('imports sample-capture.har and generates expected .void files', async () => { + const mockFilesCreated: Record = {}; + + (globalThis as any).window = { + __voidenHelpers__: { + 'voiden-wrapper-api-extension': { + createMethodNode: (method: string) => ({ type: 'method', method }), + createUrlNode: (url: string) => ({ type: 'url', url }), + createHeadersTableNode: (headers: [string, string][]) => ({ type: 'headers-table', headers }), + createQueryTableNode: (params: [string, string][]) => ({ type: 'query-table', params }), + createJsonBodyNode: (body: string, contentType: string) => ({ type: 'json_body', body, contentType }), + createXMLBodyNode: (body: string, contentType: string) => ({ type: 'xml_body', body, contentType }), + createUrlTableNode: (formData: [string, string][]) => ({ type: 'url_table', formData }), + createMultipartTableNode: (formData: [string, string][]) => ({ type: 'multipart_table', formData }), + convertBlocksToVoidFile: (title: string, blocks: any[]) => + `---\ntitle: "${title}"\n---\n\n` + + blocks.map((b) => `\`\`\`json\n${JSON.stringify(b, null, 2)}\n\`\`\``).join('\n\n'), + }, + }, + electron: { + files: { + createDirectory: async (parent: string, name: string) => name, + createVoid: async (dir: string, name: string) => ({ path: `${dir}/${name}.void` }), + write: async (filePath: string, content: string) => { + mockFilesCreated[filePath] = content; + }, + }, + }, + }; + + const harPath = resolve(process.cwd(), 'sample-capture.har'); + const harContent = readFileSync(harPath, 'utf8'); + + // Test 1: Ignore static assets = true + const result = await importHarLog(harContent, '/test-project', { ignoreStaticAssets: true }); + expect(result).toBeDefined(); + expect(result!.success).toBe(true); + + const createdPaths = Object.keys(mockFilesCreated); + expect(createdPaths.length).toBe(3); + + expect(createdPaths).toContain('/test-project/har-WebInspector/User-Dashboard/GET-users.void'); + expect(createdPaths).toContain('/test-project/har-WebInspector/Account-Settings/POST-login.void'); + expect(createdPaths).toContain('/test-project/har-WebInspector/Account-Settings/POST-profile.void'); + + // Inspect content of GET users + const getUsersContent = mockFilesCreated['/test-project/har-WebInspector/User-Dashboard/GET-users.void']; + expect(getUsersContent).toContain('GET'); + expect(getUsersContent).toContain('https://api.example.com/v1/users?role=admin&status=active'); + expect(getUsersContent).toContain('auth_token=secret_token_12345'); + + // Test 2: Ignore static assets = false + const mockFilesUnfiltered: Record = {}; + (globalThis as any).window.electron.files.write = async (filePath: string, content: string) => { + mockFilesUnfiltered[filePath] = content; + }; + + const result2 = await importHarLog(harContent, '/test-project', { ignoreStaticAssets: false }); + expect(result2).toBeDefined(); + expect(result2!.success).toBe(true); + expect(Object.keys(mockFilesUnfiltered).length).toBe(5); + }); +}); diff --git a/plugins/har-import/src/components/HarImportButton.tsx b/plugins/har-import/src/components/HarImportButton.tsx new file mode 100644 index 00000000..9078c258 --- /dev/null +++ b/plugins/har-import/src/components/HarImportButton.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { importHarLog } from '../utils/converter'; +import { X, XCircle } from 'lucide-react'; + +interface HarImportButtonProps { + tab: { + tabId: string; + title: string; + content: string; + type: string; + source?: string; + }; + showToast?: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void; +} + +interface ImportState { + isImporting: boolean; + progress: { current: number; total: number }; + error: string | null; + ignoreStaticAssets: boolean; +} + +const importStateCache = new Map(); +const cancelSignals = new Map(); + +export const HarImportButton = ({ tab, showToast }: HarImportButtonProps) => { + const cached = importStateCache.get(tab.tabId); + const [progress, setProgress] = useState(cached?.progress ?? { current: 0, total: 0 }); + const [isImporting, setIsImporting] = useState(cached?.isImporting ?? false); + const [error, setError] = useState(cached?.error ?? null); + const [ignoreStaticAssets, setIgnoreStaticAssets] = useState(cached?.ignoreStaticAssets ?? true); + const [isErrorVisible, setIsErrorVisible] = useState(false); + const cancelSignalRef = useRef<{ cancelled: boolean } | null>(null); + + useEffect(() => { + importStateCache.set(tab.tabId, { isImporting, progress, error, ignoreStaticAssets }); + }, [tab.tabId, isImporting, progress, error, ignoreStaticAssets]); + + const queryClient = useQueryClient(); + + useEffect(() => { + if (error) { + setIsErrorVisible(true); + const hideTimer = setTimeout(() => { + setIsErrorVisible(false); + const clearTimer = setTimeout(() => setError(null), 300); + return () => clearTimeout(clearTimer); + }, 5000); + return () => clearTimeout(hideTimer); + } + }, [error]); + + const handleCancel = () => { + if (cancelSignalRef.current) { + cancelSignalRef.current.cancelled = true; + } + setIsImporting(false); + setProgress({ current: 0, total: 0 }); + importStateCache.delete(tab.tabId); + cancelSignals.delete(tab.tabId); + }; + + const handleImport = async () => { + try { + setError(null); + setIsErrorVisible(false); + setIsImporting(true); + setProgress({ current: 0, total: 0 }); + + const signal = { cancelled: false }; + cancelSignalRef.current = signal; + cancelSignals.set(tab.tabId, signal); + + const projects = queryClient.getQueryData<{ + projects: { path: string; name: string }[]; + activeProject: string; + }>(['projects']); + + const activeProject = projects?.activeProject; + + if (!activeProject) { + setError('No active project found'); + setIsImporting(false); + return; + } + + let content = tab.content; + if ((!content || content.trim() === '') && tab.source) { + content = (await (window as any).electron?.files?.read(tab.source)) ?? ''; + } + + if (!content || content.trim() === '') { + setError('HAR file content is empty'); + setIsImporting(false); + return; + } + + try { + JSON.parse(content); + } catch { + setError('Invalid HAR JSON format'); + setIsImporting(false); + return; + } + + const result = await importHarLog( + content, + activeProject, + { ignoreStaticAssets }, + (current, total) => { + setProgress({ current, total }); + }, + (itemName, err) => { + const message = err instanceof Error ? err.message : String(err); + showToast?.(`Failed to import "${itemName}": ${message}`, 'error'); + }, + signal + ); + + if (signal.cancelled) return; + + if (result?.message && showToast) { + showToast(result.message, 'success'); + } + + setProgress({ current: 0, total: 0 }); + setIsImporting(false); + importStateCache.delete(tab.tabId); + cancelSignals.delete(tab.tabId); + } catch (err) { + console.error('Failed to import HAR file:', err); + const errorMessage = err instanceof Error ? err.message : 'Failed to import HAR file'; + setError(errorMessage); + setProgress({ current: 0, total: 0 }); + setIsImporting(false); + } + }; + + const dismissError = () => { + setIsErrorVisible(false); + setTimeout(() => setError(null), 300); + }; + + const getButtonText = () => { + if (isImporting && progress.current > 0 && progress.current < progress.total) { + return `Generating files... ${progress.current}/${progress.total}`; + } + if (progress.current === progress.total && progress.total > 0) { + return `Generated ${progress.total} files`; + } + return 'Generate Voiden files'; + }; + + const getButtonClass = () => { + const baseClass = 'px-2 py-0.5 rounded-sm text-sm transition-all duration-200'; + if (isImporting && progress.current > 0 && progress.current < progress.total) { + return `${baseClass} bg-yellow-500 hover:bg-yellow-600 text-black cursor-wait`; + } + if (progress.current === progress.total && progress.total > 0) { + return `${baseClass} bg-green-500 hover:bg-green-600 text-white`; + } + return `${baseClass} bg-panel hover:bg-active text-foreground`; + }; + + const isInProgress = isImporting && progress.current > 0 && progress.current < progress.total; + + return ( +
+ {!error && ( +
+ + + + + {isImporting && ( + + )} + + {isImporting && progress.current > 0 && progress.total > 0 && ( +
+ {Math.round((progress.current / progress.total) * 100)}% +
+ )} +
+ )} + + {error && ( +
+
+ {error} + +
+
+ )} +
+ ); +}; diff --git a/plugins/har-import/src/index.ts b/plugins/har-import/src/index.ts new file mode 100644 index 00000000..757c7093 --- /dev/null +++ b/plugins/har-import/src/index.ts @@ -0,0 +1,9 @@ +/** + * HTTP Archive (HAR) Importer Extension + */ + +export { default } from './main'; +export { HarImportButton } from './components/HarImportButton'; +export * from './utils/types'; +export * from './utils/staticAssets'; +export * from './utils/converter'; diff --git a/plugins/har-import/src/main.ts b/plugins/har-import/src/main.ts new file mode 100644 index 00000000..abde91cb --- /dev/null +++ b/plugins/har-import/src/main.ts @@ -0,0 +1,48 @@ +/** + * HTTP Archive (HAR) Importer Extension + * + * Enables importing HTTP Archive (.har) files and converting captured browser requests to Voiden .void request files. + */ + +import type { PluginContext } from '@voiden/sdk/ui'; +import React from 'react'; +import { HarImportButton } from './components/HarImportButton'; + +const harImportPlugin = (context: PluginContext) => { + const showToast = (context as any)?.ui?.showToast as + | ((message: string, type?: 'info' | 'success' | 'warning' | 'error') => void) + | undefined; + + return { + onload: () => { + context.registerEditorAction({ + id: 'har-import-button', + component: (props: any) => + React.createElement(HarImportButton, { + ...props, + showToast, + }), + predicate: (tab) => { + if (!tab || !tab.title) return false; + const title = tab.title.toLowerCase(); + + // Direct match for .har extension + if (title.endsWith('.har')) return true; + + // Match .json files containing HAR structure ("log" and "entries") + if (title.endsWith('.json') && tab.content) { + return ( + tab.content.indexOf('"log"') > -1 && + tab.content.indexOf('"entries"') > -1 + ); + } + + return false; + }, + }); + }, + onunload: () => {}, + }; +}; + +export default harImportPlugin; diff --git a/plugins/har-import/src/utils/converter.ts b/plugins/har-import/src/utils/converter.ts new file mode 100644 index 00000000..9368564d --- /dev/null +++ b/plugins/har-import/src/utils/converter.ts @@ -0,0 +1,315 @@ +import type { + HarContainer, + HarEntry, + HarLog, + HarPage, + HarPostData, +} from './types'; +import { isHarObject } from './types'; +import { isStaticAsset } from './staticAssets'; +import { getVoidenApiHelpers } from './useVoidenApiHelpers'; + +/** + * Sanitize folder/file names to be filesystem-safe + */ +export function sanitizeName(name: string): string { + if (!name || !name.trim()) return 'unnamed-request'; + return name + .trim() + .replace(/\/+/g, '-') // Replace slashes with dash + .replace(/[^a-zA-Z0-9-\s_.]/g, '') // Remove non-safe characters + .replace(/\s+/g, '-') // Replace whitespace with dash + .replace(/-+/g, '-') // Collapse multiple dashes + .replace(/^-+/, '') // Trim leading dashes + .replace(/-+$/, ''); // Trim trailing dashes +} + +/** + * Detect language / content type from postData + */ +function detectBodyLanguage(postData: HarPostData): 'json' | 'xml' | 'html' | 'text' { + const mime = (postData.mimeType || '').toLowerCase(); + if (mime.includes('application/json') || mime.includes('+json')) return 'json'; + if (mime.includes('application/xml') || mime.includes('text/xml') || mime.includes('+xml')) return 'xml'; + if (mime.includes('text/html')) return 'html'; + + if (postData.text) { + const trimmed = postData.text.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json'; + if (trimmed.startsWith('<')) { + if (trimmed.toLowerCase().includes(' 0 ? segments[segments.length - 1] : parsedUrl.hostname; + return `${entry.request.method} ${lastSegment}`; + } catch { + return `${entry.request.method} ${entry.request.url.slice(0, 30)}`; + } +} + +/** + * Convert a HAR entry into Voiden .void file format + */ +export const convertHarEntryToVoidenSchema = async (entry: HarEntry): Promise => { + const helpers = getVoidenApiHelpers(); + const req = entry.request; + const blocks: any[] = []; + + // 1. Request block (method + url) + const method = (req.method || 'GET').toUpperCase(); + const requestBlock = { + type: 'request', + content: [ + helpers.createMethodNode(method), + helpers.createUrlNode(req.url), + ], + }; + blocks.push(requestBlock); + + // 2. Headers (combine req.headers and req.cookies if cookie header isn't explicitly set) + const headers: [string, string][] = []; + const hasCookieHeader = (req.headers || []).some( + (h) => h.name.toLowerCase() === 'cookie' + ); + + if (req.headers && req.headers.length > 0) { + for (const h of req.headers) { + if (h.name && h.value !== undefined) { + // Filter out HTTP/2 pseudo-headers starting with ':' + if (h.name.startsWith(':')) continue; + headers.push([h.name, h.value]); + } + } + } + + if (!hasCookieHeader && req.cookies && req.cookies.length > 0) { + const cookieString = req.cookies.map((c) => `${c.name}=${c.value}`).join('; '); + headers.push(['Cookie', cookieString]); + } + + if (headers.length > 0) { + blocks.push(helpers.createHeadersTableNode(headers)); + } + + // 3. Query parameters (req.queryString) + if (req.queryString && req.queryString.length > 0) { + const queryParams: [string, string][] = req.queryString.map((q) => [ + q.name, + q.value || '', + ]); + blocks.push(helpers.createQueryTableNode(queryParams)); + } + + // 4. Request Body (req.postData) + if (req.postData) { + const postData = req.postData; + const mime = (postData.mimeType || '').toLowerCase(); + + // 4a. Form Data (application/x-www-form-urlencoded) + if ( + mime.includes('application/x-www-form-urlencoded') && + postData.params && + postData.params.length > 0 + ) { + const urlencodedParams: [string, string][] = postData.params.map((p) => [ + p.name, + p.value || '', + ]); + blocks.push(helpers.createUrlTableNode(urlencodedParams)); + } + // 4b. Multipart Form Data (multipart/form-data) + else if ( + mime.includes('multipart/form-data') && + postData.params && + postData.params.length > 0 + ) { + const multipartParams: [string, string][] = postData.params.map((p) => [ + p.name, + p.value || p.fileName || '', + ]); + blocks.push(helpers.createMultipartTableNode(multipartParams)); + } + // 4c. Raw Text / JSON / XML + else if (postData.text) { + const language = detectBodyLanguage(postData); + if (language === 'json') { + blocks.push(helpers.createJsonBodyNode(postData.text, 'json')); + } else if (language === 'xml') { + blocks.push(helpers.createXMLBodyNode(postData.text, 'xml')); + } else if (language === 'html') { + blocks.push(helpers.createXMLBodyNode(postData.text, 'html')); + } else { + blocks.push(helpers.createJsonBodyNode(postData.text, 'text')); + } + } + } + + const title = getEntryName(entry); + return helpers.convertBlocksToVoidFile(title, blocks); +}; + +/** + * Group entries by Page or Domain Hostname + */ +export function groupEntries( + log: HarLog, + ignoreStaticAssets: boolean = true +): Record { + const groups: Record = {}; + const pageMap = new Map(); + + if (log.pages && log.pages.length > 0) { + for (const page of log.pages) { + pageMap.set(page.id, sanitizeName(page.title || page.id)); + } + } + + for (const entry of log.entries) { + if (!entry.request || !entry.request.url) continue; + + // Check static asset filter + if (ignoreStaticAssets) { + const mimeType = entry.response?.content?.mimeType || entry.request.postData?.mimeType; + if (isStaticAsset(entry.request.url, mimeType)) { + continue; + } + } + + let folderName = 'general-requests'; + + if (entry.pageref && pageMap.has(entry.pageref)) { + folderName = pageMap.get(entry.pageref)!; + } else { + try { + const urlObj = new URL(entry.request.url); + folderName = sanitizeName(urlObj.hostname) || 'general-requests'; + } catch { + folderName = 'general-requests'; + } + } + + if (!groups[folderName]) { + groups[folderName] = []; + } + groups[folderName].push(entry); + } + + return groups; +} + +/** + * Create a single .void file for a HAR entry + */ +export const createSingleHarFile = async ( + entry: HarEntry, + currentPath: string +) => { + const content = await convertHarEntryToVoidenSchema(entry); + const entryName = sanitizeName(getEntryName(entry)); + + const result = await (window as any).electron?.files?.createVoid( + currentPath, + entryName + ); + + if (result?.path) { + await (window as any).electron?.files?.write(result.path, content); + } +}; + +/** + * Main import controller for HAR files + */ +export const importHarLog = async ( + harJsonString: string, + activeProject: string, + options: { ignoreStaticAssets?: boolean } = {}, + onProgress?: (current: number, total: number) => void, + onError?: (itemName: string, error: unknown) => void, + signal?: { cancelled: boolean } +) => { + const parsedData = JSON.parse(harJsonString); + const log: HarLog = isHarObject(parsedData) ? parsedData.log : parsedData; + + if (!log || !Array.isArray(log.entries)) { + throw new Error('Invalid HAR file format: missing log entries'); + } + + const ignoreStatic = options.ignoreStaticAssets ?? true; + const groups = groupEntries(log, ignoreStatic); + + let totalItems = 0; + for (const folderName in groups) { + totalItems += groups[folderName].length; + } + + if (totalItems === 0) { + return { + success: true, + message: 'No requests found to import (check static asset filter settings).', + }; + } + + // Create root HAR import directory in active project + const rootFolderName = sanitizeName( + log.creator?.name ? `har-${log.creator.name}` : 'har-import' + ); + const actualRootPath = + (await (window as any).electron?.files?.createDirectory( + activeProject, + rootFolderName + )) || rootFolderName; + const rootPath = `${activeProject}/${actualRootPath}`; + + let currentCount = 0; + + for (const folderName in groups) { + if (signal?.cancelled) return; + + const entries = groups[folderName]; + const actualFolderName = + (await (window as any).electron?.files?.createDirectory( + rootPath, + folderName + )) || folderName; + const folderPath = `${rootPath}/${actualFolderName}`; + + for (const entry of entries) { + if (signal?.cancelled) return; + + const itemName = getEntryName(entry); + try { + await createSingleHarFile(entry, folderPath); + } catch (err) { + onError?.(itemName, err); + } finally { + currentCount++; + onProgress?.(currentCount, totalItems); + } + + await new Promise((r) => setTimeout(r, 15)); + } + } + + return { + success: true, + message: `Imported ${currentCount} requests successfully`, + }; +}; diff --git a/plugins/har-import/src/utils/staticAssets.ts b/plugins/har-import/src/utils/staticAssets.ts new file mode 100644 index 00000000..1733369d --- /dev/null +++ b/plugins/har-import/src/utils/staticAssets.ts @@ -0,0 +1,67 @@ +/** + * Helper to identify static asset URLs or MIME types + */ + +const STATIC_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'avif', 'bmp', 'tiff', + 'css', 'scss', 'less', + 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', + 'woff', 'woff2', 'ttf', 'eot', 'otf', + 'mp3', 'mp4', 'wav', 'ogg', 'webm', 'aac', 'flac', + 'pdf', 'map', 'swf' +]); + +const STATIC_MIME_PREFIXES = [ + 'image/', + 'font/', + 'audio/', + 'video/' +]; + +const STATIC_MIME_EXACT = new Set([ + 'text/css', + 'application/javascript', + 'text/javascript', + 'application/x-javascript', + 'application/font-woff', + 'application/font-woff2', + 'application/x-font-ttf', + 'application/vnd.ms-fontobject' +]); + +/** + * Checks if a HAR entry represents a static asset request based on URL extension or MIME type + */ +export function isStaticAsset(url: string, mimeType?: string): boolean { + // Check MIME type if provided + if (mimeType) { + const cleanMime = mimeType.split(';')[0].trim().toLowerCase(); + if (STATIC_MIME_EXACT.has(cleanMime)) return true; + if (STATIC_MIME_PREFIXES.some((prefix) => cleanMime.startsWith(prefix))) return true; + } + + // Check URL pathname extension + try { + const parsedUrl = new URL(url); + const pathname = parsedUrl.pathname; + const lastDotIndex = pathname.lastIndexOf('.'); + if (lastDotIndex !== -1 && lastDotIndex < pathname.length - 1) { + const ext = pathname.slice(lastDotIndex + 1).toLowerCase(); + if (STATIC_EXTENSIONS.has(ext)) { + return true; + } + } + } catch { + // If URL parsing fails, perform simple string suffix check + const cleanUrl = url.split('?')[0].split('#')[0].toLowerCase(); + const lastDotIndex = cleanUrl.lastIndexOf('.'); + if (lastDotIndex !== -1 && lastDotIndex < cleanUrl.length - 1) { + const ext = cleanUrl.slice(lastDotIndex + 1); + if (STATIC_EXTENSIONS.has(ext)) { + return true; + } + } + } + + return false; +} diff --git a/plugins/har-import/src/utils/types.ts b/plugins/har-import/src/utils/types.ts new file mode 100644 index 00000000..59b0895b --- /dev/null +++ b/plugins/har-import/src/utils/types.ts @@ -0,0 +1,119 @@ +export interface HarHeader { + name: string; + value: string; + comment?: string; +} + +export interface HarCookie { + name: string; + value: string; + path?: string; + domain?: string; + expires?: string; + httpOnly?: boolean; + secure?: boolean; + comment?: string; +} + +export interface HarQueryString { + name: string; + value: string; + comment?: string; +} + +export interface HarPostParam { + name: string; + value?: string; + fileName?: string; + contentType?: string; + comment?: string; +} + +export interface HarPostData { + mimeType: string; + params?: HarPostParam[]; + text?: string; + comment?: string; +} + +export interface HarRequest { + method: string; + url: string; + httpVersion: string; + cookies: HarCookie[]; + headers: HarHeader[]; + queryString: HarQueryString[]; + postData?: HarPostData; + headersSize: number; + bodySize: number; + comment?: string; +} + +export interface HarResponse { + status: number; + statusText: string; + httpVersion: string; + cookies: HarCookie[]; + headers: HarHeader[]; + content: { + size: number; + mimeType: string; + text?: string; + encoding?: string; + }; + redirectURL: string; + headersSize: number; + bodySize: number; +} + +export interface HarPage { + id: string; + startedDateTime: string; + title: string; + pageTimings?: Record; + comment?: string; +} + +export interface HarEntry { + pageref?: string; + startedDateTime: string; + time: number; + request: HarRequest; + response?: HarResponse; + cache?: Record; + timings?: Record; + serverIPAddress?: string; + connection?: string; + comment?: string; +} + +export interface HarLog { + version: string; + creator: { + name: string; + version: string; + comment?: string; + }; + browser?: { + name: string; + version: string; + comment?: string; + }; + pages?: HarPage[]; + entries: HarEntry[]; + comment?: string; +} + +export interface HarContainer { + log: HarLog; +} + +export function isHarObject(data: any): data is HarContainer { + return ( + data && + typeof data === 'object' && + data.log && + typeof data.log === 'object' && + Array.isArray(data.log.entries) + ); +} diff --git a/plugins/har-import/src/utils/useVoidenApiHelpers.ts b/plugins/har-import/src/utils/useVoidenApiHelpers.ts new file mode 100644 index 00000000..fb2b56b7 --- /dev/null +++ b/plugins/har-import/src/utils/useVoidenApiHelpers.ts @@ -0,0 +1,33 @@ +/** + * Hook to access voiden-api plugin helpers + */ + +export interface VoidenApiHelpers { + createMethodNode: (method: string) => any; + createUrlNode: (url: string) => any; + createHeadersTableNode: (headers: [string, string][]) => any; + createJsonBodyNode: (body: string, contentType: string) => any; + createXMLBodyNode: (body: string, contentType: string) => any; + createMultipartTableNode: (formData: [string, string][]) => any; + createUrlTableNode: (formData: [string, string][]) => any; + createQueryTableNode: (params: [string, string][]) => any; + convertToVoidMarkdown: (jsonContent: any) => Promise; + convertBlocksToVoidFile: (title: string, blocks: any[]) => string; + insertParagraphAfterRequestBlocks: (content: any[]) => any[]; +} + +/** + * Get voiden-api helpers from the global window object + */ +export function getVoidenApiHelpers(): VoidenApiHelpers { + const g = typeof window !== 'undefined' ? window : (globalThis as any); + const helpers = g.__voidenHelpers__?.['voiden-wrapper-api-extension']; + + if (!helpers) { + throw new Error( + 'Voiden API helpers not found. Make sure voiden-wrapper-api-extension is loaded before har-import.' + ); + } + + return helpers; +} diff --git a/sample-capture.har b/sample-capture.har new file mode 100644 index 00000000..25911944 --- /dev/null +++ b/sample-capture.har @@ -0,0 +1,228 @@ +{ + "log": { + "version": "1.2", + "creator": { + "name": "WebInspector", + "version": "537.36" + }, + "pages": [ + { + "id": "page_dashboard", + "startedDateTime": "2026-08-06T12:00:00.000Z", + "title": "User Dashboard" + }, + { + "id": "page_settings", + "startedDateTime": "2026-08-06T12:05:00.000Z", + "title": "Account Settings" + } + ], + "entries": [ + { + "pageref": "page_dashboard", + "startedDateTime": "2026-08-06T12:00:01.000Z", + "time": 120, + "request": { + "method": "GET", + "url": "https://api.example.com/v1/users?role=admin&status=active", + "httpVersion": "HTTP/1.1", + "cookies": [ + { + "name": "auth_token", + "value": "secret_token_12345" + } + ], + "headers": [ + { + "name": "Accept", + "value": "application/json" + }, + { + "name": "User-Agent", + "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" + } + ], + "queryString": [ + { + "name": "role", + "value": "admin" + }, + { + "name": "status", + "value": "active" + } + ], + "headersSize": 180, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "content": { + "size": 150, + "mimeType": "application/json" + }, + "redirectURL": "", + "headersSize": 100, + "bodySize": 150 + } + }, + { + "pageref": "page_dashboard", + "startedDateTime": "2026-08-06T12:00:02.000Z", + "time": 45, + "request": { + "method": "GET", + "url": "https://api.example.com/static/css/styles.css", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "queryString": [], + "headersSize": 50, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "content": { + "size": 1200, + "mimeType": "text/css" + }, + "redirectURL": "", + "headersSize": 50, + "bodySize": 1200 + } + }, + { + "pageref": "page_settings", + "startedDateTime": "2026-08-06T12:05:01.000Z", + "time": 210, + "request": { + "method": "POST", + "url": "https://api.example.com/v1/auth/login", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/json", + "text": "{\n \"username\": \"developer@voiden.io\",\n \"password\": \"super_secret_password\"\n}" + }, + "headersSize": 120, + "bodySize": 75 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "content": { + "size": 90, + "mimeType": "application/json" + }, + "redirectURL": "", + "headersSize": 80, + "bodySize": 90 + } + }, + { + "pageref": "page_settings", + "startedDateTime": "2026-08-06T12:05:02.000Z", + "time": 180, + "request": { + "method": "POST", + "url": "https://api.example.com/v1/user/profile", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "queryString": [], + "postData": { + "mimeType": "application/x-www-form-urlencoded", + "params": [ + { + "name": "display_name", + "value": "Pablo Diaz" + }, + { + "name": "theme", + "value": "dark" + } + ] + }, + "headersSize": 110, + "bodySize": 40 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "content": { + "size": 30, + "mimeType": "application/json" + }, + "redirectURL": "", + "headersSize": 50, + "bodySize": 30 + } + }, + { + "pageref": "page_settings", + "startedDateTime": "2026-08-06T12:05:03.000Z", + "time": 30, + "request": { + "method": "GET", + "url": "https://api.example.com/static/images/logo.png", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "queryString": [], + "headersSize": 50, + "bodySize": 0 + }, + "response": { + "status": 200, + "statusText": "OK", + "httpVersion": "HTTP/1.1", + "cookies": [], + "headers": [], + "content": { + "size": 4500, + "mimeType": "image/png" + }, + "redirectURL": "", + "headersSize": 50, + "bodySize": 4500 + } + } + ] + } +}