@testsmith/api-spector 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13404,14 +13404,33 @@ const createWsSlice = (set2) => ({
13404
13404
  })
13405
13405
  });
13406
13406
  const HISTORY_CAP = 200;
13407
- const createHistorySlice = (set2) => ({
13407
+ let saveTimer = null;
13408
+ function persistIfEnabled(get2) {
13409
+ if (!get2().workspace?.settings?.persistHistory) return;
13410
+ if (saveTimer) clearTimeout(saveTimer);
13411
+ saveTimer = setTimeout(() => {
13412
+ window.electron.saveHistory(get2().history).catch((err) => {
13413
+ console.warn("persistHistory: could not save history.json", err);
13414
+ });
13415
+ }, 800);
13416
+ }
13417
+ const createHistorySlice = (set2, get2) => ({
13408
13418
  history: [],
13409
- addHistoryEntry: (entry) => set2((s) => {
13410
- s.history.unshift(entry);
13411
- if (s.history.length > HISTORY_CAP) s.history.length = HISTORY_CAP;
13412
- }),
13413
- clearHistory: () => set2((s) => {
13414
- s.history = [];
13419
+ addHistoryEntry: (entry) => {
13420
+ set2((s) => {
13421
+ s.history.unshift(entry);
13422
+ if (s.history.length > HISTORY_CAP) s.history.length = HISTORY_CAP;
13423
+ });
13424
+ persistIfEnabled(get2);
13425
+ },
13426
+ clearHistory: () => {
13427
+ set2((s) => {
13428
+ s.history = [];
13429
+ });
13430
+ persistIfEnabled(get2);
13431
+ },
13432
+ setHistory: (entries) => set2((s) => {
13433
+ s.history = entries.slice(0, HISTORY_CAP);
13415
13434
  })
13416
13435
  });
13417
13436
  const createRunnerSlice = (set2) => ({
@@ -13983,6 +14002,21 @@ const createEnvironmentsSlice = (set2, get2) => ({
13983
14002
  updateEnvironment: (id2, data) => set2((s) => {
13984
14003
  if (s.environments[id2]) s.environments[id2].data = data;
13985
14004
  }),
14005
+ upsertEnvVar: (envId, key, value) => {
14006
+ set2((s) => {
14007
+ const env = s.environments[envId]?.data;
14008
+ if (!env) return;
14009
+ const existing = env.variables.find((v) => v.key === key && !v.secret);
14010
+ if (existing) existing.value = value;
14011
+ else env.variables.push({ key, value, enabled: true });
14012
+ });
14013
+ const entry = get2().environments[envId];
14014
+ if (entry) {
14015
+ window.electron.saveEnvironment(entry.relPath, entry.data).catch((err) => {
14016
+ console.warn("upsertEnvVar: could not save environment", entry.relPath, err);
14017
+ });
14018
+ }
14019
+ },
13986
14020
  addEnvironment: () => set2((s) => {
13987
14021
  const existingNames = Object.values(s.environments).map((e) => e.data.name);
13988
14022
  const envName = uniqueName("New Environment", existingNames);
@@ -14152,7 +14186,7 @@ const useStore = create()(
14152
14186
  immer((set2, get2, api) => ({
14153
14187
  // ── Slice composition ─────────────────────────────────────────────────────
14154
14188
  ...createWsSlice(set2),
14155
- ...createHistorySlice(set2),
14189
+ ...createHistorySlice(set2, get2),
14156
14190
  ...createRunnerSlice(set2),
14157
14191
  ...createRecorderSlice(set2),
14158
14192
  ...createContractSlice(set2),
@@ -14244,7 +14278,7 @@ const useStore = create()(
14244
14278
  })
14245
14279
  }))
14246
14280
  );
14247
- const { electron: electron$r } = window;
14281
+ const { electron: electron$s } = window;
14248
14282
  function useAutoSave() {
14249
14283
  const collections = useStore((s) => s.collections);
14250
14284
  useStore((s) => s.environments);
@@ -14260,7 +14294,7 @@ function useAutoSave() {
14260
14294
  for (const { relPath, data, dirty } of dirtyCollections) {
14261
14295
  if (!dirty) continue;
14262
14296
  try {
14263
- await electron$r.saveCollection(relPath, data);
14297
+ await electron$s.saveCollection(relPath, data);
14264
14298
  markCollectionClean(data.id);
14265
14299
  } catch (e) {
14266
14300
  console.error("Auto-save failed for", relPath, e);
@@ -14276,7 +14310,7 @@ function useAutoSave() {
14276
14310
  if (wsTimerRef.current) clearTimeout(wsTimerRef.current);
14277
14311
  wsTimerRef.current = setTimeout(async () => {
14278
14312
  try {
14279
- await electron$r.saveWorkspace(workspace);
14313
+ await electron$s.saveWorkspace(workspace);
14280
14314
  } catch {
14281
14315
  }
14282
14316
  }, 300);
@@ -14285,7 +14319,7 @@ function useAutoSave() {
14285
14319
  };
14286
14320
  }, [workspace]);
14287
14321
  }
14288
- const { electron: electron$q } = window;
14322
+ const { electron: electron$r } = window;
14289
14323
  function useWorkspaceLoader() {
14290
14324
  const loadCollection = useStore((s) => s.loadCollection);
14291
14325
  const loadEnvironment = useStore((s) => s.loadEnvironment);
@@ -14307,16 +14341,25 @@ function useWorkspaceLoader() {
14307
14341
  });
14308
14342
  if (ws2.settings?.theme) setTheme(ws2.settings.theme);
14309
14343
  if (typeof ws2.settings?.zoom === "number") setZoom(ws2.settings.zoom);
14344
+ if (ws2.settings?.persistHistory) {
14345
+ try {
14346
+ const entries = await electron$r.loadHistory();
14347
+ useStore.getState().setHistory(entries);
14348
+ } catch {
14349
+ }
14350
+ } else {
14351
+ useStore.getState().setHistory([]);
14352
+ }
14310
14353
  for (const colPath of ws2.collections) {
14311
14354
  try {
14312
- const col = await electron$q.loadCollection(colPath);
14355
+ const col = await electron$r.loadCollection(colPath);
14313
14356
  loadCollection(colPath, col);
14314
14357
  } catch {
14315
14358
  }
14316
14359
  }
14317
14360
  for (const envPath of ws2.environments) {
14318
14361
  try {
14319
- const env = await electron$q.loadEnvironment(envPath);
14362
+ const env = await electron$r.loadEnvironment(envPath);
14320
14363
  loadEnvironment(envPath, env);
14321
14364
  } catch {
14322
14365
  }
@@ -14335,19 +14378,19 @@ function useWorkspaceLoader() {
14335
14378
  }
14336
14379
  for (const relPath of ws2.mocks ?? []) {
14337
14380
  try {
14338
- const mockData = await electron$q.loadMock(relPath);
14381
+ const mockData = await electron$r.loadMock(relPath);
14339
14382
  loadMock(relPath, mockData);
14340
14383
  } catch {
14341
14384
  }
14342
14385
  }
14343
14386
  try {
14344
- const snapshots = await electron$q.listContractSnapshots(ws2.contracts ?? []);
14387
+ const snapshots = await electron$r.listContractSnapshots(ws2.contracts ?? []);
14345
14388
  for (const { relPath, snapshot } of snapshots) loadContractSnapshot(relPath, snapshot);
14346
14389
  } catch {
14347
14390
  }
14348
14391
  if (ws2.collections.length > 0) {
14349
14392
  try {
14350
- const firstCol = await electron$q.loadCollection(ws2.collections[0]);
14393
+ const firstCol = await electron$r.loadCollection(ws2.collections[0]);
14351
14394
  setActiveCollection(firstCol.id);
14352
14395
  } catch {
14353
14396
  }
@@ -33137,8 +33180,8 @@ class CompletionTooltip {
33137
33180
  if (typeof section != "string" && section.header) {
33138
33181
  ul.appendChild(section.header(section));
33139
33182
  } else {
33140
- let header = ul.appendChild(document.createElement("completion-section"));
33141
- header.textContent = name2;
33183
+ let header2 = ul.appendChild(document.createElement("completion-section"));
33184
+ header2.textContent = name2;
33142
33185
  }
33143
33186
  }
33144
33187
  }
@@ -35948,7 +35991,7 @@ function CollectionSettingsModal({ collection, onClose }) {
35948
35991
  }
35949
35992
  );
35950
35993
  }
35951
- const { electron: electron$p } = window;
35994
+ const { electron: electron$q } = window;
35952
35995
  function normalisePath(url) {
35953
35996
  let path = url.replace(/^\{\{[^}]+\}\}/, "").replace(/^https?:\/\/[^/]+/, "");
35954
35997
  if (!path.startsWith("/")) path = "/" + path;
@@ -36035,7 +36078,7 @@ function SchemaSyncModal({
36035
36078
  setLoading(true);
36036
36079
  setError(null);
36037
36080
  try {
36038
- const entries = await electron$p.extractOpenApiSchemas();
36081
+ const entries = await electron$q.extractOpenApiSchemas();
36039
36082
  if (!entries) {
36040
36083
  setLoading(false);
36041
36084
  return;
@@ -36054,7 +36097,7 @@ function SchemaSyncModal({
36054
36097
  setLoading(true);
36055
36098
  setError(null);
36056
36099
  try {
36057
- const entries = await electron$p.extractOpenApiSchemasFromUrl(trimmed);
36100
+ const entries = await electron$q.extractOpenApiSchemasFromUrl(trimmed);
36058
36101
  setSpecEntries(entries);
36059
36102
  autoSelectChanged(entries);
36060
36103
  } catch (err) {
@@ -36087,7 +36130,7 @@ function SchemaSyncModal({
36087
36130
  }
36088
36131
  const entry = useStore.getState().collections[collectionId];
36089
36132
  if (entry) {
36090
- await electron$p.saveCollection(entry.relPath, entry.data);
36133
+ await electron$q.saveCollection(entry.relPath, entry.data);
36091
36134
  markCollectionClean(collectionId);
36092
36135
  }
36093
36136
  onClose();
@@ -55343,7 +55386,7 @@ function withContentType(headers, value) {
55343
55386
  if (idx === -1) return [...headers, next];
55344
55387
  return headers.map((h, i) => i === idx ? { ...h, value, enabled: true } : h);
55345
55388
  }
55346
- const { electron: electron$o } = window;
55389
+ const { electron: electron$p } = window;
55347
55390
  function ParamTree({ params, depth = 0 }) {
55348
55391
  if (params.length === 0) {
55349
55392
  return /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-[10px] text-surface-600 italic", children: "No parameters declared in WSDL." });
@@ -55369,7 +55412,7 @@ function SoapEditor({ request, onChange }) {
55369
55412
  let cancelled = false;
55370
55413
  (async () => {
55371
55414
  try {
55372
- const result = await electron$o.wsdlFetch(url);
55415
+ const result = await electron$p.wsdlFetch(url);
55373
55416
  if (cancelled) return;
55374
55417
  setOperations(result.operations);
55375
55418
  setEndpoints(result.endpoints);
@@ -55406,7 +55449,7 @@ function SoapEditor({ request, onChange }) {
55406
55449
  setFetching(true);
55407
55450
  setFetchError(null);
55408
55451
  try {
55409
- const result = await electron$o.wsdlFetch(soap.wsdlUrl.trim());
55452
+ const result = await electron$p.wsdlFetch(soap.wsdlUrl.trim());
55410
55453
  setOperations(result.operations);
55411
55454
  setEndpoints(result.endpoints);
55412
55455
  setTargetNs(result.targetNamespace);
@@ -55676,7 +55719,7 @@ function BodyTab({ request, onChange }) {
55676
55719
  mode === "soap" && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex-1 min-h-0", children: /* @__PURE__ */ jsxRuntimeExports.jsx(SoapEditor, { request, onChange }) })
55677
55720
  ] });
55678
55721
  }
55679
- const { electron: electron$n } = window;
55722
+ const { electron: electron$o } = window;
55680
55723
  const AUTH_TYPES = ["none", "bearer", "basic", "digest", "ntlm", "apikey", "oauth2"];
55681
55724
  function AuthTab({ request, onChange }) {
55682
55725
  const auth = request.auth;
@@ -55690,7 +55733,7 @@ function AuthTab({ request, onChange }) {
55690
55733
  }
55691
55734
  async function saveSecret(ref2) {
55692
55735
  if (!secretValue || !ref2) return;
55693
- await electron$n.setSecret(ref2, secretValue);
55736
+ await electron$o.setSecret(ref2, secretValue);
55694
55737
  setSaved(true);
55695
55738
  setSecretValue("");
55696
55739
  setTimeout(() => setSaved(false), 2e3);
@@ -55702,7 +55745,7 @@ function AuthTab({ request, onChange }) {
55702
55745
  setOauth2Error("");
55703
55746
  try {
55704
55747
  const vars = {};
55705
- const result = await electron$n.oauth2StartFlow(oauth2Auth, vars);
55748
+ const result = await electron$o.oauth2StartFlow(oauth2Auth, vars);
55706
55749
  setAuth({
55707
55750
  oauth2CachedToken: result.accessToken,
55708
55751
  oauth2TokenExpiry: result.expiresAt
@@ -55720,7 +55763,7 @@ function AuthTab({ request, onChange }) {
55720
55763
  setOauth2Status("fetching");
55721
55764
  setOauth2Error("");
55722
55765
  try {
55723
- const result = await electron$n.oauth2RefreshToken(oauth2Auth, {}, oauth2RefreshToken);
55766
+ const result = await electron$o.oauth2RefreshToken(oauth2Auth, {}, oauth2RefreshToken);
55724
55767
  setAuth({
55725
55768
  oauth2CachedToken: result.accessToken,
55726
55769
  oauth2TokenExpiry: result.expiresAt
@@ -63028,7 +63071,7 @@ function SchemaTab({ request, onChange }) {
63028
63071
  ] }) })
63029
63072
  ] });
63030
63073
  }
63031
- const { electron: electron$m } = window;
63074
+ const { electron: electron$n } = window;
63032
63075
  const EMPTY = { statusCode: 200, headers: [], bodySchema: "" };
63033
63076
  function ContractTab({ request, onChange }) {
63034
63077
  const activeTab = useStore((s) => s.tabs.find((t2) => t2.id === s.activeTabId));
@@ -63042,7 +63085,7 @@ function ContractTab({ request, onChange }) {
63042
63085
  if (!lastResponse?.body) return;
63043
63086
  setInferring(true);
63044
63087
  try {
63045
- const schema = await electron$m.inferContractSchema(lastResponse.body);
63088
+ const schema = await electron$n.inferContractSchema(lastResponse.body);
63046
63089
  if (schema) update({ bodySchema: schema });
63047
63090
  } finally {
63048
63091
  setInferring(false);
@@ -63168,7 +63211,7 @@ function ContractTab({ request, onChange }) {
63168
63211
  ] })
63169
63212
  ] });
63170
63213
  }
63171
- const { electron: electron$l } = window;
63214
+ const { electron: electron$m } = window;
63172
63215
  function formatTime$1(ts) {
63173
63216
  const d = new Date(ts);
63174
63217
  const hh = String(d.getHours()).padStart(2, "0");
@@ -63188,14 +63231,14 @@ function WebSocketPanel({ request }) {
63188
63231
  const [sendText, setSendText] = reactExports.useState("");
63189
63232
  const logEndRef = reactExports.useRef(null);
63190
63233
  reactExports.useEffect(() => {
63191
- electron$l.onWsMessage(({ requestId, message }) => {
63234
+ electron$m.onWsMessage(({ requestId, message }) => {
63192
63235
  addWsMessage(requestId, message);
63193
63236
  });
63194
- electron$l.onWsStatus(({ requestId, status, error: error2 }) => {
63237
+ electron$m.onWsStatus(({ requestId, status, error: error2 }) => {
63195
63238
  setWsStatus(requestId, status, error2);
63196
63239
  });
63197
63240
  return () => {
63198
- electron$l.offWsEvents();
63241
+ electron$m.offWsEvents();
63199
63242
  };
63200
63243
  }, [addWsMessage, setWsStatus]);
63201
63244
  reactExports.useEffect(() => {
@@ -63208,19 +63251,19 @@ function WebSocketPanel({ request }) {
63208
63251
  if (h.enabled && h.key) headers[h.key] = h.value;
63209
63252
  }
63210
63253
  try {
63211
- await electron$l.wsConnect(request.id, request.url, headers);
63254
+ await electron$m.wsConnect(request.id, request.url, headers);
63212
63255
  } catch (err) {
63213
63256
  setWsStatus(request.id, "error", err instanceof Error ? err.message : String(err));
63214
63257
  }
63215
63258
  }
63216
63259
  async function disconnect() {
63217
- await electron$l.wsDisconnect(request.id);
63260
+ await electron$m.wsDisconnect(request.id);
63218
63261
  }
63219
63262
  async function sendMessage() {
63220
63263
  const text = sendText.trim();
63221
63264
  if (!text || !isConnected) return;
63222
63265
  try {
63223
- await electron$l.wsSend(request.id, text);
63266
+ await electron$m.wsSend(request.id, text);
63224
63267
  const msg = {
63225
63268
  id: crypto.randomUUID(),
63226
63269
  direction: "sent",
@@ -63592,7 +63635,7 @@ function FuzzResultsPanel({ report, onClear }) {
63592
63635
  ] }) })
63593
63636
  ] });
63594
63637
  }
63595
- const { electron: electron$k } = window;
63638
+ const { electron: electron$l } = window;
63596
63639
  const WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
63597
63640
  function FuzzModal({ request, onClose }) {
63598
63641
  const environments = useStore((s) => s.environments);
@@ -63618,7 +63661,7 @@ function FuzzModal({ request, onClose }) {
63618
63661
  const env = resolveEnvironmentById(environments, activeEnvironmentId);
63619
63662
  const envVars = env ? Object.fromEntries(env.variables.filter((v) => v.enabled).map((v) => [v.key, v.value])) : {};
63620
63663
  const collectionVars = activeCollectionId ? collections[activeCollectionId]?.data.collectionVariables ?? {} : {};
63621
- const result = await electron$k.fuzzContracts({
63664
+ const result = await electron$l.fuzzContracts({
63622
63665
  requests: [request],
63623
63666
  envVars,
63624
63667
  collectionVars,
@@ -63710,7 +63753,7 @@ function FuzzModal({ request, onClose }) {
63710
63753
  }
63711
63754
  );
63712
63755
  }
63713
- const { electron: electron$j } = window;
63756
+ const { electron: electron$k } = window;
63714
63757
  const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "QUERY"];
63715
63758
  const METHOD_COLORS = {
63716
63759
  GET: "text-emerald-400",
@@ -63802,7 +63845,7 @@ function RequestBuilder({ request }) {
63802
63845
  activeEnvironmentId
63803
63846
  );
63804
63847
  const hookSessionVars = useStore.getState().sessionVars;
63805
- const r = await electron$j.sendRequest({
63848
+ const r = await electron$k.sendRequest({
63806
63849
  ...basePayload,
63807
63850
  environment: hookEnv,
63808
63851
  request: hook,
@@ -63846,7 +63889,7 @@ function RequestBuilder({ request }) {
63846
63889
  activeEnvironmentId
63847
63890
  );
63848
63891
  const freshSessionVars = useStore.getState().sessionVars;
63849
- const result = await electron$j.sendRequest({
63892
+ const result = await electron$k.sendRequest({
63850
63893
  ...basePayload,
63851
63894
  environment: freshEnv,
63852
63895
  request: mergedRequest,
@@ -63874,7 +63917,7 @@ function RequestBuilder({ request }) {
63874
63917
  activeEnvironmentId
63875
63918
  );
63876
63919
  const hookSessionVars = useStore.getState().sessionVars;
63877
- const r = await electron$j.sendRequest({
63920
+ const r = await electron$k.sendRequest({
63878
63921
  ...basePayload,
63879
63922
  environment: hookEnv,
63880
63923
  request: hook,
@@ -64650,7 +64693,7 @@ function HookResultsPanel({ results }) {
64650
64693
  }) })
64651
64694
  ] });
64652
64695
  }
64653
- const { electron: electron$i } = window;
64696
+ const { electron: electron$j } = window;
64654
64697
  function extractPath(url) {
64655
64698
  try {
64656
64699
  return new URL(url).pathname || "/";
@@ -64704,14 +64747,14 @@ function SaveAsMockModal({ onClose }) {
64704
64747
  const entry = state.mocks[serverId];
64705
64748
  const updated = { ...entry.data, name: newServerName, port: Number(newServerPort), routes: [route] };
64706
64749
  updateMock(serverId, updated);
64707
- await electron$i.saveMock(entry.relPath, updated);
64750
+ await electron$j.saveMock(entry.relPath, updated);
64708
64751
  const ws2 = useStore.getState().workspace;
64709
- if (ws2) await electron$i.saveWorkspace(ws2);
64752
+ if (ws2) await electron$j.saveWorkspace(ws2);
64710
64753
  } else {
64711
64754
  const entry = useStore.getState().mocks[serverId];
64712
64755
  const updated = { ...entry.data, routes: [...entry.data.routes, route] };
64713
64756
  updateMock(serverId, updated);
64714
- await electron$i.saveMock(entry.relPath, updated);
64757
+ await electron$j.saveMock(entry.relPath, updated);
64715
64758
  }
64716
64759
  onClose();
64717
64760
  } finally {
@@ -64994,7 +65037,7 @@ function RequestPanel({ sentRequest }) {
64994
65037
  if (!sentRequest) {
64995
65038
  return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex items-center justify-center h-full text-surface-400 text-xs", children: "Send a request to see what was transmitted." });
64996
65039
  }
64997
- const hasBody = sentRequest.body !== void 0 && sentRequest.body !== "";
65040
+ const hasBody2 = sentRequest.body !== void 0 && sentRequest.body !== "";
64998
65041
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex-1 min-h-0 overflow-y-auto text-xs font-mono", children: [
64999
65042
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "px-4 py-3 border-b border-surface-800 flex items-center gap-3", children: [
65000
65043
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "font-bold text-blue-400 shrink-0", children: sentRequest.method }),
@@ -65007,7 +65050,7 @@ function RequestPanel({ sentRequest }) {
65007
65050
  /* @__PURE__ */ jsxRuntimeExports.jsx("td", { className: "py-1 text-white break-all", children: v })
65008
65051
  ] }, k)) }) })
65009
65052
  ] }),
65010
- hasBody && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "px-4 py-2", children: [
65053
+ hasBody2 && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "px-4 py-2", children: [
65011
65054
  /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-[10px] text-surface-400 uppercase tracking-wider font-medium mb-1.5", children: "Body" }),
65012
65055
  /* @__PURE__ */ jsxRuntimeExports.jsx("pre", { className: "text-white whitespace-pre-wrap break-all text-[11px]", children: sentRequest.body })
65013
65056
  ] })
@@ -65043,7 +65086,183 @@ function ConsolePanel({ scriptResult }) {
65043
65086
  )) })
65044
65087
  ] });
65045
65088
  }
65046
- const { electron: electron$h } = window;
65089
+ function header(headers, name2) {
65090
+ const lower = name2.toLowerCase();
65091
+ for (const [k, v] of Object.entries(headers)) {
65092
+ if (k.toLowerCase() === lower) return v;
65093
+ }
65094
+ return void 0;
65095
+ }
65096
+ function hasBody(res) {
65097
+ return (res.bodySize ?? res.body.length) > 0;
65098
+ }
65099
+ const REDIRECTS_NEEDING_LOCATION = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
65100
+ const BODILESS = {
65101
+ 204: { label: "No Content", ref: "RFC 9110 §15.3.5" },
65102
+ 205: { label: "Reset Content", ref: "RFC 9110 §15.3.6" },
65103
+ 304: { label: "Not Modified", ref: "RFC 9110 §15.4.5" }
65104
+ };
65105
+ function validateHttpSemantics(res, opts = {}) {
65106
+ if (!res.status || res.status === 0) return [];
65107
+ const f = [];
65108
+ const method = res.method.toUpperCase();
65109
+ const { status } = res;
65110
+ const body = hasBody(res);
65111
+ const contentType = header(res.headers, "content-type");
65112
+ const contentEncoding = header(res.headers, "content-encoding");
65113
+ const clNum = Number(header(res.headers, "content-length") ?? NaN);
65114
+ const isBodiless = status in BODILESS || status >= 100 && status < 200;
65115
+ if (isBodiless) {
65116
+ const declaresBody = body || Number.isFinite(clNum) && clNum > 0;
65117
+ if (declaresBody) {
65118
+ const info = BODILESS[status] ?? { label: "Informational", ref: "RFC 9110 §15.2" };
65119
+ const via = body ? "" : ` (it declares Content-Length: ${clNum}; the body was discarded by the client)`;
65120
+ f.push({
65121
+ rule: status in BODILESS ? `no-body-${status}` : "no-body-1xx",
65122
+ severity: "error",
65123
+ message: `${status} ${info.label} must not include a message body${via}.`,
65124
+ ref: info.ref
65125
+ });
65126
+ }
65127
+ }
65128
+ if (method === "HEAD" && body) {
65129
+ f.push({ rule: "no-body-head", severity: "error", message: "Response to a HEAD request must not include a body.", ref: "RFC 9110 §9.3.2" });
65130
+ }
65131
+ if (status === 206 && !header(res.headers, "content-range")) {
65132
+ f.push({ rule: "206-no-content-range", severity: "error", message: "206 Partial Content must include a Content-Range header.", ref: "RFC 9110 §15.3.7" });
65133
+ }
65134
+ if (status === 416 && !header(res.headers, "content-range")) {
65135
+ f.push({ rule: "416-no-content-range", severity: "warning", message: '416 Range Not Satisfiable should include a Content-Range header (e.g. "bytes */1234").', ref: "RFC 9110 §15.5.17" });
65136
+ }
65137
+ if (REDIRECTS_NEEDING_LOCATION.has(status) && !header(res.headers, "location")) {
65138
+ f.push({ rule: "redirect-no-location", severity: "error", message: `${status} redirect has no Location header, so the client cannot follow it.`, ref: "RFC 9110 §15.4" });
65139
+ }
65140
+ if (status === 401 && !header(res.headers, "www-authenticate")) {
65141
+ f.push({ rule: "401-no-www-authenticate", severity: "error", message: "401 Unauthorized must include a WWW-Authenticate header.", ref: "RFC 9110 §15.5.2" });
65142
+ }
65143
+ if (status === 405 && !header(res.headers, "allow")) {
65144
+ f.push({ rule: "405-no-allow", severity: "error", message: "405 Method Not Allowed must include an Allow header listing valid methods.", ref: "RFC 9110 §15.5.6" });
65145
+ }
65146
+ if (body && !contentType && status !== 204 && status !== 304) {
65147
+ f.push({ rule: "body-no-content-type", severity: "warning", message: "Response has a body but no Content-Type header; clients must guess how to parse it.", ref: "RFC 9110 §8.3" });
65148
+ }
65149
+ if (body && contentType && /application\/(json|.*\+json)/i.test(contentType)) {
65150
+ try {
65151
+ JSON.parse(res.body);
65152
+ } catch {
65153
+ f.push({ rule: "json-invalid", severity: "error", message: `Content-Type is "${contentType}" but the body is not valid JSON.`, ref: "RFC 8259" });
65154
+ }
65155
+ }
65156
+ if (body && contentType && /[/+]xml\b/i.test(contentType) && opts.checkXml?.(res.body) === false) {
65157
+ f.push({ rule: "xml-invalid", severity: "error", message: `Content-Type is "${contentType}" but the body is not well-formed XML.`, ref: "XML 1.0 §2.1" });
65158
+ }
65159
+ if (contentType && /^text\//i.test(contentType) && !/charset=/i.test(contentType)) {
65160
+ f.push({ rule: "text-no-charset", severity: "hint", message: `"${contentType}" has no charset parameter; clients may misinterpret the encoding.`, ref: "RFC 9110 §8.3.2" });
65161
+ }
65162
+ const clRaw = header(res.headers, "content-length");
65163
+ if (clRaw !== void 0 && !contentEncoding && method !== "HEAD" && !isBodiless) {
65164
+ const actual = res.bodySize ?? res.body.length;
65165
+ if (Number.isFinite(clNum) && clNum !== actual) {
65166
+ f.push({ rule: "content-length-mismatch", severity: "error", message: `Content-Length is ${clNum} but the body is ${actual} bytes.`, ref: "RFC 9110 §8.6" });
65167
+ }
65168
+ }
65169
+ if (status < 100 || status > 599) {
65170
+ f.push({ rule: "status-out-of-range", severity: "warning", message: `${status} is not a valid HTTP status code (must be 100-599).`, ref: "RFC 9110 §15" });
65171
+ }
65172
+ if (!header(res.headers, "date") && status >= 200) {
65173
+ f.push({ rule: "no-date", severity: "hint", message: "No Date header; origin servers are expected to send one.", ref: "RFC 9110 §6.6.1" });
65174
+ }
65175
+ if ((status === 429 || status === 503) && !header(res.headers, "retry-after")) {
65176
+ f.push({ rule: "no-retry-after", severity: "hint", message: `${status} should include a Retry-After header telling clients when to retry.`, ref: "RFC 9110 §10.2.3" });
65177
+ }
65178
+ if (status === 201 && !header(res.headers, "location")) {
65179
+ f.push({ rule: "201-no-location", severity: "warning", message: "201 Created should include a Location header pointing at the new resource.", ref: "RFC 9110 §15.3.2" });
65180
+ }
65181
+ const order = { error: 0, warning: 1, hint: 2 };
65182
+ return f.sort((a, b) => order[a.severity] - order[b.severity]);
65183
+ }
65184
+ const { electron: electron$i } = window;
65185
+ function xmlWellFormed(body) {
65186
+ try {
65187
+ const doc2 = new DOMParser().parseFromString(body, "application/xml");
65188
+ return doc2.getElementsByTagName("parsererror").length === 0;
65189
+ } catch {
65190
+ return true;
65191
+ }
65192
+ }
65193
+ function requestBodyText(body) {
65194
+ switch (body?.mode) {
65195
+ case "json":
65196
+ return body.json ?? "";
65197
+ case "raw":
65198
+ return body.raw ?? "";
65199
+ case "graphql":
65200
+ return body.graphql?.query ?? "";
65201
+ case "soap":
65202
+ return body.soap?.envelope ?? "";
65203
+ case "form":
65204
+ return (body.form ?? []).filter((p2) => p2.enabled && p2.key).map((p2) => `${p2.key}=${p2.value}`).join("\n");
65205
+ default:
65206
+ return "";
65207
+ }
65208
+ }
65209
+ function KVBlock({ label, rows }) {
65210
+ if (rows.length === 0) return null;
65211
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex flex-col gap-0.5", children: [
65212
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[9px] uppercase tracking-wider text-surface-600 font-medium", children: label }),
65213
+ rows.map(([k, v]) => /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex gap-2 font-mono text-[10px]", children: [
65214
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "text-surface-500 shrink-0", children: [
65215
+ k,
65216
+ ":"
65217
+ ] }),
65218
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-surface-300 break-all", children: v })
65219
+ ] }, k))
65220
+ ] });
65221
+ }
65222
+ function HistoryTabRow({ entry, onLoad }) {
65223
+ const [open, setOpen] = reactExports.useState(false);
65224
+ const reqBody = requestBodyText(entry.request.body);
65225
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "border-b border-surface-800", children: [
65226
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex items-center gap-3 px-4 py-2 hover:bg-surface-800/40", children: [
65227
+ /* @__PURE__ */ jsxRuntimeExports.jsx("button", { onClick: () => setOpen((o) => !o), className: "text-surface-600 text-xs w-3 shrink-0", children: open ? "▾" : "▸" }),
65228
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `text-[10px] font-bold font-mono shrink-0 w-10 ${getMethodColor(entry.request.method)}`, children: entry.request.method }),
65229
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `text-xs font-bold font-mono shrink-0 w-8 ${getStatusColor(entry.response.status)}`, children: entry.response.status || "ERR" }),
65230
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "text-xs text-surface-400 shrink-0", children: [
65231
+ entry.response.durationMs,
65232
+ "ms"
65233
+ ] }),
65234
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "text-[11px] text-surface-500 shrink-0", children: [
65235
+ (entry.response.bodySize / 1024).toFixed(1),
65236
+ " KB"
65237
+ ] }),
65238
+ entry.environmentName && /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[10px] bg-surface-800 text-surface-400 px-1.5 py-0.5 rounded shrink-0", children: entry.environmentName }),
65239
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[11px] text-surface-500 ml-auto shrink-0", children: new Date(entry.timestamp).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" }) }),
65240
+ /* @__PURE__ */ jsxRuntimeExports.jsx("button", { onClick: onLoad, title: "Load this response into the viewer", className: "text-[10px] text-blue-400 hover:text-blue-300 shrink-0", children: "load" })
65241
+ ] }),
65242
+ open && /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "px-4 pb-3 pt-1 flex flex-col gap-3 bg-surface-950/40", children: [
65243
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex flex-col gap-1.5", children: [
65244
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[10px] uppercase tracking-wider text-surface-500 font-semibold", children: "Request" }),
65245
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("p", { className: "font-mono text-[10px] text-surface-300 break-all", children: [
65246
+ entry.request.method,
65247
+ " ",
65248
+ entry.resolvedUrl
65249
+ ] }),
65250
+ /* @__PURE__ */ jsxRuntimeExports.jsx(KVBlock, { label: "Headers", rows: entry.request.headers.filter((h) => h.enabled && h.key).map((h) => [h.key, h.value]) }),
65251
+ reqBody && /* @__PURE__ */ jsxRuntimeExports.jsx("pre", { className: "text-[10px] font-mono text-surface-300 bg-surface-900 border border-surface-800 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-words max-h-40 overflow-y-auto", children: reqBody })
65252
+ ] }),
65253
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex flex-col gap-1.5", children: [
65254
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[10px] uppercase tracking-wider text-surface-500 font-semibold", children: "Response" }),
65255
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "font-mono text-[10px]", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: getStatusColor(entry.response.status), children: [
65256
+ entry.response.status,
65257
+ " ",
65258
+ entry.response.statusText
65259
+ ] }) }),
65260
+ /* @__PURE__ */ jsxRuntimeExports.jsx(KVBlock, { label: "Headers", rows: Object.entries(entry.response.headers) }),
65261
+ entry.response.body && /* @__PURE__ */ jsxRuntimeExports.jsx("pre", { className: "text-[10px] font-mono text-surface-300 bg-surface-900 border border-surface-800 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-words max-h-56 overflow-y-auto", children: entry.response.body })
65262
+ ] })
65263
+ ] })
65264
+ ] });
65265
+ }
65047
65266
  function ResponseViewer() {
65048
65267
  const activeTab = useStore((s) => s.tabs.find((t2) => t2.id === s.activeTabId));
65049
65268
  const activeTabId = useStore((s) => s.activeTabId);
@@ -65058,7 +65277,25 @@ function ResponseViewer() {
65058
65277
  const sentRequest = activeTab?.lastSentRequest ?? null;
65059
65278
  const hookResults = activeTab?.lastHookResults ?? null;
65060
65279
  const requestId = activeTab?.requestId ?? null;
65280
+ const setTabResponse = useStore((s) => s.setTabResponse);
65281
+ const history2 = useStore((s) => s.history);
65282
+ const activeEnvironmentId = useStore((s) => s.activeEnvironmentId);
65283
+ const environments = useStore((s) => s.environments);
65284
+ const upsertEnvVar = useStore((s) => s.upsertEnvVar);
65061
65285
  const [tab, setTab] = reactExports.useState("body");
65286
+ const requestHistory = requestId ? history2.filter((e) => e.request.id === requestId) : [];
65287
+ const httpFindings = response && !response.error ? validateHttpSemantics({
65288
+ method: sentRequest?.method ?? "GET",
65289
+ status: response.status,
65290
+ statusText: response.statusText,
65291
+ headers: response.headers,
65292
+ body: response.body,
65293
+ bodySize: response.bodySize
65294
+ }, { checkXml: xmlWellFormed }) : [];
65295
+ const httpErrors = httpFindings.filter((x) => x.severity === "error").length;
65296
+ const [headerMenu, setHeaderMenu] = reactExports.useState(null);
65297
+ const [varDialog, setVarDialog] = reactExports.useState(null);
65298
+ const activeEnvName = activeEnvironmentId ? environments[activeEnvironmentId]?.data.name : void 0;
65062
65299
  reactExports.useEffect(() => {
65063
65300
  if (scriptResult?.preScriptError || scriptResult?.postScriptError) {
65064
65301
  setTab("console");
@@ -65071,7 +65308,7 @@ function ResponseViewer() {
65071
65308
  const contractToast = useToast(2500);
65072
65309
  async function saveAsContract() {
65073
65310
  if (!response || !requestId || !activeTabId) return;
65074
- const schema = response.body ? await electron$h.inferContractSchema(response.body) : null;
65311
+ const schema = response.body ? await electron$i.inferContractSchema(response.body) : null;
65075
65312
  const contentType2 = response.headers["content-type"];
65076
65313
  const headers = contentType2 ? [{ key: "content-type", value: contentType2, required: true }] : [];
65077
65314
  updateRequest(requestId, {
@@ -65115,16 +65352,26 @@ function ResponseViewer() {
65115
65352
  const isXml = !isJson && (contentType.includes("xml") || contentType.includes("html"));
65116
65353
  const supportsTree = isJson || isXml;
65117
65354
  const displayBody = isJson ? prettyJson(response.body) : isXml ? prettyXml(response.body) : response.body;
65355
+ const bodyParseError = response.body.trim().length > 0 && (isJson && (() => {
65356
+ try {
65357
+ JSON.parse(response.body);
65358
+ return false;
65359
+ } catch {
65360
+ return true;
65361
+ }
65362
+ })() || isXml && contentType.includes("xml") && !xmlWellFormed(response.body));
65118
65363
  const passedCount = scriptResult?.testResults.filter((t2) => t2.passed).length ?? 0;
65119
65364
  const totalCount = scriptResult?.testResults.length ?? 0;
65120
65365
  const consoleCount = scriptResult?.consoleOutput.length ?? 0;
65121
65366
  const hasScriptError = !!(scriptResult?.preScriptError || scriptResult?.postScriptError);
65122
65367
  const tabList = [
65123
65368
  { id: "request", label: "Request" },
65124
- { id: "body", label: "Body" },
65369
+ { id: "body", label: "Body", badge: bodyParseError ? "!" : void 0, error: bodyParseError },
65125
65370
  { id: "headers", label: "Headers" },
65126
65371
  { id: "tests", label: "Tests", badge: totalCount > 0 ? `${passedCount}/${totalCount}` : void 0 },
65127
- { id: "console", label: "Console", badge: hasScriptError ? "!" : consoleCount > 0 ? consoleCount : void 0, error: hasScriptError }
65372
+ { id: "console", label: "Console", badge: hasScriptError ? "!" : consoleCount > 0 ? consoleCount : void 0, error: hasScriptError },
65373
+ { id: "history", label: "History", badge: requestHistory.length > 0 ? requestHistory.length : void 0 },
65374
+ { id: "http", label: "HTTP", badge: httpFindings.length > 0 ? httpErrors > 0 ? "!" : httpFindings.length : void 0, error: httpErrors > 0 }
65128
65375
  ];
65129
65376
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "h-full flex flex-col", children: [
65130
65377
  hookResults && hookResults.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx(HookResultsPanel, { results: hookResults }),
@@ -65232,13 +65479,122 @@ function ResponseViewer() {
65232
65479
  readOnly: true,
65233
65480
  basicSetup: { lineNumbers: true, foldGutter: true }
65234
65481
  }
65235
- ) : tab === "headers" ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex-1 min-h-0 overflow-y-auto", children: /* @__PURE__ */ jsxRuntimeExports.jsx("table", { className: "w-full text-xs px-4 py-2", children: /* @__PURE__ */ jsxRuntimeExports.jsx("tbody", { children: Object.entries(response.headers).map(([k, v]) => /* @__PURE__ */ jsxRuntimeExports.jsxs("tr", { className: "border-b border-surface-800", children: [
65236
- /* @__PURE__ */ jsxRuntimeExports.jsx("td", { className: "py-1.5 px-4 text-surface-400 font-mono w-56 align-top", children: k }),
65237
- /* @__PURE__ */ jsxRuntimeExports.jsx("td", { className: "py-1.5 px-4 text-white font-mono break-all", children: v })
65238
- ] }, k)) }) }) }) : tab === "tests" ? /* @__PURE__ */ jsxRuntimeExports.jsx(TestsPanel, { scriptResult }) : tab === "console" ? /* @__PURE__ */ jsxRuntimeExports.jsx(ConsolePanel, { scriptResult }) : tab === "request" ? /* @__PURE__ */ jsxRuntimeExports.jsx(RequestPanel, { sentRequest }) : null })
65482
+ ) : tab === "headers" ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex-1 min-h-0 overflow-y-auto", children: /* @__PURE__ */ jsxRuntimeExports.jsx("table", { className: "w-full text-xs px-4 py-2", children: /* @__PURE__ */ jsxRuntimeExports.jsx("tbody", { children: Object.entries(response.headers).map(([k, v]) => /* @__PURE__ */ jsxRuntimeExports.jsxs(
65483
+ "tr",
65484
+ {
65485
+ className: "border-b border-surface-800 hover:bg-surface-800/40",
65486
+ onContextMenu: (e) => {
65487
+ e.preventDefault();
65488
+ setHeaderMenu({ x: e.clientX, y: e.clientY, key: k, value: v });
65489
+ },
65490
+ title: "Right-click to create an environment variable",
65491
+ children: [
65492
+ /* @__PURE__ */ jsxRuntimeExports.jsx("td", { className: "py-1.5 px-4 text-surface-400 font-mono w-56 align-top", children: k }),
65493
+ /* @__PURE__ */ jsxRuntimeExports.jsx("td", { className: "py-1.5 px-4 text-white font-mono break-all", children: v })
65494
+ ]
65495
+ },
65496
+ k
65497
+ )) }) }) }) : tab === "tests" ? /* @__PURE__ */ jsxRuntimeExports.jsx(TestsPanel, { scriptResult }) : tab === "console" ? /* @__PURE__ */ jsxRuntimeExports.jsx(ConsolePanel, { scriptResult }) : tab === "request" ? /* @__PURE__ */ jsxRuntimeExports.jsx(RequestPanel, { sentRequest }) : tab === "http" ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-4", children: httpFindings.length === 0 ? /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex flex-col items-center justify-center h-full gap-2 text-center", children: [
65498
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-2xl", children: "✓" }),
65499
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-sm text-emerald-400", children: "Conforms to HTTP semantics" }),
65500
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-xs text-surface-500 max-w-sm", children: "No violations of the HTTP specification (RFC 9110/9111) in this response. This check is automatic and needs no test or spec." })
65501
+ ] }) : /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex flex-col gap-2 max-w-3xl", children: httpFindings.map((find2, i) => {
65502
+ const tone = find2.severity === "error" ? "border-red-800/60 bg-red-950/20" : find2.severity === "warning" ? "border-amber-800/50 bg-amber-950/20" : "border-surface-700 bg-surface-800/40";
65503
+ const label = find2.severity === "error" ? "text-red-400" : find2.severity === "warning" ? "text-amber-400" : "text-surface-400";
65504
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: `border-l-2 rounded-r px-3 py-2 ${tone}`, children: [
65505
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [
65506
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `text-[10px] font-bold uppercase tracking-wider ${label}`, children: find2.severity }),
65507
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[10px] font-mono text-surface-500 bg-surface-900 px-1.5 py-0.5 rounded", children: find2.rule }),
65508
+ find2.ref && /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[10px] text-surface-600 ml-auto", children: find2.ref })
65509
+ ] }),
65510
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-xs text-surface-200 mt-1", children: find2.message })
65511
+ ] }, i);
65512
+ }) }) }) : tab === "history" ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex-1 min-h-0 overflow-y-auto", children: requestHistory.length === 0 ? /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-xs text-surface-500 text-center p-8", children: "No past responses for this request yet. Each send is recorded here." }) : /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex flex-col", children: requestHistory.map((entry) => /* @__PURE__ */ jsxRuntimeExports.jsx(
65513
+ HistoryTabRow,
65514
+ {
65515
+ entry,
65516
+ onLoad: () => {
65517
+ if (activeTabId) setTabResponse(activeTabId, entry.response, entry.scriptResult ?? null);
65518
+ }
65519
+ },
65520
+ entry.id
65521
+ )) }) }) : null }),
65522
+ headerMenu && /* @__PURE__ */ jsxRuntimeExports.jsx(
65523
+ ContextMenu,
65524
+ {
65525
+ x: headerMenu.x,
65526
+ y: headerMenu.y,
65527
+ onClose: () => setHeaderMenu(null),
65528
+ items: [
65529
+ { type: "header", label: headerMenu.key },
65530
+ activeEnvironmentId ? {
65531
+ type: "item",
65532
+ label: `Create variable in "${activeEnvName}"`,
65533
+ onClick: () => {
65534
+ setVarDialog({ name: headerMenu.key.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, ""), value: headerMenu.value });
65535
+ setHeaderMenu(null);
65536
+ }
65537
+ } : { type: "header", label: "Select an environment first" }
65538
+ ]
65539
+ }
65540
+ ),
65541
+ varDialog && /* @__PURE__ */ jsxRuntimeExports.jsx(Modal, { onClose: () => setVarDialog(null), title: "Create environment variable", panelClassName: "bg-surface-900 border border-surface-800 rounded-lg shadow-2xl w-[420px]", children: /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex flex-col gap-3 p-4", children: [
65542
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "flex flex-col gap-1", children: [
65543
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[10px] uppercase tracking-wider text-surface-600 font-medium", children: "Variable name" }),
65544
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
65545
+ "input",
65546
+ {
65547
+ autoFocus: true,
65548
+ value: varDialog.name,
65549
+ onChange: (e) => setVarDialog((d) => d && { ...d, name: e.target.value }),
65550
+ className: "bg-surface-800 border border-surface-700 rounded px-2.5 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-500"
65551
+ }
65552
+ )
65553
+ ] }),
65554
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "flex flex-col gap-1", children: [
65555
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-[10px] uppercase tracking-wider text-surface-600 font-medium", children: "Value" }),
65556
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
65557
+ "input",
65558
+ {
65559
+ value: varDialog.value,
65560
+ onChange: (e) => setVarDialog((d) => d && { ...d, value: e.target.value }),
65561
+ className: "bg-surface-800 border border-surface-700 rounded px-2.5 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-500"
65562
+ }
65563
+ )
65564
+ ] }),
65565
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("p", { className: "text-[11px] text-surface-500", children: [
65566
+ "Saved to ",
65567
+ activeEnvName ? `"${activeEnvName}"` : "the active environment",
65568
+ ". Use it as ",
65569
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("code", { className: "text-surface-300", children: [
65570
+ "{{",
65571
+ varDialog.name || "name",
65572
+ "}}"
65573
+ ] }),
65574
+ "."
65575
+ ] }),
65576
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex justify-end gap-2 mt-1", children: [
65577
+ /* @__PURE__ */ jsxRuntimeExports.jsx("button", { onClick: () => setVarDialog(null), className: "px-3 py-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors", children: "Cancel" }),
65578
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
65579
+ "button",
65580
+ {
65581
+ onClick: () => {
65582
+ if (activeEnvironmentId && varDialog.name.trim()) {
65583
+ upsertEnvVar(activeEnvironmentId, varDialog.name.trim(), varDialog.value);
65584
+ assertToast.show(`✓ Saved {{${varDialog.name.trim()}}}`, true);
65585
+ }
65586
+ setVarDialog(null);
65587
+ },
65588
+ disabled: !varDialog.name.trim(),
65589
+ className: "px-3 py-1.5 text-xs rounded bg-blue-600 hover:bg-blue-500 text-white font-semibold disabled:opacity-50 transition-colors",
65590
+ children: "Create"
65591
+ }
65592
+ )
65593
+ ] })
65594
+ ] }) })
65239
65595
  ] });
65240
65596
  }
65241
- const { electron: electron$g } = window;
65597
+ const { electron: electron$h } = window;
65242
65598
  const TARGETS = [
65243
65599
  { id: "robot_framework", label: "Robot Framework", description: "Python RequestsLibrary keywords + test suite" },
65244
65600
  { id: "playwright_ts", label: "Playwright TS", description: "TypeScript page-object API classes + spec files" },
@@ -65278,7 +65634,7 @@ function GeneratorPanel() {
65278
65634
  try {
65279
65635
  const col = collections[selectedCollectionId]?.data;
65280
65636
  const env = resolveEnvironmentById(environments, activeEnvironmentId);
65281
- const generated = await electron$g.generateCode({ collection: col, environment: env, target });
65637
+ const generated = await electron$h.generateCode({ collection: col, environment: env, target });
65282
65638
  setFiles(generated);
65283
65639
  setSelectedFile(generated[0]?.path ?? null);
65284
65640
  } catch (e) {
@@ -65290,7 +65646,7 @@ function GeneratorPanel() {
65290
65646
  async function saveZip() {
65291
65647
  if (files.length === 0) return;
65292
65648
  const col = collections[selectedCollectionId]?.data;
65293
- await electron$g.saveGeneratedFilesAsZip(files, col?.name ?? "api-tests", target);
65649
+ await electron$h.saveGeneratedFilesAsZip(files, col?.name ?? "api-tests", target);
65294
65650
  }
65295
65651
  const selectedContent = files.find((f) => f.path === selectedFile)?.content ?? "";
65296
65652
  const activeTarget = TARGETS.find((t2) => t2.id === target);
@@ -65389,6 +65745,92 @@ function GeneratorPanel() {
65389
65745
  files.length === 0 && !generating && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex-1 flex items-center justify-center text-surface-400 text-xs text-center px-6", children: "Select a collection and hit Generate to preview the output code." })
65390
65746
  ] });
65391
65747
  }
65748
+ function enabledHeaders(headers) {
65749
+ return headers.filter((h) => h.enabled && h.key).map((h) => ({ name: h.key, value: h.value }));
65750
+ }
65751
+ function queryStringOf(url) {
65752
+ const q = url.indexOf("?");
65753
+ if (q === -1) return [];
65754
+ const out = [];
65755
+ for (const pair2 of url.slice(q + 1).split("&")) {
65756
+ if (!pair2) continue;
65757
+ const eq = pair2.indexOf("=");
65758
+ const name2 = eq === -1 ? pair2 : pair2.slice(0, eq);
65759
+ const value = eq === -1 ? "" : pair2.slice(eq + 1);
65760
+ try {
65761
+ out.push({ name: decodeURIComponent(name2), value: decodeURIComponent(value) });
65762
+ } catch {
65763
+ out.push({ name: name2, value });
65764
+ }
65765
+ }
65766
+ return out;
65767
+ }
65768
+ function postDataOf(body) {
65769
+ if (!body || body.mode === "none") return void 0;
65770
+ switch (body.mode) {
65771
+ case "json":
65772
+ return body.json ? { mimeType: "application/json", text: body.json } : void 0;
65773
+ case "raw":
65774
+ return body.raw ? { mimeType: body.rawContentType ?? "text/plain", text: body.raw } : void 0;
65775
+ case "graphql":
65776
+ return body.graphql ? { mimeType: "application/json", text: JSON.stringify(body.graphql) } : void 0;
65777
+ case "soap":
65778
+ return body.soap ? { mimeType: "text/xml", text: body.soap.envelope ?? "" } : void 0;
65779
+ case "form": {
65780
+ const text = (body.form ?? []).filter((p2) => p2.enabled && p2.key).map((p2) => `${encodeURIComponent(p2.key)}=${encodeURIComponent(p2.value)}`).join("&");
65781
+ return { mimeType: "application/x-www-form-urlencoded", text };
65782
+ }
65783
+ default:
65784
+ return void 0;
65785
+ }
65786
+ }
65787
+ function historyToHar(entries, creatorVersion = "1.0") {
65788
+ const harEntries = entries.map((e) => {
65789
+ const post = postDataOf(e.request.body);
65790
+ const contentType = e.response.headers["content-type"] ?? e.response.headers["Content-Type"] ?? "text/plain";
65791
+ return {
65792
+ startedDateTime: new Date(e.timestamp).toISOString(),
65793
+ time: e.response.durationMs,
65794
+ request: {
65795
+ method: e.request.method,
65796
+ url: e.resolvedUrl,
65797
+ httpVersion: "HTTP/1.1",
65798
+ cookies: [],
65799
+ headers: enabledHeaders(e.request.headers),
65800
+ queryString: queryStringOf(e.resolvedUrl),
65801
+ ...post ? { postData: post } : {},
65802
+ headersSize: -1,
65803
+ bodySize: post ? post.text.length : 0
65804
+ },
65805
+ response: {
65806
+ status: e.response.status,
65807
+ statusText: e.response.statusText,
65808
+ httpVersion: "HTTP/1.1",
65809
+ cookies: [],
65810
+ headers: Object.entries(e.response.headers).map(([name2, value]) => ({ name: name2, value })),
65811
+ content: {
65812
+ size: e.response.bodySize,
65813
+ mimeType: contentType,
65814
+ text: e.response.body
65815
+ },
65816
+ redirectURL: "",
65817
+ headersSize: -1,
65818
+ bodySize: e.response.bodySize
65819
+ },
65820
+ cache: {},
65821
+ timings: { send: 0, wait: e.response.durationMs, receive: 0 },
65822
+ ...e.environmentName ? { comment: `environment: ${e.environmentName}` } : {}
65823
+ };
65824
+ });
65825
+ return JSON.stringify({
65826
+ log: {
65827
+ version: "1.2",
65828
+ creator: { name: "API Spector", version: creatorVersion },
65829
+ entries: harEntries
65830
+ }
65831
+ }, null, 2);
65832
+ }
65833
+ const { electron: electron$g } = window;
65392
65834
  const STATUS_COLOR = {
65393
65835
  "2": "text-emerald-400",
65394
65836
  "3": "text-amber-400",
@@ -65416,6 +65858,8 @@ function HistoryPanel() {
65416
65858
  const clearHistory = useStore((s) => s.clearHistory);
65417
65859
  const activeTabId = useStore((s) => s.activeTabId);
65418
65860
  const setTabResponse = useStore((s) => s.setTabResponse);
65861
+ const setActiveRequest = useStore((s) => s.setActiveRequest);
65862
+ const collections = useStore((s) => s.collections);
65419
65863
  const [selected, setSelected] = reactExports.useState(null);
65420
65864
  const [search, setSearch] = reactExports.useState("");
65421
65865
  const filtered = search ? history2.filter(
@@ -65431,9 +65875,18 @@ function HistoryPanel() {
65431
65875
  groups.push({ label, entries: [entry] });
65432
65876
  }
65433
65877
  }
65878
+ async function downloadHar() {
65879
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:T]/g, "-");
65880
+ await electron$g.saveResults(historyToHar(history2), `api-spector-history-${stamp}.har`);
65881
+ }
65434
65882
  function open(entry) {
65435
65883
  setSelected(entry);
65436
- if (activeTabId) {
65884
+ const stillExists = Object.values(collections).some((c) => entry.request.id in c.data.requests);
65885
+ if (stillExists) {
65886
+ setActiveRequest(entry.request.id);
65887
+ const tabId = useStore.getState().activeTabId;
65888
+ if (tabId) setTabResponse(tabId, entry.response, entry.scriptResult ?? null);
65889
+ } else if (activeTabId) {
65437
65890
  setTabResponse(activeTabId, entry.response, entry.scriptResult ?? null);
65438
65891
  }
65439
65892
  }
@@ -65448,18 +65901,29 @@ function HistoryPanel() {
65448
65901
  className: "flex-1 bg-surface-800 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
65449
65902
  }
65450
65903
  ),
65451
- history2.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsx(
65452
- "button",
65453
- {
65454
- onClick: () => {
65455
- clearHistory();
65456
- setSelected(null);
65457
- },
65458
- className: "text-xs text-surface-400 hover:text-red-400 transition-colors px-1",
65459
- title: "Clear all history",
65460
- children: "Clear"
65461
- }
65462
- )
65904
+ history2.length > 0 && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
65905
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
65906
+ "button",
65907
+ {
65908
+ onClick: downloadHar,
65909
+ className: "text-xs text-surface-400 hover:text-surface-100 transition-colors px-1",
65910
+ title: "Download history as a HAR file",
65911
+ children: "HAR"
65912
+ }
65913
+ ),
65914
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
65915
+ "button",
65916
+ {
65917
+ onClick: () => {
65918
+ clearHistory();
65919
+ setSelected(null);
65920
+ },
65921
+ className: "text-xs text-surface-400 hover:text-red-400 transition-colors px-1",
65922
+ title: "Clear all history",
65923
+ children: "Clear"
65924
+ }
65925
+ )
65926
+ ] })
65463
65927
  ] }),
65464
65928
  history2.length === 0 ? /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "flex-1 flex items-center justify-center text-xs text-surface-400 px-4 text-center", children: "No history yet. Send a request to start recording." }) : /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex-1 overflow-y-auto", children: [
65465
65929
  filtered.length === 0 && /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "px-3 py-4 text-xs text-surface-400", children: "No matches." }),
@@ -66241,6 +66705,7 @@ function WorkspaceSettingsModal({ onClose }) {
66241
66705
  const existing = workspace?.settings ?? {};
66242
66706
  const [activeTab, setActiveTab] = reactExports.useState("general");
66243
66707
  const [defaultEnvironment, setDefaultEnvironment] = reactExports.useState(existing.defaultEnvironment ?? "");
66708
+ const [persistHistory, setPersistHistory] = reactExports.useState(existing.persistHistory ?? false);
66244
66709
  const [proxyUrl, setProxyUrl] = reactExports.useState(existing.proxy?.url ?? "");
66245
66710
  const [proxyUser, setProxyUser] = reactExports.useState(existing.proxy?.auth?.username ?? "");
66246
66711
  const [proxyPass, setProxyPass] = reactExports.useState(existing.proxy?.auth?.password ?? "");
@@ -66287,9 +66752,15 @@ function WorkspaceSettingsModal({ onClose }) {
66287
66752
  else delete settings.dashboardUrl;
66288
66753
  if (defaultEnvironment) settings.defaultEnvironment = defaultEnvironment;
66289
66754
  else delete settings.defaultEnvironment;
66755
+ if (persistHistory) settings.persistHistory = true;
66756
+ else delete settings.persistHistory;
66290
66757
  updateWorkspaceSettings(settings);
66291
66758
  const updated = useStore.getState().workspace;
66292
66759
  if (updated) await electron$b.saveWorkspace(updated);
66760
+ if (persistHistory) {
66761
+ await electron$b.saveHistory(useStore.getState().history).catch(() => {
66762
+ });
66763
+ }
66293
66764
  onClose();
66294
66765
  }
66295
66766
  function zoomStep(dir) {
@@ -66339,7 +66810,22 @@ function WorkspaceSettingsModal({ onClose }) {
66339
66810
  }
66340
66811
  )
66341
66812
  ] }),
66342
- /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-surface-600 text-[11px]", children: "CLI runs without --environment use this environment, and the app selects it when no environment is active." })
66813
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "text-surface-600 text-[11px]", children: "CLI runs without --environment use this environment, and the app selects it when no environment is active." }),
66814
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "flex items-start gap-2 mt-2 cursor-pointer", children: [
66815
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
66816
+ "input",
66817
+ {
66818
+ type: "checkbox",
66819
+ checked: persistHistory,
66820
+ onChange: (e) => setPersistHistory(e.target.checked),
66821
+ className: "mt-0.5"
66822
+ }
66823
+ ),
66824
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "flex flex-col gap-0.5", children: [
66825
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-surface-200", children: "Persist request history" }),
66826
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "text-surface-600 text-[11px]", children: "Save history to history.json in the workspace folder so it survives restarts. The file is gitignored. Off by default; history stays in memory otherwise." })
66827
+ ] })
66828
+ ] })
66343
66829
  ] }),
66344
66830
  activeTab === "appearance" && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
66345
66831
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "flex flex-col gap-1", children: [
@@ -71568,7 +72054,7 @@ function App() {
71568
72054
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { style: { color: "#6aa3c8" }, children: "Spector" }),
71569
72055
  /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "ml-2 text-[10px] font-normal opacity-50", children: [
71570
72056
  "v",
71571
- "0.3.6"
72057
+ "0.3.8"
71572
72058
  ] }),
71573
72059
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "mx-2 opacity-30", children: "·" }),
71574
72060
  /* @__PURE__ */ jsxRuntimeExports.jsx(