mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 20:23:40 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
479a2af6c3 | ||
|
|
919e58fc9d | ||
|
|
b4417add33 | ||
|
|
7890281db3 | ||
|
|
d44d75dcef | ||
|
|
7f30253616 | ||
|
|
6c2a18d506 | ||
|
|
0a57d8eb61 | ||
|
|
411aa262c7 | ||
|
|
34815f327a | ||
|
|
6fc43a60e2 | ||
|
|
23d369d84e |
No files matched your search
Generated
+49
-99
@@ -65,13 +65,28 @@ version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-no-stdlib 2.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -778,9 +793,20 @@ version = "8.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
"alloc-no-stdlib 2.0.4",
|
||||
"alloc-stdlib 0.2.2",
|
||||
"brotli-decompressor 5.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "9.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
"alloc-stdlib 0.3.0",
|
||||
"brotli-decompressor 6.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -789,8 +815,18 @@ version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"alloc-no-stdlib 2.0.4",
|
||||
"alloc-stdlib 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
"alloc-stdlib 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1241,11 +1277,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.43"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
|
||||
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"brotli 9.1.1",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
@@ -3733,15 +3769,6 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -7846,56 +7873,6 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json_path"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b992cea3194eea663ba99a042d61cea4bd1872da37021af56f6a37e0359b9d33"
|
||||
dependencies = [
|
||||
"inventory",
|
||||
"nom 7.1.3",
|
||||
"regex 1.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_json_path_core",
|
||||
"serde_json_path_macros",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json_path_core"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dde67d8dfe7d4967b5a95e247d4148368ddd1e753e500adb34b3ffe40c6bc1bc"
|
||||
dependencies = [
|
||||
"inventory",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json_path_macros"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "517acfa7f77ddaf5c43d5f119c44a683774e130b4247b7d3210f8924506cfac8"
|
||||
dependencies = [
|
||||
"inventory",
|
||||
"serde_json_path_core",
|
||||
"serde_json_path_macros_internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json_path_macros_internal"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
@@ -8679,7 +8656,7 @@ version = "2.6.1"
|
||||
source = "git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f36d72e3a14ee53#d9bc695c18d9a25baec21d8a5f36d72e3a14ee53"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
"brotli 8.0.1",
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
@@ -9020,7 +8997,7 @@ version = "2.9.1"
|
||||
source = "git+https://github.com/tauri-apps/tauri?rev=d9bc695c18d9a25baec21d8a5f36d72e3a14ee53#d9bc695c18d9a25baec21d8a5f36d72e3a14ee53"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
"brotli 8.0.1",
|
||||
"cargo_metadata",
|
||||
"ctor",
|
||||
"dom_query",
|
||||
@@ -11226,11 +11203,9 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"yaak-assertions",
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-jsonpath",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-templates",
|
||||
@@ -11333,17 +11308,6 @@ dependencies = [
|
||||
"yaak-window",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-assertions"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ts-rs",
|
||||
"yaak-jsonpath",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-cli"
|
||||
version = "0.1.0"
|
||||
@@ -11376,7 +11340,6 @@ dependencies = [
|
||||
"webbrowser",
|
||||
"yaak",
|
||||
"yaak-api",
|
||||
"yaak-assertions",
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
@@ -11519,7 +11482,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"async-trait",
|
||||
"brotli",
|
||||
"brotli 9.1.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"flate2",
|
||||
@@ -11546,16 +11509,6 @@ dependencies = [
|
||||
"zstd 0.13.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-jsonpath"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_json_path",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-license"
|
||||
version = "0.1.0"
|
||||
@@ -11616,7 +11569,6 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
"ts-rs",
|
||||
"urlencoding",
|
||||
"yaak-assertions",
|
||||
"yaak-core",
|
||||
"yaak-database",
|
||||
"yaak-templates",
|
||||
@@ -11722,7 +11674,6 @@ dependencies = [
|
||||
"ts-rs",
|
||||
"yaak-git",
|
||||
"yaak-grpc",
|
||||
"yaak-jsonpath",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-sse",
|
||||
@@ -11752,6 +11703,7 @@ dependencies = [
|
||||
"serde_path_to_error",
|
||||
"serde_yaml",
|
||||
"sha1 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
@@ -11826,8 +11778,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"yaak-assertions",
|
||||
"yaak-jsonpath",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-templates",
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/yaak",
|
||||
"crates/yaak-assertions",
|
||||
"crates/yaak-jsonpath",
|
||||
"crates/yaak-commands",
|
||||
# Common/foundation crates
|
||||
"crates/common/yaak-database",
|
||||
@@ -84,8 +82,6 @@ yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
||||
# Internal crates - shared
|
||||
yaak-core = { path = "crates/yaak-core" }
|
||||
yaak = { path = "crates/yaak" }
|
||||
yaak-assertions = { path = "crates/yaak-assertions" }
|
||||
yaak-jsonpath = { path = "crates/yaak-jsonpath" }
|
||||
yaak-commands = { path = "crates/yaak-commands" }
|
||||
yaak-common = { path = "crates/yaak-common" }
|
||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
|
||||
@@ -6,11 +6,12 @@ import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
||||
|
||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
string | null,
|
||||
unknown,
|
||||
Environment | null
|
||||
>({
|
||||
/**
|
||||
* Prompt for and create a sub-environment of the given base environment, resolving with the new
|
||||
* environment's ID (or null if the dialog was dismissed). This does not change the workspace's
|
||||
* active environment.
|
||||
*/
|
||||
export const createSubEnvironment = createFastMutation<string | null, unknown, Environment | null>({
|
||||
mutationKey: ["create_environment"],
|
||||
mutationFn: async (baseEnvironment) => {
|
||||
if (baseEnvironment == null) {
|
||||
@@ -41,6 +42,17 @@ export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Same as {@link createSubEnvironment}, but also makes the new environment the active one. */
|
||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
string | null,
|
||||
unknown,
|
||||
Environment | null
|
||||
>({
|
||||
mutationKey: ["create_environment_and_activate"],
|
||||
mutationFn: (baseEnvironment) => createSubEnvironment.mutateAsync(baseEnvironment),
|
||||
disableToastError: true, // Already shown by createSubEnvironment
|
||||
onSuccess: async (environmentId) => {
|
||||
if (environmentId == null) {
|
||||
return; // Was not created
|
||||
|
||||
@@ -4,8 +4,16 @@ import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createSubEnvironment } from "../commands/createEnvironment";
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import {
|
||||
environmentsBreakdownAtom,
|
||||
@@ -40,6 +48,9 @@ interface Props {
|
||||
|
||||
type TreeModel = Environment | Workspace;
|
||||
|
||||
/** Lets tree row slots create a sub-environment and select it in the editor. */
|
||||
const CreateSubEnvironmentContext = createContext<() => Promise<void>>(async () => {});
|
||||
|
||||
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
|
||||
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
|
||||
@@ -115,7 +126,7 @@ function EnvironmentEditDialogSidebar({
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
|
||||
const treeId = `environment.${activeWorkspaceId}.sidebar`;
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||
useLayoutEffect(() => {
|
||||
@@ -124,6 +135,31 @@ function EnvironmentEditDialogSidebar({
|
||||
treeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// A just-created environment to select once it reaches the model store. Selecting it before
|
||||
// then would show a "failed to find" error in the editor, and the tree would drop the
|
||||
// selection of an item it doesn't have yet.
|
||||
const [pendingSelectionId, setPendingSelectionId] = useState<string | null>(null);
|
||||
const tree = useAtomValue(treeAtom);
|
||||
useEffect(() => {
|
||||
if (pendingSelectionId == null) return;
|
||||
if (!allEnvironments.some((e) => e.id === pendingSelectionId)) return;
|
||||
|
||||
setPendingSelectionId(null);
|
||||
setSelectedEnvironmentId(pendingSelectionId);
|
||||
|
||||
// The tree leaves out sub-environments while there are multiple base environments
|
||||
const node = tree == null ? null : findTreeNode(tree, pendingSelectionId);
|
||||
if (node == null) return;
|
||||
if (node.parent != null) {
|
||||
// Expand the parent so the new item is visible to be selected
|
||||
const parentId = node.parent.item.id;
|
||||
jotaiStore.set(collapsedFamily(treeId), (prev) => ({ ...prev, [parentId]: false }));
|
||||
}
|
||||
// Wait a frame so the tree's own pending selection fixups, which may have been scheduled
|
||||
// before the new item arrived, don't reset this selection
|
||||
requestAnimationFrame(() => treeRef.current?.selectItem(pendingSelectionId));
|
||||
}, [allEnvironments, pendingSelectionId, setSelectedEnvironmentId, tree, treeId]);
|
||||
|
||||
const handleDeleteEnvironment = useCallback(
|
||||
async (environment: Environment) => {
|
||||
await deleteModelWithConfirm(environment);
|
||||
@@ -134,6 +170,14 @@ function EnvironmentEditDialogSidebar({
|
||||
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
|
||||
);
|
||||
|
||||
const handleCreateSubEnvironment = useCallback(async () => {
|
||||
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
|
||||
if (baseEnvironment == null) return;
|
||||
const id = await createSubEnvironment.mutateAsync(baseEnvironment);
|
||||
// Show the new environment in the editor without changing the workspace's active environment
|
||||
if (id != null) setPendingSelectionId(id);
|
||||
}, []);
|
||||
|
||||
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
||||
|
||||
const getSelectedTreeModels = useCallback(
|
||||
@@ -195,13 +239,14 @@ function EnvironmentEditDialogSidebar({
|
||||
const addEnvironmentItem: DropdownItem = {
|
||||
label: "Create Sub Environment",
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: async () => {
|
||||
await createSubEnvironment();
|
||||
},
|
||||
onSelect: handleCreateSubEnvironment,
|
||||
};
|
||||
// Sub-environments aren't shown while there are multiple base environments, same as the
|
||||
// plus button on the base environment row
|
||||
const canCreateSubEnvironment = baseEnvironments.length <= 1;
|
||||
|
||||
if (environment == null || environment.model !== "environment") {
|
||||
return [addEnvironmentItem];
|
||||
return canCreateSubEnvironment ? [addEnvironmentItem] : [];
|
||||
}
|
||||
|
||||
const singleEnvironment = items.length === 1;
|
||||
@@ -257,7 +302,7 @@ function EnvironmentEditDialogSidebar({
|
||||
];
|
||||
|
||||
// Add sub environment to base environment
|
||||
if (isBaseEnvironment(environment) && singleEnvironment) {
|
||||
if (isBaseEnvironment(environment) && singleEnvironment && canCreateSubEnvironment) {
|
||||
menuItems.push({ type: "separator" });
|
||||
menuItems.push(addEnvironmentItem);
|
||||
}
|
||||
@@ -266,6 +311,7 @@ function EnvironmentEditDialogSidebar({
|
||||
},
|
||||
[
|
||||
baseEnvironments.length,
|
||||
handleCreateSubEnvironment,
|
||||
handleDeleteEnvironment,
|
||||
handleDuplicateSelected,
|
||||
handleRenameSelected,
|
||||
@@ -317,34 +363,37 @@ function EnvironmentEditDialogSidebar({
|
||||
);
|
||||
|
||||
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
|
||||
({ items, position, onClose }) => (
|
||||
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
||||
),
|
||||
({ items, position, onClose }) =>
|
||||
// Right-clicking empty space has nothing to offer while there are multiple base environments
|
||||
items.length === 0 ? null : (
|
||||
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const tree = useAtomValue(treeAtom);
|
||||
return (
|
||||
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
|
||||
{tree != null && (
|
||||
<div className="pt-2">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
treeId={treeId}
|
||||
collapsedAtom={collapsedFamily(treeId)}
|
||||
className="px-2 pb-10"
|
||||
root={tree}
|
||||
getContextMenu={getContextMenu}
|
||||
renderContextMenu={renderContextMenuFn}
|
||||
onDragEnd={handleDragEnd}
|
||||
getItemKey={(i) => `${i.id}::${i.name}`}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
ItemRightSlot={ItemRightSlot}
|
||||
ItemInner={ItemInner}
|
||||
onActivate={handleActivate}
|
||||
getEditOptions={getEditOptions}
|
||||
/>
|
||||
</div>
|
||||
<CreateSubEnvironmentContext.Provider value={handleCreateSubEnvironment}>
|
||||
<div className="pt-2">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
treeId={treeId}
|
||||
collapsedAtom={collapsedFamily(treeId)}
|
||||
className="px-2 pb-10"
|
||||
root={tree}
|
||||
getContextMenu={getContextMenu}
|
||||
renderContextMenu={renderContextMenuFn}
|
||||
onDragEnd={handleDragEnd}
|
||||
getItemKey={(i) => `${i.id}::${i.name}`}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
ItemRightSlot={ItemRightSlot}
|
||||
ItemInner={ItemInner}
|
||||
onActivate={handleActivate}
|
||||
getEditOptions={getEditOptions}
|
||||
/>
|
||||
</div>
|
||||
</CreateSubEnvironmentContext.Provider>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
@@ -384,6 +433,15 @@ const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
|
||||
return root;
|
||||
});
|
||||
|
||||
function findTreeNode(node: TreeNode<TreeModel>, id: string): TreeNode<TreeModel> | null {
|
||||
if (node.item.id === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = findTreeNode(child, id);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
|
||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
||||
return baseEnvironments.length > 1 ? (
|
||||
@@ -395,6 +453,7 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) {
|
||||
|
||||
function ItemRightSlot({ item }: { item: TreeModel }) {
|
||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
||||
const handleCreateSubEnvironment = useContext(CreateSubEnvironmentContext);
|
||||
return (
|
||||
<>
|
||||
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
|
||||
@@ -405,7 +464,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
|
||||
icon="plus_circle"
|
||||
className="opacity-50 hover:opacity-100"
|
||||
title="Add Sub-Environment"
|
||||
onClick={createSubEnvironment}
|
||||
onClick={handleCreateSubEnvironment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -425,13 +484,6 @@ function ItemInner({ item }: { item: TreeModel }) {
|
||||
);
|
||||
}
|
||||
|
||||
async function createSubEnvironment() {
|
||||
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
|
||||
if (baseEnvironment == null) return;
|
||||
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getEditOptions(item: TreeModel) {
|
||||
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
|
||||
defaultValue: item.name,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
|
||||
import { headerNames } from "../lib/data/headerNames";
|
||||
import { mimeTypes } from "../lib/data/mimetypes";
|
||||
import { userAgents } from "../lib/data/userAgents";
|
||||
import { validateHttpHeader } from "../lib/validateHttpHeader";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
@@ -178,13 +179,3 @@ const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const validateHttpHeader = (v: string) => {
|
||||
if (v === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Template strings are not allowed so we replace them with a valid example string
|
||||
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
|
||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import deepEqual from "@gilbarbara/deep-equal";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { AssertionReport, HttpAssertions, HttpResponse } from "@yaakapp-internal/models";
|
||||
import { format } from "date-fns";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { Button } from "./core/Button";
|
||||
import { HttpAssertionReport } from "./HttpAssertionResults";
|
||||
|
||||
interface Props {
|
||||
assertions: HttpAssertions;
|
||||
response: HttpResponse | null;
|
||||
}
|
||||
|
||||
export function HttpAssertionPreview(props: Props) {
|
||||
// A response switch (including a new send) discards results and in-flight UI
|
||||
// state. The old evaluation may finish, but it cannot label the new response.
|
||||
return <ResponsePreview key={`${props.response?.id}:${props.response?.updatedAt}`} {...props} />;
|
||||
}
|
||||
|
||||
function ResponsePreview({ assertions, response }: Props) {
|
||||
const preview = useMutation({
|
||||
mutationFn: (definition: HttpAssertions) =>
|
||||
rpc<AssertionReport>("cmd_preview_http_assertions", {
|
||||
responseId: response?.id,
|
||||
assertions: definition,
|
||||
}),
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
});
|
||||
const unavailable = !response
|
||||
? "Send a request first to get a response to try."
|
||||
: response.state !== "closed"
|
||||
? "Wait for the response to finish."
|
||||
: !assertions.checks.some((check) => check.enabled)
|
||||
? "Enable an assertion to try it."
|
||||
: null;
|
||||
const changed = preview.data != null && !deepEqual(preview.data.definition, assertions);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
disabled={unavailable != null || preview.isPending}
|
||||
onClick={() => preview.mutate(assertions)}
|
||||
>
|
||||
{preview.isPending ? "Trying assertions…" : "Try assertions"}
|
||||
</Button>
|
||||
<p className="text-xs text-text-subtle">
|
||||
{unavailable ?? "Preview against the selected response without sending again."}
|
||||
</p>
|
||||
{preview.isError && (
|
||||
<p className="text-sm text-danger" role="alert">
|
||||
Could not preview assertions:{" "}
|
||||
{preview.error instanceof Error ? preview.error.message : String(preview.error)}
|
||||
</p>
|
||||
)}
|
||||
{preview.data && response && (
|
||||
<section
|
||||
aria-label="Assertion preview"
|
||||
aria-live="polite"
|
||||
className="border border-border-subtle rounded-md p-3 space-y-2"
|
||||
>
|
||||
<p className="text-sm font-medium">Preview</p>
|
||||
<p className="text-xs text-text-subtle">
|
||||
Response from {format(`${response.createdAt}Z`, "MMM d, h:mm:ss a")}. Recorded results
|
||||
are unchanged.
|
||||
</p>
|
||||
{changed && (
|
||||
<p className="text-xs text-warning" role="status">
|
||||
Assertions changed. Try again to update this preview.
|
||||
</p>
|
||||
)}
|
||||
<HttpAssertionReport report={preview.data} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { AssertionReport, HttpResponse } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
|
||||
export function HttpAssertionResults({ response }: { response: HttpResponse }) {
|
||||
const report = response.assertionResults;
|
||||
if (!report)
|
||||
return <EmptyStateText>Assertions were not evaluated for this response.</EmptyStateText>;
|
||||
if (!report.error && report.results.length === 0)
|
||||
return <EmptyStateText>Waiting for the response to finish.</EmptyStateText>;
|
||||
return (
|
||||
<div className="h-full overflow-auto space-y-2 pb-3">
|
||||
<p className="text-xs text-text-subtle">Checks saved with this response.</p>
|
||||
<HttpAssertionReport report={report} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HttpAssertionReport({ report }: { report: AssertionReport }) {
|
||||
const passed = report.results.filter((r) => r.outcome === "passed").length;
|
||||
const failed = report.results.filter((r) => r.outcome === "failed").length;
|
||||
const errors = report.results.filter(
|
||||
(r) => r.outcome === "error" || r.outcome === "invalid",
|
||||
).length;
|
||||
const skipped = report.results.filter((r) => r.outcome === "skipped").length;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">
|
||||
{passed} passed · {failed} failed · {errors} errors · {skipped} skipped
|
||||
</p>
|
||||
{report.error && <p className="text-sm text-danger">{report.error}</p>}
|
||||
{report.results.map((result, index) => {
|
||||
const check = report.definition.checks[index];
|
||||
return (
|
||||
<div
|
||||
key={`${result.assertionId}:${index}`}
|
||||
className="border border-border-subtle rounded-md p-2 text-sm space-y-1"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-mono break-all">
|
||||
{check?.target === "status" ? "Status" : check?.selector}
|
||||
</span>
|
||||
<span
|
||||
className={classNames(
|
||||
"capitalize shrink-0",
|
||||
result.outcome === "passed"
|
||||
? "text-success"
|
||||
: result.outcome === "skipped"
|
||||
? "text-text-subtle"
|
||||
: "text-danger",
|
||||
)}
|
||||
>
|
||||
{result.outcome}
|
||||
</span>
|
||||
</div>
|
||||
{check && (
|
||||
<p className="text-xs break-all">
|
||||
{check.operator.replaceAll("_", " ")}
|
||||
{check.operator !== "exists" && (
|
||||
<>
|
||||
{" "}
|
||||
{check.expectedType === "null"
|
||||
? "null"
|
||||
: check.expectedType === "text"
|
||||
? JSON.stringify(check.expected)
|
||||
: check.expected}{" "}
|
||||
({check.expectedType})
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-text-subtle">{result.reason}</p>
|
||||
{result.actual != null && (
|
||||
<p className="font-mono text-xs break-all">Actual: {JSON.stringify(result.actual)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { HttpAssertion, HttpAssertions, HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModelDebounced } from "@yaakapp-internal/models";
|
||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { JsonPathInput } from "./JsonPathInput";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import { HttpAssertionPreview } from "./HttpAssertionPreview";
|
||||
|
||||
const EMPTY: HttpAssertions = { version: 1, checks: [] };
|
||||
const operators = [
|
||||
{ value: "equals", label: "Equals" },
|
||||
{ value: "not_equals", label: "Does not equal" },
|
||||
{ value: "greater_than", label: "Greater than" },
|
||||
{ value: "less_than", label: "Less than" },
|
||||
{ value: "contains", label: "Contains" },
|
||||
{ value: "exists", label: "Exists" },
|
||||
];
|
||||
|
||||
export function HttpAssertionsEditor({ request }: { request: HttpRequest }) {
|
||||
const updateKey = useRequestUpdateKey(request.id);
|
||||
const { activeResponse } = usePinnedHttpResponse(request.id);
|
||||
const [definition, setDefinition] = useStateWithDeps(request.assertions ?? EMPTY, [updateKey]);
|
||||
const validation = useQuery({
|
||||
queryKey: ["validate_assertions", definition],
|
||||
queryFn: () =>
|
||||
rpc<Record<string, string>>("cmd_validate_http_assertions", { assertions: definition }),
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const save = (checks: HttpAssertion[]) => {
|
||||
const next = { ...definition, checks };
|
||||
setDefinition(next);
|
||||
patchModelDebounced(request, { assertions: next });
|
||||
};
|
||||
const update = (id: string, patch: Partial<HttpAssertion>) =>
|
||||
save(definition.checks.map((c) => (c.id === id ? { ...c, ...patch } : c)));
|
||||
|
||||
return (
|
||||
<div className="@container h-full overflow-auto pb-3 space-y-3">
|
||||
<p className="text-sm text-text-subtle">
|
||||
Check the response each time you send. Results appear in the response’s Assertions tab.
|
||||
</p>
|
||||
{definition.checks.map((check, index) => (
|
||||
<div key={check.id} className="border border-border-subtle rounded-md p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
title={`Assertion ${index + 1}`}
|
||||
checked={check.enabled}
|
||||
onChange={(enabled) => update(check.id, { enabled })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="xs"
|
||||
title={`Delete assertion ${index + 1}`}
|
||||
onClick={() => save(definition.checks.filter((c) => c.id !== check.id))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
|
||||
<Select
|
||||
name={`target-${check.id}`}
|
||||
label="Response"
|
||||
size="sm"
|
||||
value={check.target}
|
||||
options={[
|
||||
{ value: "json", label: "JSON body" },
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "header", label: "Header" },
|
||||
]}
|
||||
onChange={(target) =>
|
||||
update(check.id, {
|
||||
target,
|
||||
selector: "",
|
||||
operator: "equals",
|
||||
expected: target === "status" ? "200" : "",
|
||||
expectedType: target === "status" ? "number" : "text",
|
||||
})
|
||||
}
|
||||
/>
|
||||
{check.target === "json" && (
|
||||
<JsonPathInput
|
||||
response={activeResponse}
|
||||
stateKey={`assertion-path:${request.id}:${check.id}`}
|
||||
defaultValue={check.selector}
|
||||
forceUpdateKey={updateKey}
|
||||
onChange={(selector) => update(check.id, { selector })}
|
||||
/>
|
||||
)}
|
||||
{check.target === "header" && (
|
||||
<PlainInput
|
||||
label="Header name"
|
||||
size="sm"
|
||||
defaultValue={check.selector}
|
||||
forceUpdateKey={`${updateKey}:${check.target}`}
|
||||
placeholder="Content-Type"
|
||||
onChange={(selector) => update(check.id, { selector })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
|
||||
<Select
|
||||
name={`operator-${check.id}`}
|
||||
label="Comparison"
|
||||
size="sm"
|
||||
value={check.operator}
|
||||
options={operators.filter(
|
||||
(o) =>
|
||||
check.target === "json" ||
|
||||
(check.target === "status"
|
||||
? o.value !== "contains" && o.value !== "exists"
|
||||
: !["greater_than", "less_than"].includes(o.value)),
|
||||
)}
|
||||
onChange={(operator) =>
|
||||
update(check.id, {
|
||||
operator,
|
||||
...(["greater_than", "less_than"].includes(operator)
|
||||
? { expectedType: "number" }
|
||||
: operator === "contains"
|
||||
? { expectedType: "text" }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{check.operator !== "exists" && (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
{check.expectedType !== "null" && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<PlainInput
|
||||
label="Expected value"
|
||||
size="sm"
|
||||
defaultValue={check.expected}
|
||||
forceUpdateKey={`${updateKey}:${check.target}`}
|
||||
placeholder={check.expectedType === "boolean" ? "true or false" : "Value"}
|
||||
onChange={(expected) => update(check.id, { expected })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{check.target === "json" &&
|
||||
!["greater_than", "less_than", "contains"].includes(check.operator) && (
|
||||
<div className="w-24 shrink-0">
|
||||
<Select
|
||||
name={`type-${check.id}`}
|
||||
label="Type"
|
||||
size="sm"
|
||||
value={check.expectedType}
|
||||
options={[
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "number", label: "Number" },
|
||||
{ value: "boolean", label: "Boolean" },
|
||||
{ value: "null", label: "Null" },
|
||||
]}
|
||||
onChange={(expectedType) => update(check.id, { expectedType })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{validation.data?.[check.id] && (
|
||||
<p className="text-xs text-danger" role="status">
|
||||
{validation.data[check.id]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{validation.data?.[""] && (
|
||||
<p className="text-sm text-danger" role="status">
|
||||
{validation.data[""]}
|
||||
</p>
|
||||
)}
|
||||
{validation.isError && (
|
||||
<p className="text-sm text-danger" role="status">
|
||||
Unable to validate assertions.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
disabled={definition.version !== 1 || definition.checks.length >= 100}
|
||||
onClick={() =>
|
||||
save([
|
||||
...definition.checks,
|
||||
{
|
||||
id: generateId(),
|
||||
enabled: true,
|
||||
target: "json",
|
||||
selector: "",
|
||||
operator: "equals",
|
||||
expected: "",
|
||||
expectedType: "text",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Add assertion
|
||||
</Button>
|
||||
{definition.checks.some((c) => c.target === "json") && (
|
||||
<p className="text-xs text-text-subtle">
|
||||
Use a JSONPath such as $.user.id or user.id. Comparisons select one value; Exists also
|
||||
accepts multiple matches. Suggestions use the selected response, one level at a time.
|
||||
Values are literal and types must match.
|
||||
</p>
|
||||
)}
|
||||
<HttpAssertionPreview assertions={definition} response={activeResponse} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,12 +42,11 @@ import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { FormMultipartEditor } from "./FormMultipartEditor";
|
||||
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAssertionsEditor } from "./HttpAssertionsEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { JsonBodyEditor } from "./JsonBodyEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
@@ -73,7 +72,6 @@ const TAB_HEADERS = "headers";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TAB_ASSERTIONS = "assertions";
|
||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
||||
|
||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||
@@ -173,7 +171,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
() => [
|
||||
{
|
||||
value: TAB_BODY,
|
||||
menuLabel: "Body",
|
||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||
options: {
|
||||
value: activeRequest.bodyType,
|
||||
@@ -267,16 +264,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
{
|
||||
value: TAB_ASSERTIONS,
|
||||
label: "Assertions",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={activeRequest.assertions?.checks.filter((c) => c.enabled).length ?? 0}
|
||||
/>
|
||||
),
|
||||
hiddenByDefault: !activeRequest.assertions?.checks.length,
|
||||
},
|
||||
],
|
||||
[
|
||||
activeRequest,
|
||||
@@ -323,7 +310,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
e.preventDefault(); // Prevent input onChange
|
||||
|
||||
await patchModel(activeRequest, patch);
|
||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
||||
await setActiveTab({
|
||||
storageKey: TABS_STORAGE_KEY,
|
||||
activeTabKey: activeRequestId,
|
||||
value: TAB_PARAMS,
|
||||
});
|
||||
|
||||
// Wait for request to update, then refresh the UI
|
||||
// TODO: Somehow make this deterministic
|
||||
@@ -377,12 +368,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 -mb-1.5"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
visibilityStorageKey={[TABS_STORAGE_KEY, "visibility", activeRequest.workspaceId]}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_ASSERTIONS}>
|
||||
<HttpAssertionsEditor key={activeRequest.id} request={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ComponentType, CSSProperties } from "react";
|
||||
import { lazy, Suspense, useMemo, useRef } from "react";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
@@ -23,12 +23,11 @@ import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { Tooltip } from "./core/Tooltip";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { HttpAssertionResults } from "./HttpAssertionResults";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
@@ -57,7 +56,6 @@ const TAB_BODY = "body";
|
||||
const TAB_REQUEST = "request";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_COOKIES = "cookies";
|
||||
const TAB_ASSERTIONS = "assertions";
|
||||
const TAB_TIMELINE = "timeline";
|
||||
|
||||
export type TimelineViewMode = "timeline" | "text";
|
||||
@@ -69,7 +67,6 @@ interface RedirectDropWarning {
|
||||
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
const tabsRef = useRef<TabsRef>(null);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||
@@ -86,53 +83,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||
const saveResponse = useSaveResponse(activeResponse ?? null);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
|
||||
const assertionReport = activeResponse?.assertionResults;
|
||||
// Native sends save the definition before evaluation. Wait for actual outcomes,
|
||||
// and leave the tab hidden when every check was disabled.
|
||||
const hasAssertionResults =
|
||||
activeResponse?.state === "closed" &&
|
||||
assertionReport != null &&
|
||||
(assertionReport.error != null ||
|
||||
assertionReport.results.some((result) => result.outcome !== "skipped"));
|
||||
const failedAssertions =
|
||||
assertionReport?.results.filter((result) => result.outcome === "failed").length ?? 0;
|
||||
const assertionErrors =
|
||||
assertionReport?.results.filter(
|
||||
(result) => result.outcome === "error" || result.outcome === "invalid",
|
||||
).length ?? 0;
|
||||
const assertionFailures = failedAssertions + assertionErrors;
|
||||
const assertionFailureLabel = [
|
||||
failedAssertions > 0
|
||||
? `${failedAssertions} assertion${failedAssertions === 1 ? "" : "s"} failed`
|
||||
: null,
|
||||
assertionErrors > 0
|
||||
? `${assertionErrors} assertion error${assertionErrors === 1 ? "" : "s"}`
|
||||
: assertionReport?.error != null
|
||||
? "Assertion error"
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const assertionPasses =
|
||||
assertionReport?.results.filter((result) => result.outcome === "passed").length ?? 0;
|
||||
const assertionsHaveErrors = assertionFailures > 0 || assertionReport?.error != null;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
...(hasAssertionResults
|
||||
? [
|
||||
{
|
||||
value: TAB_ASSERTIONS,
|
||||
label: "Assertions",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={assertionsHaveErrors ? assertionFailures || true : assertionPasses}
|
||||
color={assertionsHaveErrors ? "danger" : "success"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: TAB_BODY,
|
||||
label: "Response",
|
||||
@@ -209,10 +162,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
activeResponse?.requestHeaders.length,
|
||||
activeResponse?.state,
|
||||
activeResponse?.status,
|
||||
hasAssertionResults,
|
||||
assertionFailures,
|
||||
assertionPasses,
|
||||
assertionsHaveErrors,
|
||||
cookieCounts.sent,
|
||||
cookieCounts.received,
|
||||
copyResponse.mutate,
|
||||
@@ -261,17 +210,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={activeResponse} />
|
||||
{hasAssertionResults && assertionsHaveErrors && (
|
||||
<PillButton
|
||||
color="danger"
|
||||
className="font-sans"
|
||||
title="View assertion results"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" />}
|
||||
onClick={() => tabsRef.current?.setActiveTab(TAB_ASSERTIONS)}
|
||||
>
|
||||
{assertionFailureLabel}
|
||||
</PillButton>
|
||||
)}
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</span>
|
||||
@@ -344,18 +282,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
)}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
tabs={tabs}
|
||||
defaultValue={TAB_BODY}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey="http_response_tabs"
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_ASSERTIONS}>
|
||||
<HttpAssertionResults response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ErrorBoundary name="Http Response Viewer">
|
||||
<Suspense>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { Input } from "./core/Input";
|
||||
import type { InputProps } from "./core/Input";
|
||||
import { jsonPathCompletion } from "./core/Editor/json/pathCompletion";
|
||||
import type { JsonPathChildren, LoadJsonPathChildren } from "./core/Editor/json/pathCompletion";
|
||||
|
||||
type Props = Pick<InputProps, "defaultValue" | "onChange" | "forceUpdateKey" | "stateKey"> & {
|
||||
response: HttpResponse | null;
|
||||
};
|
||||
|
||||
export function JsonPathInput({ response, ...props }: Props) {
|
||||
const client = useQueryClient();
|
||||
const [sampled, setSampled] = useState(false);
|
||||
const responseKey = response?.state === "closed" ? `${response.id}:${response.updatedAt}` : null;
|
||||
const currentResponseKey = useRef(responseKey);
|
||||
currentResponseKey.current = responseKey;
|
||||
const load = useRef<LoadJsonPathChildren>(async () => null);
|
||||
load.current = async (parent) => {
|
||||
if (!response || !responseKey) return null;
|
||||
try {
|
||||
const result = await client.fetchQuery({
|
||||
queryKey: ["json_path_children", response.id, response.updatedAt, parent],
|
||||
queryFn: () =>
|
||||
rpc<JsonPathChildren>("cmd_http_response_json_children", {
|
||||
responseId: response.id,
|
||||
parent,
|
||||
}),
|
||||
staleTime: Infinity,
|
||||
gcTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
if (responseKey !== currentResponseKey.current) return null;
|
||||
setSampled(result.truncated);
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
} // Non-JSON bodies and incomplete expressions still allow normal typing.
|
||||
};
|
||||
// The editor installs extraExtensions once. A ref keeps the response current without rebuilding
|
||||
// the input and losing focus/selection when a new response arrives.
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
autocompletion({ override: [jsonPathCompletion((parent) => load.current(parent))] }),
|
||||
EditorView.domEventHandlers({
|
||||
focus: (_event, view) => {
|
||||
startCompletion(view);
|
||||
},
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
{...props}
|
||||
label="JSONPath"
|
||||
size="sm"
|
||||
placeholder="$.user.id"
|
||||
language="text"
|
||||
autocompleteFunctions={false}
|
||||
autocompleteVariables={false}
|
||||
extraExtensions={extensions}
|
||||
/>
|
||||
{sampled && (
|
||||
<p className="text-xs text-text-subtle mt-1">
|
||||
Suggestions are sampled. You can enter any path.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseBulkPairLine } from "./BulkPairEditor";
|
||||
import {
|
||||
formatBulkPairLine,
|
||||
formatBulkPairs,
|
||||
parseBulkPairLine,
|
||||
parseBulkPairs,
|
||||
} from "./BulkPairEditor";
|
||||
|
||||
describe("parseBulkPairLine", () => {
|
||||
test("parses colon-space pairs as name and value", () => {
|
||||
@@ -33,4 +38,130 @@ describe("parseBulkPairLine", () => {
|
||||
value: "bar\nbaz",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses commented-out pairs as disabled", () => {
|
||||
expect(parseBulkPairLine("# foo: bar")).toMatchObject({
|
||||
enabled: false,
|
||||
name: "foo",
|
||||
value: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("allows any whitespace after the comment marker", () => {
|
||||
expect(parseBulkPairLine("# foo: bar")).toMatchObject({
|
||||
enabled: false,
|
||||
name: "foo",
|
||||
value: "bar",
|
||||
});
|
||||
expect(parseBulkPairLine("#\tfoo: bar")).toMatchObject({
|
||||
enabled: false,
|
||||
name: "foo",
|
||||
value: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("treats a # without whitespace after it as part of the name", () => {
|
||||
expect(parseBulkPairLine("#foo: bar")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "#foo",
|
||||
value: "bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses commented-out pairs with an empty value as disabled", () => {
|
||||
expect(parseBulkPairLine("# token:")).toMatchObject({
|
||||
enabled: false,
|
||||
name: "token",
|
||||
value: "",
|
||||
});
|
||||
expect(parseBulkPairLine("# token: ")).toMatchObject({
|
||||
enabled: false,
|
||||
name: "token",
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("drops comments that are not pairs", () => {
|
||||
expect(parseBulkPairLine("# just a comment")).toBeNull();
|
||||
expect(parseBulkPairLine("# see http://example.com")).toBeNull();
|
||||
expect(parseBulkPairLine("#")).toBeNull();
|
||||
});
|
||||
|
||||
test("leaves values that start with # alone", () => {
|
||||
expect(parseBulkPairLine("color: #fff")).toMatchObject({
|
||||
enabled: true,
|
||||
name: "color",
|
||||
value: "#fff",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBulkPairLine", () => {
|
||||
test("formats enabled pairs as name and value", () => {
|
||||
expect(formatBulkPairLine({ enabled: true, name: "foo", value: "bar" })).toBe("foo: bar");
|
||||
});
|
||||
|
||||
test("treats pairs without an enabled flag as enabled", () => {
|
||||
expect(formatBulkPairLine({ name: "foo", value: "bar" })).toBe("foo: bar");
|
||||
});
|
||||
|
||||
test("comments out disabled pairs", () => {
|
||||
expect(formatBulkPairLine({ enabled: false, name: "foo", value: "bar" })).toBe("# foo: bar");
|
||||
});
|
||||
|
||||
test("escapes newlines in values", () => {
|
||||
expect(formatBulkPairLine({ enabled: false, name: "foo", value: "a\nb" })).toBe("# foo: a\\nb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk pair round trip", () => {
|
||||
test("preserves order and enabled state", () => {
|
||||
const pairs = [
|
||||
{ enabled: true, name: "host", value: "example.com" },
|
||||
{ enabled: false, name: "token", value: "old" },
|
||||
{ enabled: true, name: "color", value: "#fff" },
|
||||
{ enabled: false, name: "multi", value: "a\nb" },
|
||||
{ enabled: true, name: "token", value: "new" },
|
||||
];
|
||||
|
||||
const text = formatBulkPairs(pairs);
|
||||
expect(text).toBe(
|
||||
["host: example.com", "# token: old", "color: #fff", "# multi: a\\nb", "token: new"].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
|
||||
const parsed = parseBulkPairs(text);
|
||||
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
|
||||
});
|
||||
|
||||
test("preserves enabled pairs whose name starts with #", () => {
|
||||
const pairs = [{ enabled: true, name: "#foo", value: "bar" }];
|
||||
const text = formatBulkPairs(pairs);
|
||||
expect(text).toBe("#foo: bar");
|
||||
const parsed = parseBulkPairs(text);
|
||||
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
|
||||
});
|
||||
|
||||
test("preserves disabled pairs whose name starts with #", () => {
|
||||
const pairs = [{ enabled: false, name: "#foo", value: "bar" }];
|
||||
const text = formatBulkPairs(pairs);
|
||||
expect(text).toBe("# #foo: bar");
|
||||
const parsed = parseBulkPairs(text);
|
||||
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
|
||||
});
|
||||
|
||||
test("preserves disabled pairs with empty values", () => {
|
||||
const pairs = [{ enabled: false, name: "token", value: "" }];
|
||||
const parsed = parseBulkPairs(formatBulkPairs(pairs));
|
||||
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
|
||||
});
|
||||
|
||||
test("drops free-text comments and blank lines between pairs", () => {
|
||||
const parsed = parseBulkPairs("# Auth\nfoo: bar\n\n# baz: qux\n# see the docs");
|
||||
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual([
|
||||
{ enabled: true, name: "foo", value: "bar" },
|
||||
{ enabled: false, name: "baz", value: "qux" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -14,20 +14,11 @@ export function BulkPairEditor({
|
||||
forcedEnvironmentId,
|
||||
stateKey,
|
||||
}: Props) {
|
||||
const pairsText = useMemo(() => {
|
||||
return pairs
|
||||
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
|
||||
.map(formatBulkPairLine)
|
||||
.join("\n");
|
||||
}, [pairs]);
|
||||
const pairsText = useMemo(() => formatBulkPairs(pairs), [pairs]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(text: string) => {
|
||||
const pairs = text
|
||||
.split("\n")
|
||||
.filter((l: string) => l.trim())
|
||||
.map(parseBulkPairLine);
|
||||
onChange(pairs);
|
||||
onChange(parseBulkPairs(text));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
@@ -47,13 +38,57 @@ export function BulkPairEditor({
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBulkPairLine(pair: Pair) {
|
||||
const value = pair.value.replaceAll("\n", "\\n");
|
||||
return `${pair.name}: ${value}`;
|
||||
export function formatBulkPairs(pairs: Pair[]): string {
|
||||
return pairs
|
||||
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
|
||||
.map(formatBulkPairLine)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function parseBulkPairLine(line: string): PairWithId {
|
||||
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
|
||||
export function parseBulkPairs(text: string): PairWithId[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((l: string) => l.trim())
|
||||
.map(parseBulkPairLine)
|
||||
.filter((p) => p != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a pair as a `name: value` line. Disabled pairs are commented out dotenv-style, as
|
||||
* `# name: value`, so the enabled state survives a round trip through {@link parseBulkPairLine}.
|
||||
*/
|
||||
export function formatBulkPairLine(pair: Pair) {
|
||||
const value = pair.value.replaceAll("\n", "\\n");
|
||||
const line = `${pair.name}: ${value}`;
|
||||
return pair.enabled === false ? `# ${line}` : line;
|
||||
}
|
||||
|
||||
const PAIR_REGEX = /^([^:]+):\s+(.*)$/;
|
||||
// Commented-out pairs may also end at the colon, so a hand-typed `# name:` isn't dropped
|
||||
const COMMENTED_PAIR_REGEX = /^([^:]+):(?:\s+(.*))?$/;
|
||||
// A # only marks a comment when followed by whitespace (or nothing), so `#foo: bar` stays an
|
||||
// enabled pair named `#foo`
|
||||
const COMMENT_PREFIX_REGEX = /^\s*#(?:\s+|$)/;
|
||||
|
||||
/**
|
||||
* Parse a `name: value` line into an enabled pair. A line starting with `# ` is a disabled pair
|
||||
* if the rest of it parses as `name: value`, otherwise it's a free-text comment and `null` is
|
||||
* returned so it can be dropped.
|
||||
*/
|
||||
export function parseBulkPairLine(line: string): PairWithId | null {
|
||||
const commentPrefix = line.match(COMMENT_PREFIX_REGEX);
|
||||
if (commentPrefix != null) {
|
||||
const uncommented = line.slice(commentPrefix[0].length);
|
||||
const match = uncommented.match(COMMENTED_PAIR_REGEX);
|
||||
if (match == null) return null;
|
||||
return { ...pairFromMatch(uncommented, match), enabled: false };
|
||||
}
|
||||
|
||||
return pairFromMatch(line, line.match(PAIR_REGEX));
|
||||
}
|
||||
|
||||
function pairFromMatch(line: string, match: RegExpMatchArray | null): PairWithId {
|
||||
const [, name, value] = match ?? [];
|
||||
return {
|
||||
enabled: true,
|
||||
name: (name ?? line).trim(),
|
||||
|
||||
@@ -442,7 +442,21 @@ function EditorInner({
|
||||
? EditorState.fromJSON(cachedJsonState, config, stateFields)
|
||||
: EditorState.create(config);
|
||||
|
||||
const view = new EditorView({ state, parent: container });
|
||||
// PERF: Built detached and attached in a microtask, rather than passing `parent` and
|
||||
// letting the constructor attach it first.
|
||||
//
|
||||
// The constructor sets `contenteditable` on the content element and then reads
|
||||
// `document.fonts.ready`. Both force the browser to flush style and layout when the
|
||||
// element is already in the document, and whatever the previous editor attached has
|
||||
// just dirtied it again — so mounting n editors in one commit costs n forced layouts.
|
||||
// The environment editor mounts two per variable row (~60 of them), which is where its
|
||||
// half-second open went.
|
||||
//
|
||||
// Building detached moves that work off the live document, and deferring the attach to
|
||||
// a microtask means every editor in the commit is built before any of them is inserted:
|
||||
// one forced layout for the batch instead of one each. The microtask still runs before
|
||||
// paint, so nothing is visible in the meantime.
|
||||
const view = new EditorView({ state });
|
||||
|
||||
// For large documents, the parser may parse the max number of lines and fail to add
|
||||
// things like fold markers because of it.
|
||||
@@ -450,12 +464,26 @@ function EditorInner({
|
||||
forceParsing(view, 9e6, 100);
|
||||
|
||||
cm.current = { view, languageCompartment };
|
||||
if (autoFocus) {
|
||||
view.focus();
|
||||
}
|
||||
if (autoSelect) {
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
// The editor was torn down (or replaced) before we got here
|
||||
if (cm.current?.view !== view) return;
|
||||
|
||||
// Runs after the surrounding try/catch has returned, so it needs its own
|
||||
try {
|
||||
container.appendChild(view.dom);
|
||||
// Everything the constructor measured, it measured detached
|
||||
view.requestMeasure();
|
||||
|
||||
if (autoFocus) {
|
||||
view.focus();
|
||||
}
|
||||
if (autoSelect) {
|
||||
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to attach Codemirror", e);
|
||||
}
|
||||
});
|
||||
setRef?.(view);
|
||||
} catch (e) {
|
||||
console.log("Failed to initialize Codemirror", e);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { jsonParseLinter } from "./json-lint";
|
||||
|
||||
// The linter only reads the document text off the view
|
||||
function view(doc: string): EditorView {
|
||||
return { state: { doc: { toString: () => doc } } } as EditorView;
|
||||
}
|
||||
|
||||
const lint = jsonParseLinter();
|
||||
|
||||
describe("jsonParseLinter", () => {
|
||||
test("reports nothing for an empty document", () => {
|
||||
expect(lint(view(" "))).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports nothing for valid JSON", () => {
|
||||
expect(lint(view('{"a": 1}'))).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports a message for invalid JSON", () => {
|
||||
expect(lint(view('{"a": }'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("ignores a template tag standing in for a value", () => {
|
||||
expect(lint(view('{"a": "${[ my_var ]}"}'))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores a tag whose quoted argument contains `]}`", () => {
|
||||
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}"}`))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores a tag whose quoted argument contains an escaped quote", () => {
|
||||
expect(lint(view(`{"a": "\${[ fn(arg='it\\'s ]}') ]}"}`))).toEqual([]);
|
||||
});
|
||||
|
||||
test("still reports errors around a tag with a quoted `]}`", () => {
|
||||
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}",}`))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("keeps the tag's length so error offsets stay correct", () => {
|
||||
const doc = `{"a" "\${[ fn(arg='x]}y') ]}"}`;
|
||||
const [error] = lint(view(doc));
|
||||
expect(error?.from).toEqual(doc.indexOf('"${['));
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||
|
||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
||||
import { replaceTemplateTags } from "../../../lib/templateTags";
|
||||
|
||||
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
||||
const MESSAGES: Record<string, string> = {
|
||||
@@ -36,7 +35,7 @@ export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
const escapedDoc = replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start));
|
||||
|
||||
const errors: ParseError[] = [];
|
||||
parse(escapedDoc, errors, {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, it, vi } from "vite-plus/test";
|
||||
import { jsonPathCompletion, jsonPathCompletionTarget } from "./pathCompletion";
|
||||
import type { JsonPathChildren } from "./pathCompletion";
|
||||
|
||||
const root: JsonPathChildren = {
|
||||
children: [
|
||||
{ label: "users", selector: ".users", kind: "array" },
|
||||
{ label: "total", selector: ".total", kind: "number" },
|
||||
{ label: "a.b", selector: '["a.b"]', kind: "text" },
|
||||
{ label: "a,b", selector: '["a,b"]', kind: "text" },
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
function context(text: string, explicit = false, pos = text.length) {
|
||||
return new CompletionContext(EditorState.create({ doc: text }), pos, explicit);
|
||||
}
|
||||
|
||||
describe("progressive JSONPath completion", () => {
|
||||
it.each([
|
||||
["", "$", ""],
|
||||
["$", "$", ""],
|
||||
["$.", "$", "."],
|
||||
["us", "$", "us"],
|
||||
["users.na", "$.users", ".na"],
|
||||
["$.users[", "$.users", "["],
|
||||
["$.users[0].na", "$.users[0]", ".na"],
|
||||
["$.users[*].", "$.users[*]", "."],
|
||||
['$["a.b"].', '$["a.b"]', "."],
|
||||
['$["a.b', "$", '["a.b'],
|
||||
["$['a.b", "$", "['a.b"],
|
||||
['$["a,b', "$", '["a,b'],
|
||||
['$.users[?(@.name == "a.b")].', '$.users[?(@.name == "a.b")]', "."],
|
||||
])("finds the next level in %s", (text, parent, fragment) => {
|
||||
expect(jsonPathCompletionTarget(text)).toEqual({ parent, fragment });
|
||||
});
|
||||
|
||||
it("loads only the parent, filters the typed prefix, and inserts a complete escaped path", async () => {
|
||||
const load = vi.fn(async () => root);
|
||||
const complete = jsonPathCompletion(load);
|
||||
const result = await complete(context("us"));
|
||||
expect(load).toHaveBeenCalledExactlyOnceWith("$");
|
||||
expect(result?.options.map((o) => o.label)).toEqual(["users"]);
|
||||
const quoted = await complete(context('$["a.b'));
|
||||
const option = quoted!.options[0]!;
|
||||
const dispatch = vi.fn();
|
||||
expect(option.label).toBe("a.b");
|
||||
if (typeof option.apply !== "function") throw new Error("Expected a completion function");
|
||||
option.apply({ dispatch } as unknown as EditorView, option, 0, 6);
|
||||
expect(dispatch.mock.calls[0]?.[0].changes).toEqual({ from: 0, to: 6, insert: '$["a.b"]' });
|
||||
expect((await complete(context("$['a.b")))?.options.map((o) => o.label)).toEqual(["a.b"]);
|
||||
});
|
||||
|
||||
it("explores a completed container without turning the path into an invalid draft", async () => {
|
||||
const array: JsonPathChildren = {
|
||||
children: [
|
||||
{ label: "[*]", selector: "[*]", kind: "all items" },
|
||||
{ label: "[0]", selector: "[0]", kind: "object" },
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
const load = vi.fn(async (parent: string) => (parent === "$.users" ? array : root));
|
||||
const result = await jsonPathCompletion(load)(context("$.users", true));
|
||||
expect(load).toHaveBeenCalledExactlyOnceWith("$.users");
|
||||
expect(result?.options.map((o) => o.label)).toEqual(["[*]", "[0]"]);
|
||||
expect(
|
||||
(await jsonPathCompletion(load)(context("$.users[0")))?.options.map((o) => o.label),
|
||||
).toEqual(["[0]"]);
|
||||
});
|
||||
|
||||
it("does not suggest descendants at the root or duplicate the current scalar path", async () => {
|
||||
const load = vi.fn(async (parent: string) =>
|
||||
parent === "$" ? root : { children: [], truncated: false },
|
||||
);
|
||||
const result = await jsonPathCompletion(load)(context(""));
|
||||
expect(result?.options.map((o) => o.label)).toEqual(["users", "total", "a.b", "a,b"]);
|
||||
expect(await jsonPathCompletion(load)(context("$.total", true))).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves complex expressions, mid-path editing, unavailable data, and stale results alone", async () => {
|
||||
const load = vi.fn(async () => root);
|
||||
expect(await jsonPathCompletion(load)(context("$.users[?(@.na"))).toBeNull();
|
||||
expect(await jsonPathCompletion(load)(context("$..na"))).toBeNull();
|
||||
expect(await jsonPathCompletion(load)(context("$.users[0].name", false, 5))).toBeNull();
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
expect(await jsonPathCompletion(async () => null)(context("$"))).toBeNull();
|
||||
const stale = context("$.");
|
||||
Object.defineProperty(stale, "aborted", { get: () => true });
|
||||
expect(await jsonPathCompletion(load)(stale)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||
import { startCompletion } from "@codemirror/autocomplete";
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
|
||||
export type JsonPathChildren = RpcSchema["cmd_http_response_json_children"][1];
|
||||
export type LoadJsonPathChildren = (parent: string) => Promise<JsonPathChildren | null>;
|
||||
|
||||
export function normalizeJsonPath(value: string): string {
|
||||
const text = value.trim();
|
||||
if (!text) return "$";
|
||||
return text.startsWith("$") ? text : text.startsWith("[") ? `$${text}` : `$.${text}`;
|
||||
}
|
||||
|
||||
/** Locate the unfinished child accessor, leaving complete JSONPath expressions to Rust. */
|
||||
export function jsonPathCompletionTarget(
|
||||
text: string,
|
||||
): { parent: string; fragment: string } | null {
|
||||
text = text.trim();
|
||||
if (text.length > 1024) return null;
|
||||
if (!text || text === "$") return { parent: "$", fragment: "" };
|
||||
let quote = "";
|
||||
let escaped = false;
|
||||
let depth = 0;
|
||||
let boundary = -1;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (c === "\\") escaped = true;
|
||||
else if (c === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (depth > 0 && (c === '"' || c === "'")) quote = c;
|
||||
else if (c === "[") {
|
||||
if (depth === 0) boundary = i;
|
||||
depth++;
|
||||
} else if (c === "]") {
|
||||
if (--depth < 0) return null;
|
||||
} else if (depth === 0 && c === ".") boundary = i;
|
||||
}
|
||||
if (depth === 0 && text.endsWith("]")) return { parent: normalizeJsonPath(text), fragment: "" };
|
||||
if (boundary === -1) return text.startsWith("$") ? null : { parent: "$", fragment: text };
|
||||
const fragment = text.slice(boundary);
|
||||
// Don't try to complete inside filters, unions, or recursive descent.
|
||||
if (
|
||||
depth > 1 ||
|
||||
fragment.startsWith("[?") ||
|
||||
fragment.startsWith("[(") ||
|
||||
text[boundary - 1] === "." ||
|
||||
fragment.startsWith("[...") ||
|
||||
(fragment.includes(",") && !fragment.startsWith('["') && !fragment.startsWith("['"))
|
||||
)
|
||||
return null;
|
||||
return { parent: normalizeJsonPath(text.slice(0, boundary)), fragment };
|
||||
}
|
||||
|
||||
function matches(child: JsonPathChildren["children"][number], fragment: string): boolean {
|
||||
if (!fragment) return true;
|
||||
if (!fragment.startsWith("[")) return child.label.startsWith(fragment.replace(/^\./, ""));
|
||||
if (child.selector.startsWith("[") && !child.selector.startsWith('["'))
|
||||
return child.selector.startsWith(fragment);
|
||||
const quoted = JSON.stringify(child.label);
|
||||
const accessor = fragment.startsWith("['")
|
||||
? `['${quoted.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}']`
|
||||
: `[${quoted}]`;
|
||||
return accessor.startsWith(fragment);
|
||||
}
|
||||
|
||||
/** Complete a path progressively. Discovery is cached by response + parent in the caller. */
|
||||
export function jsonPathCompletion(load: LoadJsonPathChildren) {
|
||||
return async (context: CompletionContext): Promise<CompletionResult | null> => {
|
||||
// Never replace later path segments while the user edits the middle of an expression.
|
||||
if (context.pos !== context.state.doc.length) return null;
|
||||
context.addEventListener("abort", () => {}, { onDocChange: true });
|
||||
const text = context.state.doc.toString();
|
||||
let target = jsonPathCompletionTarget(text);
|
||||
if (!target) return null;
|
||||
let children: JsonPathChildren | null = null;
|
||||
if (context.explicit && text.trim()) {
|
||||
// Accepting a container or explicitly opening completion explores its children, without
|
||||
// inserting an unfinished dot/bracket (the container itself may be the assertion target).
|
||||
const parent = normalizeJsonPath(text);
|
||||
children = await load(parent);
|
||||
if (context.aborted) return null;
|
||||
if (children?.children.length) target = { parent, fragment: "" };
|
||||
else children = null;
|
||||
}
|
||||
children ??= await load(target.parent);
|
||||
if (context.aborted || !children) return null;
|
||||
const parent = target.parent;
|
||||
const options = children.children
|
||||
.filter((child) => matches(child, target.fragment))
|
||||
.filter((child) => `${parent}${child.selector}` !== normalizeJsonPath(text))
|
||||
.map((child) => {
|
||||
const path = `${parent}${child.selector}`;
|
||||
return {
|
||||
label: child.label,
|
||||
displayLabel: child.selector.startsWith('["') ? child.selector : child.label,
|
||||
detail: child.kind,
|
||||
type: child.selector.startsWith("[") ? "variable" : "property",
|
||||
apply(
|
||||
view: Parameters<typeof startCompletion>[0],
|
||||
_completion: unknown,
|
||||
from: number,
|
||||
to: number,
|
||||
) {
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: path },
|
||||
selection: { anchor: from + path.length },
|
||||
userEvent: "input.complete",
|
||||
});
|
||||
if (["object", "array", "all items", "mixed"].includes(child.kind))
|
||||
startCompletion(view);
|
||||
},
|
||||
};
|
||||
});
|
||||
return options.length ? { from: 0, to: context.pos, options, filter: false } : null;
|
||||
};
|
||||
}
|
||||
@@ -4,4 +4,6 @@ export const highlight = styleTags({
|
||||
TagOpen: t.bracket,
|
||||
TagClose: t.bracket,
|
||||
TagContent: t.keyword,
|
||||
// Strings are part of the tag body, so they get the same color as the rest of it
|
||||
TagString: t.keyword,
|
||||
});
|
||||
@@ -1,13 +1,17 @@
|
||||
@top Template { (Tag | Text)* }
|
||||
|
||||
// `TagString` keeps a quoted argument from closing the tag, so `${[ fn(a='x]}y') ]}` stays a
|
||||
// single Tag. Only a terminated string matches, so a half-typed `'` degrades to TagContent and
|
||||
// the tag still closes at the next `]}`.
|
||||
@local tokens {
|
||||
TagClose { "]}" }
|
||||
TagString { "'" (![\\'] | "\\" _)* "'" }
|
||||
@else TagContent
|
||||
}
|
||||
|
||||
@skip { } {
|
||||
TagOpen { "${[" }
|
||||
Tag { TagOpen (TagContent)+ TagClose }
|
||||
Tag { TagOpen (TagContent | TagString)+ TagClose }
|
||||
}
|
||||
|
||||
@tokens {
|
||||
|
||||
@@ -3,5 +3,6 @@ export const Template = 1,
|
||||
Tag = 2,
|
||||
TagOpen = 3,
|
||||
TagContent = 4,
|
||||
TagClose = 5,
|
||||
Text = 6;
|
||||
TagString = 5,
|
||||
TagClose = 6,
|
||||
Text = 7;
|
||||
@@ -23,6 +23,10 @@ function hasError(input: string): boolean {
|
||||
return getNodeNames(input).includes("⚠");
|
||||
}
|
||||
|
||||
function countTags(input: string): number {
|
||||
return getNodeNames(input).filter((n) => n === "Tag").length;
|
||||
}
|
||||
|
||||
describe("twig grammar", () => {
|
||||
describe("${[var]} format (valid template tags)", () => {
|
||||
test("parses simple variable as Tag", () => {
|
||||
@@ -83,6 +87,50 @@ describe("twig grammar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoted strings inside tags", () => {
|
||||
test("keeps a quoted `]}` inside the tag", () => {
|
||||
const input = "${[ fn(a='x]}y') ]}";
|
||||
expect(countTags(input)).toBe(1);
|
||||
expect(hasError(input)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps an escaped quote inside the string", () => {
|
||||
const input = "${[ fn(a='it\\'s ]}') ]}";
|
||||
expect(countTags(input)).toBe(1);
|
||||
expect(hasError(input)).toBe(false);
|
||||
});
|
||||
|
||||
test("parses a b64 string as one Tag", () => {
|
||||
const input = "${[ b64'Zm9v' ]}";
|
||||
expect(countTags(input)).toBe(1);
|
||||
expect(hasError(input)).toBe(false);
|
||||
});
|
||||
|
||||
test("closes at the first `]}` when a quote is left unterminated", () => {
|
||||
// Half-typed strings must not swallow the rest of the document
|
||||
const input = "${[ fn(a='unterminated ]} after";
|
||||
expect(countTags(input)).toBe(1);
|
||||
expect(hasError(input)).toBe(false);
|
||||
const tag = parser.parse(input).topNode.getChild("Tag");
|
||||
expect(input.slice(tag?.from ?? 0, tag?.to ?? 0)).toBe("${[ fn(a='unterminated ]}");
|
||||
});
|
||||
|
||||
test("handles two tags with quoted `]}` in the same string", () => {
|
||||
const input = "${[ a(x='1]}') ]} and ${[ b(y='2]}') ]}";
|
||||
expect(countTags(input)).toBe(2);
|
||||
expect(hasError(input)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps JSON around a tag with a quoted `]}` intact", () => {
|
||||
const tag = "${[ fn(a='x]}y') ]}";
|
||||
const json = '{"a": "' + tag + '"}';
|
||||
expect(countTags(json)).toBe(1);
|
||||
expect(hasError(json)).toBe(false);
|
||||
const node = parser.parse(json).topNode.getChild("Tag");
|
||||
expect(json.slice(node?.from ?? 0, node?.to ?? 0)).toBe(tag);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("handles $ at end of string", () => {
|
||||
expect(hasError("hello$")).toBe(false);
|
||||
|
||||
@@ -4,17 +4,24 @@ import { highlight } from "./highlight";
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
|
||||
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
|
||||
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
|
||||
nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text",
|
||||
maxTerm: 10,
|
||||
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cd'#CdQQOPOOOOOO'#Ce'#CeObOQO,58xOOOO-E6b-E6bOOOO-E6c-E6cOOOO1G.d1G.d",
|
||||
stateData: "m~OVROZPO~OSTOTTO~OSTOTTOUXO~O",
|
||||
goto: "oYPPZ_PPPPciTROSTQOSQSORVSQUQRWU",
|
||||
nodeNames: "⚠ Template Tag TagOpen TagContent TagString TagClose Text",
|
||||
maxTerm: 11,
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 2,
|
||||
tokenData:
|
||||
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
|
||||
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
|
||||
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTV~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOV~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OZ~",
|
||||
tokenizers: [
|
||||
1,
|
||||
new LocalTokenGroup(
|
||||
"#R~RQwxX#P#Q!u~[VOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o<%lOX~vOT~~yRO;'SX;'S;=`!S;=`OX~!VWOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o;=`<%lX<%lOX~!rP;=`<%lX~!xP#q#r!{~#QOU~~",
|
||||
94,
|
||||
4,
|
||||
),
|
||||
],
|
||||
topRules: { Template: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, ReactNode, Ref } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
@@ -27,19 +27,11 @@ import { fireAndForget } from "../../../lib/fireAndForget";
|
||||
import { ErrorBoundary } from "../../ErrorBoundary";
|
||||
import type { ButtonProps } from "../Button";
|
||||
import { Button } from "../Button";
|
||||
import { Dropdown } from "../Dropdown";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import type { RadioDropdownProps } from "../RadioDropdown";
|
||||
import { RadioDropdown } from "../RadioDropdown";
|
||||
import { useFittingTabs } from "./useFittingTabs";
|
||||
import { MIN_TAB_GAP_SCALE, MIN_TAB_PADDING_SCALE } from "./fitTabs";
|
||||
|
||||
export type TabItem = {
|
||||
/** Stable label for the visibility menu, including tabs with dropdown labels. */
|
||||
menuLabel?: string;
|
||||
hiddenByDefault?: boolean;
|
||||
} & (
|
||||
export type TabItem =
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -52,8 +44,7 @@ export type TabItem = {
|
||||
options: Omit<RadioDropdownProps, "children">;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
interface TabsStorage {
|
||||
order: string[];
|
||||
@@ -81,8 +72,6 @@ interface Props {
|
||||
storageKey?: string | string[];
|
||||
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
|
||||
activeTabKey?: string;
|
||||
/** Enables show/hide controls, scoped independently of tab order and selection. */
|
||||
visibilityStorageKey?: string | string[];
|
||||
}
|
||||
|
||||
export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
@@ -98,7 +87,6 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
layout = "vertical",
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
visibilityStorageKey,
|
||||
}: Props,
|
||||
forwardedRef: Ref<TabsRef>,
|
||||
) {
|
||||
@@ -120,33 +108,10 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
|
||||
const savedOrder = storage.order;
|
||||
|
||||
const visibilityKey = visibilityStorageKey ?? ["tabs", "default_visibility"];
|
||||
const { value: visibility, set: setVisibility } = useKeyValue<Record<string, boolean>>({
|
||||
namespace: "no_sync",
|
||||
key: visibilityKey,
|
||||
fallback: {},
|
||||
});
|
||||
const { value: wrapPreference, set: setWrapPreference } = useKeyValue<boolean>({
|
||||
namespace: "no_sync",
|
||||
key: [...(Array.isArray(visibilityKey) ? visibilityKey : [visibilityKey]), "wrap"],
|
||||
fallback: false,
|
||||
});
|
||||
const isVisible = useCallback(
|
||||
(tab: TabItem) =>
|
||||
!("hidden" in tab && tab.hidden) &&
|
||||
(!visibilityStorageKey || (visibility?.[tab.value] ?? !tab.hiddenByDefault)),
|
||||
[visibility, visibilityStorageKey],
|
||||
);
|
||||
|
||||
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
|
||||
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
|
||||
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
|
||||
const requestedValue = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
||||
// Dynamic tabs can disappear when switching responses or other contexts.
|
||||
// Keep the saved preference, but display an available tab until it returns.
|
||||
const value = originalTabs.some((t) => t.value === requestedValue && isVisible(t))
|
||||
? requestedValue
|
||||
: originalTabs.find(isVisible)?.value;
|
||||
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
||||
|
||||
// Helper to normalize storage (handle migration from old format)
|
||||
const normalizeStorage = useCallback(
|
||||
@@ -158,9 +123,6 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
// Handle tab change - update internal state, storage if we have a key, and call prop callback
|
||||
const onChangeValue = useCallback(
|
||||
async (newValue: string) => {
|
||||
if (visibilityStorageKey) {
|
||||
await setVisibility((s) => ({ ...s, [newValue]: true }));
|
||||
}
|
||||
setInternalValue(newValue);
|
||||
if (storageKey && activeTabKey) {
|
||||
await setStorage((s) => {
|
||||
@@ -173,15 +135,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
}
|
||||
onChangeValueProp?.(newValue);
|
||||
},
|
||||
[
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
setStorage,
|
||||
onChangeValueProp,
|
||||
normalizeStorage,
|
||||
visibilityStorageKey,
|
||||
setVisibility,
|
||||
],
|
||||
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
|
||||
);
|
||||
|
||||
// Expose imperative methods via ref
|
||||
@@ -245,18 +199,6 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
}, [originalTabs, savedOrder, storageKey]);
|
||||
|
||||
const tabs = storageKey ? orderedTabs : originalTabs;
|
||||
const responsive = !!visibilityStorageKey && layout === "vertical";
|
||||
const wrapTabs = responsive && !!wrapPreference;
|
||||
const { viewportRef, measurementsRef, fittedTabs } = useFittingTabs(
|
||||
responsive && !wrapTabs,
|
||||
value,
|
||||
);
|
||||
const fitsInStrip = useCallback(
|
||||
(tab: TabItem) =>
|
||||
isVisible(tab) && (fittedTabs == null || fittedTabs.some((t) => t.value === tab.value)),
|
||||
[isVisible, fittedTabs],
|
||||
);
|
||||
const overflowTabs = tabs.filter((tab) => isVisible(tab) && !fitsInStrip(tab));
|
||||
|
||||
// Update tabs when value changes
|
||||
useEffect(() => {
|
||||
@@ -348,168 +290,69 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
const tabButtons = useMemo(() => {
|
||||
const items: ReactNode[] = [];
|
||||
tabs.forEach((t, i) => {
|
||||
if (!fitsInStrip(t)) {
|
||||
if ("hidden" in t && t.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = tabs[i + 1];
|
||||
const fitted = fittedTabs?.find((tab) => tab.value === t.value);
|
||||
const isActive = t.value === value;
|
||||
const showDropMarkerBefore = hoveredIndex === i;
|
||||
|
||||
if (showDropMarkerBefore) {
|
||||
items.push(
|
||||
<div
|
||||
key={`marker-${t.value}`}
|
||||
className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}
|
||||
>
|
||||
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
<TabButton
|
||||
key={t.value}
|
||||
tab={t}
|
||||
isActive={t.value === value}
|
||||
isActive={isActive}
|
||||
addBorders={addBorders}
|
||||
layout={layout}
|
||||
reorderable={reorderable}
|
||||
isDragging={isDragging?.value === t.value}
|
||||
onChangeValue={onChangeValue}
|
||||
width={fitted?.width}
|
||||
spacingCompression={fitted?.spacingCompression}
|
||||
wrap={wrapTabs}
|
||||
dropBefore={hoveredIndex === i}
|
||||
// A drop after the last fitted tab still inserts into the full saved order.
|
||||
dropAfter={hoveredIndex === i + 1 && (!next || !fitsInStrip(next))}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return items;
|
||||
}, [
|
||||
tabs,
|
||||
value,
|
||||
addBorders,
|
||||
layout,
|
||||
reorderable,
|
||||
isDragging,
|
||||
onChangeValue,
|
||||
hoveredIndex,
|
||||
fitsInStrip,
|
||||
fittedTabs,
|
||||
wrapTabs,
|
||||
]);
|
||||
}, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]);
|
||||
|
||||
const tabStrip = (
|
||||
const tabList = (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className={classNames(
|
||||
!visibilityStorageKey && tabListClassName,
|
||||
tabListClassName,
|
||||
addBorders && layout === "horizontal" && "pl-3 -ml-1",
|
||||
addBorders && layout === "vertical" && "ml-0 mb-2",
|
||||
"flex items-center hide-scrollbars",
|
||||
layout === "horizontal" && "h-full overflow-auto p-2",
|
||||
layout === "vertical" &&
|
||||
(responsive ? "relative overflow-hidden py-1" : "overflow-x-auto overflow-y-visible"),
|
||||
layout === "vertical" && "overflow-x-auto overflow-y-visible ",
|
||||
// Give space for button focus states within overflow boundary.
|
||||
!responsive && !addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
|
||||
!addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
layout === "horizontal" && "flex flex-col w-full pb-3 mb-auto",
|
||||
layout === "vertical" && "flex flex-row shrink-0 w-full",
|
||||
wrapTabs && "flex-wrap gap-y-1",
|
||||
)}
|
||||
>
|
||||
{tabButtons}
|
||||
</div>
|
||||
{responsive && !wrapTabs && (
|
||||
<div
|
||||
ref={measurementsRef}
|
||||
aria-hidden
|
||||
inert
|
||||
className="absolute invisible pointer-events-none flex w-max"
|
||||
>
|
||||
{tabs.filter(isVisible).map((tab) => (
|
||||
<div key={tab.value} data-measure-tab={tab.value} className="flex shrink-0">
|
||||
<TabButton
|
||||
tab={tab}
|
||||
isActive={tab.value === value}
|
||||
addBorders={addBorders}
|
||||
layout={layout}
|
||||
reorderable={false}
|
||||
isDragging={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Keep the menu outside the scroll viewport so tabs cannot paint behind or past it.
|
||||
const tabList = visibilityStorageKey ? (
|
||||
<div
|
||||
className={classNames(
|
||||
tabListClassName,
|
||||
"flex shrink-0 min-w-0 min-h-0",
|
||||
layout === "vertical" ? "items-start" : "flex-col",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 min-h-0 flex-1">{tabStrip}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"shrink-0 pl-1",
|
||||
layout === "vertical" && "flex items-center h-md my-1",
|
||||
{hoveredIndex === tabs.length && (
|
||||
<div className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}>
|
||||
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Dropdown
|
||||
items={[
|
||||
...(overflowTabs.length > 0
|
||||
? [
|
||||
{ type: "separator" as const, label: "More tabs" },
|
||||
...overflowTabs.map((tab) => ({
|
||||
label: tab.menuLabel ?? ("label" in tab ? tab.label : tab.value),
|
||||
icon: tab.value === value ? ("check" as const) : ("empty" as const),
|
||||
rightSlot: tab.rightSlot,
|
||||
onSelect: () => onChangeValue(tab.value),
|
||||
})),
|
||||
{ type: "separator" as const },
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Show tabs",
|
||||
submenu: [
|
||||
{ type: "separator", label: "In this workspace" },
|
||||
...tabs
|
||||
.filter((t) => !("hidden" in t && t.hidden))
|
||||
.map((tab) => ({
|
||||
label: tab.menuLabel ?? ("label" in tab ? tab.label : tab.value),
|
||||
icon: isVisible(tab)
|
||||
? ("check_square_checked" as const)
|
||||
: ("check_square_unchecked" as const),
|
||||
disabled: isVisible(tab) && tabs.filter(isVisible).length <= 1,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: async () => {
|
||||
if (!isVisible(tab)) {
|
||||
await onChangeValue(tab.value);
|
||||
} else {
|
||||
if (tab.value === value) {
|
||||
const next = tabs.find((t) => t.value !== tab.value && isVisible(t));
|
||||
if (next) await onChangeValue(next.value);
|
||||
}
|
||||
await setVisibility((s) => ({ ...s, [tab.value]: false }));
|
||||
}
|
||||
},
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Wrap tabs",
|
||||
hidden: layout !== "vertical",
|
||||
icon: wrapTabs ? "check_square_checked" : "check_square_unchecked",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: () => setWrapPreference((wrap) => !wrap),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<IconButton icon="ellipsis" title="More tabs and visibility" size="sm" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
tabStrip
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -518,13 +361,9 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
className={classNames(
|
||||
className,
|
||||
"tabs-container",
|
||||
"h-full",
|
||||
// Size wrapped rows at the resolved pane width instead of using an intrinsic grid track.
|
||||
responsive
|
||||
? "flex flex-col min-h-0 [&>.tab-content]:flex-1 [&>.tab-content]:min-h-0"
|
||||
: "grid",
|
||||
"h-full grid",
|
||||
layout === "horizontal" && "grid-rows-1 grid-cols-[auto_minmax(0,1fr)]",
|
||||
!responsive && layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
|
||||
layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{reorderable ? (
|
||||
@@ -570,11 +409,6 @@ interface TabButtonProps {
|
||||
isDragging: boolean;
|
||||
onChangeValue?: (value: string) => void;
|
||||
overlay?: boolean;
|
||||
width?: number;
|
||||
spacingCompression?: number;
|
||||
wrap?: boolean;
|
||||
dropBefore?: boolean;
|
||||
dropAfter?: boolean;
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
@@ -586,26 +420,35 @@ function TabButton({
|
||||
isDragging,
|
||||
onChangeValue,
|
||||
overlay = false,
|
||||
width,
|
||||
spacingCompression = 0,
|
||||
wrap = false,
|
||||
dropBefore = false,
|
||||
dropAfter = false,
|
||||
}: TabButtonProps) {
|
||||
const constrained = width !== undefined;
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
} = useDraggable({
|
||||
id: tab.value,
|
||||
disabled: !reorderable,
|
||||
// The button inside handles focus
|
||||
attributes: { tabIndex: -1 },
|
||||
});
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({
|
||||
id: tab.value,
|
||||
disabled: !reorderable,
|
||||
});
|
||||
|
||||
const handleSetWrapperRef = useCallback(
|
||||
(n: HTMLDivElement | null) => {
|
||||
if (reorderable) {
|
||||
setDraggableRef(n);
|
||||
setDroppableRef(n);
|
||||
}
|
||||
},
|
||||
[reorderable, setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
const btnProps: Partial<ButtonProps> = {
|
||||
color: "custom",
|
||||
justify: layout === "horizontal" ? "start" : "center",
|
||||
innerClassName: classNames(
|
||||
"tab-button-label",
|
||||
constrained && "w-auto! shrink-0 overflow-visible! text-clip!",
|
||||
wrap && "whitespace-normal! overflow-visible! text-clip! [overflow-wrap:anywhere]",
|
||||
),
|
||||
style: constrained
|
||||
? {
|
||||
paddingInline: `calc(var(--spacing) * ${2 * (1 - spacingCompression * (1 - MIN_TAB_PADDING_SCALE))})`,
|
||||
}
|
||||
: undefined,
|
||||
onClick: isActive
|
||||
? undefined
|
||||
: (e: React.MouseEvent) => {
|
||||
@@ -614,8 +457,7 @@ function TabButton({
|
||||
},
|
||||
className: classNames(
|
||||
"flex items-center rounded-sm whitespace-nowrap",
|
||||
"ml-px",
|
||||
!constrained && "px-2!",
|
||||
"px-2! ml-px",
|
||||
"outline-hidden",
|
||||
"ring-none",
|
||||
"focus-visible-or-class:outline-2",
|
||||
@@ -629,9 +471,6 @@ function TabButton({
|
||||
layout === "horizontal" && "min-w-40",
|
||||
isDragging && "opacity-50",
|
||||
overlay && "opacity-80",
|
||||
constrained && "w-[calc(100%_-_1px)]",
|
||||
wrap && "max-w-[calc(100%_-_1px)]! h-auto! min-h-md",
|
||||
(constrained || wrap) && "[&>div:not(.tab-button-label)]:shrink-0",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -648,9 +487,6 @@ function TabButton({
|
||||
onChange={tab.options.onChange}
|
||||
>
|
||||
<Button
|
||||
title={
|
||||
(constrained || wrap) && typeof option?.label === "string" ? option.label : undefined
|
||||
}
|
||||
leftSlot={tab.leftSlot}
|
||||
rightSlot={
|
||||
<div className="flex items-center">
|
||||
@@ -675,91 +511,22 @@ function TabButton({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
title={(constrained || wrap) && "label" in tab ? tab.label : undefined}
|
||||
leftSlot={tab.leftSlot}
|
||||
rightSlot={tab.rightSlot}
|
||||
{...btnProps}
|
||||
>
|
||||
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
|
||||
{"label" in tab && tab.label ? tab.label : tab.value}
|
||||
</Button>
|
||||
);
|
||||
})();
|
||||
|
||||
const wrapperClassName = classNames(
|
||||
"relative shrink-0",
|
||||
layout === "vertical" && (constrained ? "min-w-0" : "mr-2"),
|
||||
wrap && "min-w-0 max-w-[calc(100%_-_0.5rem)]",
|
||||
);
|
||||
const wrapperStyle: CSSProperties = {
|
||||
width,
|
||||
paddingRight: constrained
|
||||
? `calc(var(--spacing) * ${2 * (1 - spacingCompression * (1 - MIN_TAB_GAP_SCALE))})`
|
||||
: undefined,
|
||||
};
|
||||
// Anchor markers to their tabs so they follow the tab when it wraps onto another row.
|
||||
const content = (
|
||||
<>
|
||||
{dropBefore && (
|
||||
<DropMarker
|
||||
key="before"
|
||||
orientation={layout === "vertical" ? "vertical" : "horizontal"}
|
||||
className={layout === "vertical" ? "left-0" : "top-0"}
|
||||
/>
|
||||
)}
|
||||
{buttonContent}
|
||||
{dropAfter && (
|
||||
<DropMarker
|
||||
key="after"
|
||||
orientation={layout === "vertical" ? "vertical" : "horizontal"}
|
||||
className={layout === "vertical" ? "right-0" : "bottom-0"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return reorderable && !overlay ? (
|
||||
<DraggableTab value={tab.value} className={wrapperClassName} style={wrapperStyle}>
|
||||
{content}
|
||||
</DraggableTab>
|
||||
) : (
|
||||
<div className={wrapperClassName} style={wrapperStyle}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Measurement copies and drag overlays must not register duplicate draggable IDs.
|
||||
function DraggableTab({
|
||||
value,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
className: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
} = useDraggable({
|
||||
id: value,
|
||||
attributes: { tabIndex: -1 },
|
||||
});
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({ id: value });
|
||||
const setRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setDraggableRef(node);
|
||||
setDroppableRef(node);
|
||||
},
|
||||
[setDraggableRef, setDroppableRef],
|
||||
);
|
||||
// Apply drag handlers to wrapper, not button
|
||||
const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};
|
||||
|
||||
return (
|
||||
<div ref={setRef} className={className} style={style} {...attributes} {...listeners}>
|
||||
{children}
|
||||
<div
|
||||
ref={handleSetWrapperRef}
|
||||
className={classNames("relative", layout === "vertical" && "mr-2")}
|
||||
{...wrapperProps}
|
||||
>
|
||||
{buttonContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { fitTabs } from "./fitTabs";
|
||||
|
||||
const tabs = [
|
||||
{ value: "body", width: 80, compactWidth: 66 },
|
||||
{ value: "headers", width: 100, compactWidth: 86 },
|
||||
{ value: "info", width: 60, compactWidth: 46 },
|
||||
{ value: "assertions", width: 120, compactWidth: 106 },
|
||||
];
|
||||
|
||||
describe("responsive request tabs", () => {
|
||||
test("restores the saved order when the pane has room for every tab", () => {
|
||||
expect(fitTabs(tabs, 500, "assertions")).toEqual(
|
||||
tabs.map(({ value, width }) => ({ value, width, spacingCompression: 0 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("tightens gaps and padding without taking any space from labels", () => {
|
||||
expect(fitTabs(tabs, 332, "assertions")).toEqual([
|
||||
{ value: "body", width: 73, spacingCompression: 0.5 },
|
||||
{ value: "headers", width: 93, spacingCompression: 0.5 },
|
||||
{ value: "info", width: 53, spacingCompression: 0.5 },
|
||||
{ value: "assertions", width: 113, spacingCompression: 0.5 },
|
||||
]);
|
||||
expect(fitTabs(tabs, 304, "assertions")).toEqual(
|
||||
tabs.map(({ value, compactWidth }) => ({
|
||||
value,
|
||||
width: compactWidth,
|
||||
spacingCompression: 1,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
test("moves whole tabs into overflow when compact spacing is not enough", () => {
|
||||
expect(fitTabs(tabs, 258, "assertions")).toEqual([
|
||||
{ value: "body", width: 66, spacingCompression: 1 },
|
||||
{ value: "headers", width: 86, spacingCompression: 1 },
|
||||
{ value: "assertions", width: 106, spacingCompression: 1 },
|
||||
]);
|
||||
expect(fitTabs(tabs, 257, "assertions").map((tab) => tab.value)).toEqual([
|
||||
"body",
|
||||
"assertions",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps an active tab from the end visible without reordering it", () => {
|
||||
expect(fitTabs(tabs, 172, "assertions")).toEqual([
|
||||
{ value: "body", width: 66, spacingCompression: 1 },
|
||||
{ value: "assertions", width: 106, spacingCompression: 1 },
|
||||
]);
|
||||
expect(fitTabs(tabs, 170, "headers").map((tab) => tab.value)).toEqual(["body", "headers"]);
|
||||
});
|
||||
|
||||
test("puts even the active tab into overflow if it cannot fit on its own", () => {
|
||||
expect(fitTabs(tabs, 40, "assertions")).toEqual([]);
|
||||
expect(fitTabs(tabs, 0, "assertions")).toEqual([]);
|
||||
expect(fitTabs(tabs, 90, "assertions")).toEqual([
|
||||
{ value: "body", width: 80, spacingCompression: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("recalculates after a dropdown label or badge changes width", () => {
|
||||
const longLabel = [{ value: "body", width: 200, compactWidth: 186 }, ...tabs.slice(1)];
|
||||
const result = fitTabs(longLabel, 300, "body");
|
||||
expect(result.map((tab) => tab.value)).toEqual(["body", "headers"]);
|
||||
expect(result.reduce((sum, tab) => sum + tab.width, 0)).toBeCloseTo(300);
|
||||
|
||||
const largeBadge = [{ value: "body", width: 240, compactWidth: 226 }, ...tabs.slice(1)];
|
||||
expect(fitTabs(largeBadge, 280, "body").map((tab) => tab.value)).toEqual(["body"]);
|
||||
});
|
||||
|
||||
test("does not skip a long tab to squeeze later tabs into the leading group", () => {
|
||||
expect(fitTabs(tabs, 200, "assertions").map((tab) => tab.value)).toEqual([
|
||||
"body",
|
||||
"assertions",
|
||||
]);
|
||||
expect(fitTabs(tabs, 160).map((tab) => tab.value)).toEqual(["body", "headers"]);
|
||||
});
|
||||
|
||||
test("handles tabs with no spacing to compress", () => {
|
||||
const shortTabs = tabs.filter((tab) => tab.value === "body" || tab.value === "info");
|
||||
expect(fitTabs(shortTabs, 126, "body")).toEqual([
|
||||
{ value: "body", width: 73, spacingCompression: 0.5 },
|
||||
{ value: "info", width: 53, spacingCompression: 0.5 },
|
||||
]);
|
||||
const fixedTabs = shortTabs.map((tab) => ({ ...tab, compactWidth: tab.width }));
|
||||
expect(fitTabs(fixedTabs, 126, "body")).toEqual([
|
||||
{ value: "body", width: 80, spacingCompression: 0 },
|
||||
]);
|
||||
expect(fitTabs(fixedTabs, 40, "body")).toEqual([]);
|
||||
});
|
||||
|
||||
test("stays within the pane and each label's bounds throughout resizing", () => {
|
||||
for (let width = 0; width <= 500; width++) {
|
||||
for (const active of tabs) {
|
||||
const result = fitTabs(tabs, width, active.value);
|
||||
expect(result.some((tab) => tab.value === active.value)).toBe(width >= active.compactWidth);
|
||||
expect(result.reduce((sum, tab) => sum + tab.width, 0)).toBeLessThanOrEqual(width + 0.001);
|
||||
for (const tab of result) {
|
||||
const original = tabs.find((t) => t.value === tab.value)!;
|
||||
expect(tab.width).toBeGreaterThanOrEqual(original.compactWidth);
|
||||
expect(tab.width).toBeLessThanOrEqual(original.width);
|
||||
expect(tab.spacingCompression).toBeGreaterThanOrEqual(0);
|
||||
expect(tab.spacingCompression).toBeLessThanOrEqual(1);
|
||||
// All removed width must come from spacing, at every pane size.
|
||||
expect(original.width - tab.width).toBeCloseTo(
|
||||
tab.spacingCompression * (original.width - original.compactWidth),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
export const MIN_TAB_PADDING_SCALE = 0.5;
|
||||
export const MIN_TAB_GAP_SCALE = 0.25;
|
||||
|
||||
export interface TabWidth {
|
||||
value: string;
|
||||
width: number;
|
||||
/** Full label and badges, with the smallest padding and gap. */
|
||||
compactWidth: number;
|
||||
}
|
||||
|
||||
/** Tighten spacing to fit whole tabs, keeping every visible label intact. */
|
||||
export function fitTabs(
|
||||
tabs: TabWidth[],
|
||||
availableWidth: number,
|
||||
activeValue?: string,
|
||||
): { value: string; width: number; spacingCompression: number }[] {
|
||||
const available = Math.max(0, availableWidth);
|
||||
// Even the active tab goes into overflow if it cannot fit on its own.
|
||||
const active = tabs.find((tab) => tab.value === activeValue && tab.compactWidth <= available);
|
||||
let remaining = available - (active?.compactWidth ?? 0);
|
||||
const fitted = new Set(active ? [active.value] : []);
|
||||
for (const tab of tabs) {
|
||||
if (tab === active) continue;
|
||||
if (tab.compactWidth > remaining) break;
|
||||
fitted.add(tab.value);
|
||||
remaining -= tab.compactWidth;
|
||||
}
|
||||
const visible = tabs.filter((tab) => fitted.has(tab.value));
|
||||
const naturalWidth = visible.reduce((sum, tab) => sum + tab.width, 0);
|
||||
const deficit = Math.max(0, naturalWidth - available);
|
||||
const spareSpacing = visible.reduce((sum, tab) => sum + tab.width - tab.compactWidth, 0);
|
||||
const spacingCompression = spareSpacing > 0 ? Math.min(1, deficit / spareSpacing) : 0;
|
||||
return visible.map((tab) => ({
|
||||
value: tab.value,
|
||||
spacingCompression,
|
||||
width: tab.width - spacingCompression * (tab.width - tab.compactWidth),
|
||||
}));
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { fitTabs, MIN_TAB_GAP_SCALE, MIN_TAB_PADDING_SCALE } from "./fitTabs";
|
||||
|
||||
export function useFittingTabs(enabled: boolean, activeValue?: string) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const measurementsRef = useRef<HTMLDivElement>(null);
|
||||
const [fittedTabs, setFittedTabs] = useState<ReturnType<typeof fitTabs> | null>(null);
|
||||
|
||||
// Reobserve after render: tab labels, badges and available tabs can all change.
|
||||
useLayoutEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
const measurements = measurementsRef.current;
|
||||
if (!enabled || !viewport || !measurements) return;
|
||||
|
||||
const measure = () => {
|
||||
const widths = Array.from(measurements.children, (child) => {
|
||||
const width = child.getBoundingClientRect().width;
|
||||
const button = child.querySelector("button");
|
||||
const buttonStyle = button ? getComputedStyle(button) : null;
|
||||
const padding = buttonStyle
|
||||
? Number.parseFloat(buttonStyle.paddingLeft) + Number.parseFloat(buttonStyle.paddingRight)
|
||||
: 0;
|
||||
const gap = child.firstElementChild
|
||||
? Number.parseFloat(getComputedStyle(child.firstElementChild).marginRight)
|
||||
: 0;
|
||||
const compactWidth =
|
||||
width - padding * (1 - MIN_TAB_PADDING_SCALE) - gap * (1 - MIN_TAB_GAP_SCALE);
|
||||
return {
|
||||
value: child.getAttribute("data-measure-tab") ?? "",
|
||||
width,
|
||||
compactWidth,
|
||||
};
|
||||
});
|
||||
const next = fitTabs(widths, viewport.clientWidth, activeValue);
|
||||
setFittedTabs((previous) =>
|
||||
previous?.length === next.length &&
|
||||
previous.every(
|
||||
(tab, i) =>
|
||||
tab.value === next[i]?.value &&
|
||||
tab.width === next[i]?.width &&
|
||||
tab.spacingCompression === next[i]?.spacingCompression,
|
||||
)
|
||||
? previous
|
||||
: next,
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(viewport);
|
||||
for (const child of measurements.children) observer.observe(child);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
return { viewportRef, measurementsRef, fittedTabs: enabled ? fittedTabs : null };
|
||||
}
|
||||
@@ -5,13 +5,11 @@ import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||
import pdfWorkerUrl from "./pdfWorker?worker&url";
|
||||
|
||||
// Document can start loading during render, so configure its worker synchronously.
|
||||
// Vite bundles the installed PDF.js worker as a local asset, including offline builds.
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(pdfWorkerUrl, import.meta.url).toString();
|
||||
|
||||
interface Props {
|
||||
/** A URL for the body the host already stored. */
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// The worker has its own globals, so it needs the polyfills before PDF.js evaluates
|
||||
import "../../polyfills";
|
||||
import "pdfjs-dist/build/pdf.worker.min.mjs";
|
||||
@@ -31,6 +31,7 @@
|
||||
<div id="cm-portal" class="cm-portal"></div>
|
||||
<div id="react-portal"></div>
|
||||
<div id="radix-portal" class="cm-portal"></div>
|
||||
<script type="module" src="/polyfills.ts"></script>
|
||||
<script type="module" src="/theme.ts"></script>
|
||||
<script type="module" src="/font-size.ts"></script>
|
||||
<script type="module" src="/font.ts"></script>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* oxlint-disable no-template-curly-in-string */
|
||||
|
||||
/*
|
||||
* Shared expectations for `resolvedModelName`.
|
||||
*
|
||||
* The request template function plugin carries a hand-copied `resolvedModelName` and template-tag
|
||||
* scanner, because importing this module into the plugin bundle fails to build on Windows in CI.
|
||||
* Both `resolvedModelName.test.ts` here and `plugins/template-function-request/tests/
|
||||
* resolvedModelName.test.ts` iterate these cases, so changing an expectation on this side
|
||||
* automatically exercises the plugin's copy and a drifting copy fails its own suite.
|
||||
*
|
||||
* Keep this file dependency-free — the plugin test imports it by relative path, so anything it
|
||||
* pulls in would have to resolve from both packages.
|
||||
*/
|
||||
|
||||
export interface ResolvedModelNameCase {
|
||||
/** Test name, used as the `test.each` title on both sides. */
|
||||
name: string;
|
||||
/** `url` of the `http_request` under test. */
|
||||
url: string;
|
||||
/** `name` of the `http_request`, when the case is about an explicit name. */
|
||||
requestName?: string;
|
||||
/** What `resolvedModelName` must return. */
|
||||
expected: string;
|
||||
}
|
||||
|
||||
export const resolvedModelNameCases: ResolvedModelNameCase[] = [
|
||||
{
|
||||
name: "returns the name when there is one",
|
||||
url: "https://example.com",
|
||||
requestName: "My Request",
|
||||
expected: "My Request",
|
||||
},
|
||||
{
|
||||
name: "replaces a simple tag with its contents",
|
||||
url: "${[ base_url ]}/users",
|
||||
expected: "base_url/users",
|
||||
},
|
||||
{
|
||||
name: "replaces a tag containing a quoted argument with a space",
|
||||
url: "${[ fn(arg='my key') ]}/users",
|
||||
expected: "fn(arg='my key')/users",
|
||||
},
|
||||
{
|
||||
name: "replaces each tag in a multi-tag string",
|
||||
url: "${[ fn(arg='my key') ]}/a/${[ other(b='x y') ]}/b",
|
||||
expected: "fn(arg='my key')/a/other(b='x y')/b",
|
||||
},
|
||||
{
|
||||
name: "keeps a quoted `]}` inside the tag",
|
||||
url: "${[ fn(arg='x]}y') ]}/users",
|
||||
expected: "fn(arg='x]}y')/users",
|
||||
},
|
||||
{
|
||||
name: "keeps an escaped quote inside the tag",
|
||||
url: "${[ fn(arg='it\\'s ]}') ]}/users",
|
||||
expected: "fn(arg='it\\'s ]}')/users",
|
||||
},
|
||||
{
|
||||
name: "closes at the first `]}` when a quote is left unterminated",
|
||||
url: "${[ fn(arg='oops ]}/users",
|
||||
expected: "fn(arg='oops/users",
|
||||
},
|
||||
{
|
||||
name: "stops each tag at its first closing bracket",
|
||||
url: "${[ a ]}${[ b ]}",
|
||||
expected: "ab",
|
||||
},
|
||||
{
|
||||
name: "strips the protocol",
|
||||
url: "https://example.com/${[ path ]}",
|
||||
expected: "example.com/path",
|
||||
},
|
||||
{
|
||||
name: "falls back to a generic name when the url is empty",
|
||||
url: "",
|
||||
expected: "HTTP Request",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { resolvedModelNameCases } from "./resolvedModelName.cases";
|
||||
|
||||
// resolvedModelName only reads plain model fields, but its module imports pull in the
|
||||
// platform singleton, which needs a browser. Stub them out to keep this a pure unit test.
|
||||
vi.mock("@yaakapp-internal/models", () => ({ foldersAtom: {} }));
|
||||
vi.mock("./jotai", () => ({ jotaiStore: { get: () => [] } }));
|
||||
|
||||
const { resolvedModelName } = await import("./resolvedModelName");
|
||||
|
||||
function httpRequest(url: string, name = ""): HttpRequest {
|
||||
return { id: "rq_test", model: "http_request", name, url } as HttpRequest;
|
||||
}
|
||||
|
||||
describe("resolvedModelName", () => {
|
||||
// Shared with the plugin's hand-copied scanner. See `resolvedModelName.cases.ts`.
|
||||
test.each(resolvedModelNameCases)("$name", ({ url, requestName, expected }) => {
|
||||
expect(resolvedModelName(httpRequest(url, requestName))).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { foldersAtom } from "@yaakapp-internal/models";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { replaceTemplateTags } from "./templateTags";
|
||||
|
||||
export function resolvedModelName(r: AnyModel | null): string {
|
||||
if (r == null) return "";
|
||||
@@ -15,7 +16,7 @@ export function resolvedModelName(r: AnyModel | null): string {
|
||||
}
|
||||
|
||||
// Replace variable syntax with variable name
|
||||
const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, "$1");
|
||||
const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim());
|
||||
if (withoutVariables.trim() === "") {
|
||||
return r.model === "http_request"
|
||||
? r.bodyType && r.bodyType === "graphql"
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/* oxlint-disable no-template-curly-in-string */
|
||||
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { findTemplateTags, replaceTemplateTags } from "./templateTags";
|
||||
|
||||
/** Seeded so a fuzz failure reproduces. */
|
||||
function random(seed: number): () => number {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function millis(run: () => void): number {
|
||||
const started = performance.now();
|
||||
run();
|
||||
return performance.now() - started;
|
||||
}
|
||||
|
||||
describe("findTemplateTags", () => {
|
||||
test("finds a simple tag", () => {
|
||||
expect(findTemplateTags("${[ my_var ]}")).toEqual([{ start: 0, end: 13, inner: " my_var " }]);
|
||||
});
|
||||
|
||||
test("finds a tag surrounded by text", () => {
|
||||
const text = 'a="${[ my_var ]}"';
|
||||
expect(findTemplateTags(text)).toEqual([{ start: 3, end: 16, inner: " my_var " }]);
|
||||
expect(text.slice(3, 16)).toEqual("${[ my_var ]}");
|
||||
});
|
||||
|
||||
test("finds an empty tag", () => {
|
||||
expect(findTemplateTags("${[]}")).toEqual([{ start: 0, end: 5, inner: "" }]);
|
||||
});
|
||||
|
||||
test("keeps a quoted `]}` inside the tag", () => {
|
||||
const text = "${[ fn(a='x]}y') ]}";
|
||||
expect(findTemplateTags(text)).toEqual([
|
||||
{ start: 0, end: text.length, inner: " fn(a='x]}y') " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps an escaped quote inside the string", () => {
|
||||
const text = "${[ fn(a='it\\'s ]}') ]}";
|
||||
expect(findTemplateTags(text)).toEqual([
|
||||
{ start: 0, end: text.length, inner: " fn(a='it\\'s ]}') " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a b64 string in one tag", () => {
|
||||
const text = "${[ b64'Zm9v' ]}";
|
||||
expect(findTemplateTags(text)).toEqual([{ start: 0, end: text.length, inner: " b64'Zm9v' " }]);
|
||||
});
|
||||
|
||||
test("closes at the first `]}` when a quote is left unterminated", () => {
|
||||
const text = "${[ fn(a='unterminated ]} rest";
|
||||
expect(findTemplateTags(text)).toEqual([{ start: 0, end: 25, inner: " fn(a='unterminated " }]);
|
||||
expect(text.slice(0, 25)).toEqual("${[ fn(a='unterminated ]}");
|
||||
});
|
||||
|
||||
test("finds two tags with text between them", () => {
|
||||
expect(findTemplateTags("one ${[ a ]} two ${[ b ]} three")).toEqual([
|
||||
{ start: 4, end: 12, inner: " a " },
|
||||
{ start: 17, end: 25, inner: " b " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds two adjacent tags", () => {
|
||||
expect(findTemplateTags("${[ a ]} ${[ b ]}")).toEqual([
|
||||
{ start: 0, end: 8, inner: " a " },
|
||||
{ start: 9, end: 17, inner: " b " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds two tags that each quote a `]}`", () => {
|
||||
const text = "${[ a(x='1]}') ]} and ${[ b(y='2]}') ]}";
|
||||
expect(findTemplateTags(text)).toEqual([
|
||||
{ start: 0, end: 17, inner: " a(x='1]}') " },
|
||||
{ start: 22, end: 39, inner: " b(y='2]}') " },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds nothing in an unclosed tag", () => {
|
||||
expect(findTemplateTags("${[ no close")).toEqual([]);
|
||||
});
|
||||
|
||||
test("finds nothing in text without tags", () => {
|
||||
expect(findTemplateTags('{"a": "${b}", "c": 1}')).toEqual([]);
|
||||
});
|
||||
|
||||
test("treats a nested `${[` as ordinary content", () => {
|
||||
expect(findTemplateTags("${[ a ${[ b ]}")).toEqual([{ start: 0, end: 14, inner: " a ${[ b " }]);
|
||||
});
|
||||
|
||||
test("finishes fast on a long run of quotes", () => {
|
||||
const text = `\${[${"'".repeat(20_000)} ]}`;
|
||||
let matches: unknown;
|
||||
const elapsed = millis(() => {
|
||||
matches = findTemplateTags(text);
|
||||
});
|
||||
expect(matches).toEqual([{ start: 0, end: text.length, inner: `${"'".repeat(20_000)} ` }]);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test("finishes fast on a long run of unpairable quotes", () => {
|
||||
// Every quote is escaped, so none of them opens a string that closes
|
||||
const text = `\${[${"\\'".repeat(20_000)} ]}`;
|
||||
let matches: unknown;
|
||||
const elapsed = millis(() => {
|
||||
matches = findTemplateTags(text);
|
||||
});
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test("finishes fast on a long run of openers that never close", () => {
|
||||
const text = "${[".repeat(20_000);
|
||||
let matches: unknown;
|
||||
const elapsed = millis(() => {
|
||||
matches = findTemplateTags(text);
|
||||
});
|
||||
expect(matches).toEqual([]);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
});
|
||||
|
||||
test("never throws, and always reports real bounds", () => {
|
||||
const alphabet = ["$", "{", "[", "]", "}", "'", "\\", "a", "b"];
|
||||
const next = random(0x5eed);
|
||||
|
||||
for (let n = 0; n < 2_000; n++) {
|
||||
let text = "";
|
||||
const length = Math.floor(next() * 31);
|
||||
for (let i = 0; i < length; i++) {
|
||||
text += alphabet[Math.floor(next() * alphabet.length)];
|
||||
}
|
||||
|
||||
let matches: ReturnType<typeof findTemplateTags> = [];
|
||||
expect(() => {
|
||||
matches = findTemplateTags(text);
|
||||
}, text).not.toThrow();
|
||||
|
||||
let previousEnd = 0;
|
||||
for (const match of matches) {
|
||||
expect(text.slice(match.start, match.start + 3), text).toEqual("${[");
|
||||
expect(text.slice(match.end - 2, match.end), text).toEqual("]}");
|
||||
expect(text.slice(match.start + 3, match.end - 2), text).toEqual(match.inner);
|
||||
expect(match.start, text).toBeGreaterThanOrEqual(previousEnd);
|
||||
previousEnd = match.end;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceTemplateTags", () => {
|
||||
test("leaves text without tags alone", () => {
|
||||
expect(replaceTemplateTags('{"a": 1}', () => "x")).toEqual('{"a": 1}');
|
||||
});
|
||||
|
||||
test("replaces each tag with its trimmed contents", () => {
|
||||
expect(replaceTemplateTags("${[ a ]}/x/${[ b ]}", (m) => m.inner.trim())).toEqual("a/x/b");
|
||||
});
|
||||
|
||||
test("keeps the replaced length when padding", () => {
|
||||
const tag = "${[ fn(arg='x]}y') ]}";
|
||||
const text = `{"a": "${tag}"}`;
|
||||
const replaced = replaceTemplateTags(text, (m) => "1".repeat(m.end - m.start));
|
||||
expect(replaced).toEqual(`{"a": "${"1".repeat(tag.length)}"}`);
|
||||
expect(replaced).toHaveLength(text.length);
|
||||
});
|
||||
|
||||
test("leaves an unclosed tag in place", () => {
|
||||
expect(replaceTemplateTags("${[ oops", () => "x")).toEqual("${[ oops");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Finding template tags in text.
|
||||
*
|
||||
* A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the
|
||||
* printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to
|
||||
* step over quoted strings. It mirrors the Lezer grammar in
|
||||
* `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever
|
||||
* follows it) is consumed whole, and an unterminated quote is just another character, so a
|
||||
* half-typed tag still closes at the next `]}` instead of swallowing the rest of the document.
|
||||
*
|
||||
* This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated
|
||||
* quote needed a negative lookahead that rescanned to the end of the input at every unpaired
|
||||
* quote, which is quadratic on text an attacker picks. The scan below is linear instead — see
|
||||
* the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why.
|
||||
*/
|
||||
|
||||
const QUOTE = 0x27; // '
|
||||
const BACKSLASH = 0x5c; // \
|
||||
const CLOSE_BRACKET = 0x5d; // ]
|
||||
const CLOSE_BRACE = 0x7d; // }
|
||||
|
||||
/** The `${[`…`]}` bounds of one tag, and what sits between them. */
|
||||
export interface TemplateTagMatch {
|
||||
/** Index of the `$` that opens the tag. */
|
||||
start: number;
|
||||
/** Index just past the closing `]}`. */
|
||||
end: number;
|
||||
/** Everything between `${[` and `]}`, not trimmed. */
|
||||
inner: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or
|
||||
* `-1` when nothing does.
|
||||
*
|
||||
* Built right to left in a single pass, which is the trick that replaces the regex lookahead:
|
||||
* whether a quote opens a terminated string depends only on the text after it, so the answer
|
||||
* can be computed once for every position instead of re-derived at each quote. Skipping a
|
||||
* string of any length then costs one lookup.
|
||||
*/
|
||||
function closingQuoteTable(text: string): Int32Array {
|
||||
// Two extra slots so a `\` in the last position can read past the end and find `-1`
|
||||
const table = new Int32Array(text.length + 2).fill(-1);
|
||||
for (let i = text.length - 1; i >= 0; i--) {
|
||||
const c = text.charCodeAt(i);
|
||||
// A backslash escapes whatever follows it, including a quote
|
||||
const next = c === BACKSLASH ? table[i + 2] : table[i + 1];
|
||||
table[i] = c === QUOTE ? i : (next ?? -1);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every template tag in `text`, in order and without overlaps.
|
||||
*
|
||||
* A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body —
|
||||
* both are ordinary content, same as the regex this replaced treated them.
|
||||
*/
|
||||
export function findTemplateTags(text: string): TemplateTagMatch[] {
|
||||
const matches: TemplateTagMatch[] = [];
|
||||
|
||||
// Both built only once a scan actually needs them, so short values allocate nothing
|
||||
let closingQuotes: Int32Array | null = null;
|
||||
|
||||
/*
|
||||
* Body positions a previous scan already ran off the end of the text from. Where a scan ends
|
||||
* up depends only on where it starts, so a scan that walks into one of these would repeat
|
||||
* that same walk and fail the same way, and can stop right there.
|
||||
*
|
||||
* That bound is what keeps the whole pass linear: every position enters this set at most
|
||||
* once, and every step of every scan either visits a position for the first time or ends
|
||||
* that scan.
|
||||
*/
|
||||
let deadEnds: Set<number> | null = null;
|
||||
|
||||
let searchFrom = 0;
|
||||
while (searchFrom < text.length) {
|
||||
const start = text.indexOf("${[", searchFrom);
|
||||
if (start < 0) break;
|
||||
|
||||
const visited: number[] = [];
|
||||
let end = -1;
|
||||
let i = start + 3;
|
||||
while (i < text.length) {
|
||||
if (deadEnds?.has(i)) break;
|
||||
visited.push(i);
|
||||
|
||||
const c = text.charCodeAt(i);
|
||||
if (c === QUOTE) {
|
||||
closingQuotes ??= closingQuoteTable(text);
|
||||
const close = closingQuotes[i + 1] ?? -1;
|
||||
// An unterminated quote is an ordinary character, so only step over a closed string
|
||||
i = close < 0 ? i + 1 : close + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === CLOSE_BRACKET && text.charCodeAt(i + 1) === CLOSE_BRACE) {
|
||||
end = i + 2;
|
||||
break;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (end < 0) {
|
||||
// Not a tag. Resume past the opener: a real tag may start inside a string this scan
|
||||
// stepped over.
|
||||
deadEnds ??= new Set();
|
||||
for (const position of visited) deadEnds.add(position);
|
||||
searchFrom = start + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({ start, end, inner: text.slice(start + 3, end - 2) });
|
||||
searchFrom = end;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** Rewrites every template tag in `text` with whatever `replace` returns for it. */
|
||||
export function replaceTemplateTags(
|
||||
text: string,
|
||||
replace: (match: TemplateTagMatch) => string,
|
||||
): string {
|
||||
const matches = findTemplateTags(text);
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
let result = "";
|
||||
let copiedTo = 0;
|
||||
for (const match of matches) {
|
||||
result += text.slice(copiedTo, match.start) + replace(match);
|
||||
copiedTo = match.end;
|
||||
}
|
||||
|
||||
return result + text.slice(copiedTo);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { validateHttpHeader } from "./validateHttpHeader";
|
||||
|
||||
describe("validateHttpHeader", () => {
|
||||
test("allows an empty value", () => {
|
||||
expect(validateHttpHeader("")).toEqual(true);
|
||||
});
|
||||
|
||||
test("allows a plain header name", () => {
|
||||
expect(validateHttpHeader("X-Api-Key")).toEqual(true);
|
||||
});
|
||||
|
||||
test("rejects a header name with a space", () => {
|
||||
expect(validateHttpHeader("X Api Key")).toEqual(false);
|
||||
});
|
||||
|
||||
test("allows a simple template tag", () => {
|
||||
expect(validateHttpHeader("${[ my_var ]}")).toEqual(true);
|
||||
});
|
||||
|
||||
test("allows a tag containing a quoted argument with a space", () => {
|
||||
expect(validateHttpHeader("${[ fn(arg='my key') ]}")).toEqual(true);
|
||||
});
|
||||
|
||||
test("allows a multi-tag value", () => {
|
||||
expect(validateHttpHeader("${[ fn(arg='my key') ]}-${[ other(b='x y') ]}")).toEqual(true);
|
||||
});
|
||||
|
||||
test("allows a tag whose quoted argument contains `]}`", () => {
|
||||
expect(validateHttpHeader("${[ fn(arg='x]}y') ]}")).toEqual(true);
|
||||
});
|
||||
|
||||
test("allows a tag whose quoted argument contains an escaped quote", () => {
|
||||
expect(validateHttpHeader("${[ fn(arg='it\\'s ]}') ]}")).toEqual(true);
|
||||
});
|
||||
|
||||
test("rejects a tag with a quoted `]}` followed by an invalid character", () => {
|
||||
expect(validateHttpHeader("${[ fn(arg='x]}y') ]} oops")).toEqual(false);
|
||||
});
|
||||
|
||||
test("rejects a tag followed by an invalid character", () => {
|
||||
expect(validateHttpHeader("${[ my_var ]} oops")).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { replaceTemplateTags } from "./templateTags";
|
||||
|
||||
export function validateHttpHeader(v: string): boolean {
|
||||
if (v === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Template strings are not allowed so we replace them with a valid example string
|
||||
const withoutTemplateStrings = replaceTemplateTags(v, () => "123");
|
||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { getOrInsert, getOrInsertComputed, readableStreamValues, sumPrecise } from "./polyfills";
|
||||
|
||||
describe("getOrInsert", () => {
|
||||
test("returns the existing value without replacing it", () => {
|
||||
const map = new Map([["a", 1]]);
|
||||
expect(getOrInsert.call(map, "a", 2)).toBe(1);
|
||||
expect(map.get("a")).toBe(1);
|
||||
});
|
||||
|
||||
test("inserts and returns the value for a missing key", () => {
|
||||
const map = new Map<string, number>();
|
||||
expect(getOrInsert.call(map, "a", 2)).toBe(2);
|
||||
expect(map.get("a")).toBe(2);
|
||||
});
|
||||
|
||||
test("works on a WeakMap", () => {
|
||||
const key = {};
|
||||
const map = new WeakMap<object, number>();
|
||||
expect(getOrInsert.call(map, key, 1)).toBe(1);
|
||||
expect(getOrInsert.call(map, key, 2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrInsertComputed", () => {
|
||||
test("returns the existing value without calling the callback", () => {
|
||||
const map = new Map([["a", 1]]);
|
||||
const callback = vi.fn(() => 2);
|
||||
expect(getOrInsertComputed.call(map, "a", callback)).toBe(1);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls the callback with the key and inserts its result", () => {
|
||||
const map = new Map<string, string>();
|
||||
expect(getOrInsertComputed.call(map, "a", (key) => `${String(key)}!`)).toBe("a!");
|
||||
expect(map.get("a")).toBe("a!");
|
||||
});
|
||||
|
||||
test("stores what the callback returns even if the callback set the key", () => {
|
||||
const map = new Map<string, number>();
|
||||
const result = getOrInsertComputed.call(map, "a", () => {
|
||||
map.set("a", 1);
|
||||
return 2;
|
||||
});
|
||||
expect(result).toBe(2);
|
||||
expect(map.get("a")).toBe(2);
|
||||
});
|
||||
|
||||
test("canonicalizes -0 to +0", () => {
|
||||
const map = new Map<number, number>();
|
||||
getOrInsertComputed.call(map, -0, (key) => {
|
||||
expect(Object.is(key, 0)).toBe(true);
|
||||
return 1;
|
||||
});
|
||||
expect(Object.is([...map.keys()][0], 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("throws when the callback is not a function, even for an existing key", () => {
|
||||
const map = new Map([["a", 1]]);
|
||||
// @ts-expect-error testing a non-callable callback
|
||||
expect(() => getOrInsertComputed.call(map, "a", 1)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test("is installed on Map and WeakMap", () => {
|
||||
const map = new Map<string, number[]>();
|
||||
map.getOrInsertComputed("a", () => []).push(1);
|
||||
map.getOrInsertComputed("a", () => []).push(2);
|
||||
expect(map.get("a")).toEqual([1, 2]);
|
||||
|
||||
const key = {};
|
||||
const weakMap = new WeakMap<object, number>();
|
||||
expect(weakMap.getOrInsertComputed(key, () => 1)).toBe(1);
|
||||
expect(weakMap.getOrInsert(key, 2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sumPrecise", () => {
|
||||
test("sums without intermediate rounding", () => {
|
||||
expect(sumPrecise([1e20, 0.1, -1e20])).toBe(0.1);
|
||||
expect(sumPrecise([0.1, 0.2, 0.3])).toBe(0.6);
|
||||
expect(sumPrecise(Array.from({ length: 10 }, () => 0.1))).toBe(1);
|
||||
});
|
||||
|
||||
test("rounds half to even on the final step", () => {
|
||||
expect(sumPrecise([1, 1e-16, 1e-16])).toBe(1.0000000000000002);
|
||||
expect(sumPrecise([1, 2 ** -53])).toBe(1);
|
||||
});
|
||||
|
||||
test("handles zeroes, infinities and NaN like the spec", () => {
|
||||
expect(Object.is(sumPrecise([]), -0)).toBe(true);
|
||||
expect(Object.is(sumPrecise([-0, -0]), -0)).toBe(true);
|
||||
expect(Object.is(sumPrecise([-0, 0]), 0)).toBe(true);
|
||||
expect(Object.is(sumPrecise([1, -1]), 0)).toBe(true);
|
||||
expect(sumPrecise([1, Infinity])).toBe(Infinity);
|
||||
expect(sumPrecise([1, -Infinity])).toBe(-Infinity);
|
||||
expect(sumPrecise([Infinity, -Infinity])).toBeNaN();
|
||||
expect(sumPrecise([1, NaN])).toBeNaN();
|
||||
});
|
||||
|
||||
test("accepts any iterable and rejects non-numbers", () => {
|
||||
expect(sumPrecise(new Set([1, 2, 3]))).toBe(6);
|
||||
// @ts-expect-error testing a non-number item
|
||||
expect(() => sumPrecise([1, "2"])).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test("is installed on Math", () => {
|
||||
expect(Math.sumPrecise([1, 2, 3])).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readableStreamValues", () => {
|
||||
const values = readableStreamValues<number>;
|
||||
|
||||
function streamOf(chunks: number[], onCancel?: () => void) {
|
||||
return new ReadableStream<number>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
cancel: onCancel,
|
||||
});
|
||||
}
|
||||
|
||||
test("yields every chunk and releases the lock", async () => {
|
||||
const stream = streamOf([1, 2, 3]);
|
||||
const seen: number[] = [];
|
||||
for await (const chunk of values.call(stream)) seen.push(chunk);
|
||||
expect(seen).toEqual([1, 2, 3]);
|
||||
expect(stream.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("cancels the stream when the loop exits early", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const stream = new ReadableStream<number>({
|
||||
pull(controller) {
|
||||
controller.enqueue(1);
|
||||
},
|
||||
cancel: onCancel,
|
||||
});
|
||||
for await (const _ of values.call(stream)) break;
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(stream.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("leaves the stream open with preventCancel", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const stream = streamOf([1, 2], onCancel);
|
||||
for await (const _ of values.call(stream, { preventCancel: true })) break;
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(stream.locked).toBe(false);
|
||||
});
|
||||
|
||||
test("releases the lock and rethrows when cancelling rejects", async () => {
|
||||
const stream = new ReadableStream<number>({
|
||||
pull(controller) {
|
||||
controller.enqueue(1);
|
||||
},
|
||||
cancel() {
|
||||
throw new Error("cancel failed");
|
||||
},
|
||||
});
|
||||
await expect(async () => {
|
||||
for await (const _ of values.call(stream)) break;
|
||||
}).rejects.toThrow("cancel failed");
|
||||
expect(stream.locked).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Built-ins that PDF.js's modern build calls unconditionally but the system WebKit on
|
||||
* older macOS lacks: the upsert methods and `Math.sumPrecise` arrived in Safari 26.2,
|
||||
* and async iteration of `ReadableStream` in Safari 27. Without them the PDF viewer
|
||||
* throws as soon as it renders. Loaded first by `index.html`, and by `pdfWorker.ts`
|
||||
* because the worker has its own globals.
|
||||
*
|
||||
* Each is defined only when missing, so newer engines keep their native versions.
|
||||
*/
|
||||
|
||||
type KeyedCollection<K, V> = {
|
||||
has(key: K): boolean;
|
||||
get(key: K): V | undefined;
|
||||
set(key: K, value: V): unknown;
|
||||
};
|
||||
|
||||
/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsert */
|
||||
export function getOrInsert<K, V>(this: KeyedCollection<K, V>, key: K, value: V): V {
|
||||
if (this.has(key)) return this.get(key) as V;
|
||||
this.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsertComputed */
|
||||
export function getOrInsertComputed<K, V>(
|
||||
this: KeyedCollection<K, V>,
|
||||
key: K,
|
||||
callbackfn: (key: K) => V,
|
||||
): V {
|
||||
if (typeof callbackfn !== "function") {
|
||||
throw new TypeError("getOrInsertComputed callback is not a function");
|
||||
}
|
||||
if (this.has(key)) return this.get(key) as V;
|
||||
// Map keys are canonicalized, so -0 is stored and passed on as +0
|
||||
const k = (Object.is(key, -0) ? 0 : key) as K;
|
||||
const value = callbackfn(k);
|
||||
// The spec sets after the callback even if the callback inserted the key itself
|
||||
this.set(k, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://tc39.es/proposal-math-sum/
|
||||
*
|
||||
* Shewchuk's exact summation with round-half-even on the final step (the algorithm
|
||||
* behind Python's `math.fsum`). A finite input set whose running sum overflows
|
||||
* partway through is not handled exactly, which PDF.js's byte lengths and widths
|
||||
* never come near.
|
||||
*/
|
||||
export function sumPrecise(items: Iterable<number>): number {
|
||||
const partials: number[] = [];
|
||||
let allNegativeZero = true;
|
||||
let sawNaN = false;
|
||||
let sawPositiveInfinity = false;
|
||||
let sawNegativeInfinity = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (typeof item !== "number") {
|
||||
throw new TypeError("Math.sumPrecise requires an iterable of numbers");
|
||||
}
|
||||
if (!Object.is(item, -0)) allNegativeZero = false;
|
||||
if (Number.isNaN(item)) {
|
||||
sawNaN = true;
|
||||
} else if (item === Infinity) {
|
||||
sawPositiveInfinity = true;
|
||||
} else if (item === -Infinity) {
|
||||
sawNegativeInfinity = true;
|
||||
} else {
|
||||
let x = item;
|
||||
let i = 0;
|
||||
for (let y of partials) {
|
||||
if (Math.abs(x) < Math.abs(y)) [x, y] = [y, x];
|
||||
const hi = x + y;
|
||||
const lo = y - (hi - x);
|
||||
if (lo !== 0) partials[i++] = lo;
|
||||
x = hi;
|
||||
}
|
||||
partials.length = i;
|
||||
partials.push(x);
|
||||
}
|
||||
}
|
||||
|
||||
if (sawNaN || (sawPositiveInfinity && sawNegativeInfinity)) return NaN;
|
||||
if (sawPositiveInfinity) return Infinity;
|
||||
if (sawNegativeInfinity) return -Infinity;
|
||||
if (allNegativeZero) return -0;
|
||||
|
||||
let n = partials.length;
|
||||
let hi = 0;
|
||||
let lo = 0;
|
||||
if (n > 0) {
|
||||
hi = partials[--n] as number;
|
||||
while (n > 0) {
|
||||
const x = hi;
|
||||
const y = partials[--n] as number;
|
||||
hi = x + y;
|
||||
lo = y - (hi - x);
|
||||
if (lo !== 0) break;
|
||||
}
|
||||
const next = partials[n - 1];
|
||||
if (n > 0 && next != null && ((lo < 0 && next < 0) || (lo > 0 && next > 0))) {
|
||||
const y = lo * 2;
|
||||
const x = hi + y;
|
||||
if (y === x - hi) hi = x;
|
||||
}
|
||||
}
|
||||
return hi === 0 ? 0 : hi;
|
||||
}
|
||||
|
||||
/** https://streams.spec.whatwg.org/#rs-asynciterator */
|
||||
export async function* readableStreamValues<R>(
|
||||
this: ReadableStream<R>,
|
||||
{ preventCancel = false }: { preventCancel?: boolean } = {},
|
||||
): AsyncGenerator<R, undefined, undefined> {
|
||||
const reader = this.getReader();
|
||||
let finished = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
finished = true;
|
||||
return;
|
||||
}
|
||||
yield value;
|
||||
}
|
||||
} catch (err) {
|
||||
finished = true;
|
||||
throw err;
|
||||
} finally {
|
||||
// Leaving the loop early (break, return, throw in the body) cancels the stream. The
|
||||
// lock is released even if cancelling rejects, which then propagates as in the spec.
|
||||
try {
|
||||
if (!finished && !preventCancel) await reader.cancel();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function define(target: object, key: PropertyKey, value: unknown) {
|
||||
if (key in target) return;
|
||||
Object.defineProperty(target, key, { value, writable: true, configurable: true });
|
||||
}
|
||||
|
||||
define(Map.prototype, "getOrInsert", getOrInsert);
|
||||
define(Map.prototype, "getOrInsertComputed", getOrInsertComputed);
|
||||
define(WeakMap.prototype, "getOrInsert", getOrInsert);
|
||||
define(WeakMap.prototype, "getOrInsertComputed", getOrInsertComputed);
|
||||
define(Math, "sumPrecise", sumPrecise);
|
||||
if (typeof ReadableStream !== "undefined") {
|
||||
define(ReadableStream.prototype, "values", readableStreamValues);
|
||||
define(
|
||||
ReadableStream.prototype,
|
||||
Symbol.asyncIterator,
|
||||
Reflect.get(ReadableStream.prototype, "values"),
|
||||
);
|
||||
}
|
||||
|
||||
// TypeScript's lib does not describe these yet
|
||||
declare global {
|
||||
interface Map<K, V> {
|
||||
getOrInsert(key: K, value: V): V;
|
||||
getOrInsertComputed(key: K, callbackfn: (key: K) => V): V;
|
||||
}
|
||||
interface WeakMap<K extends WeakKey, V> {
|
||||
getOrInsert(key: K, value: V): V;
|
||||
getOrInsertComputed(key: K, callbackfn: (key: K) => V): V;
|
||||
}
|
||||
interface Math {
|
||||
sumPrecise(items: Iterable<number>): number;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ name = "yaak"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
yaak-assertions = { workspace = true }
|
||||
arboard = "3"
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
@@ -71,9 +71,6 @@ pub enum Commands {
|
||||
/// Send a request, folder, or workspace by ID
|
||||
Send(SendArgs),
|
||||
|
||||
/// Run HTTP assertions (exit 0: passed, 1: failed/error, 2: invalid configuration)
|
||||
Test(TestArgs),
|
||||
|
||||
/// Cookie jar commands
|
||||
CookieJar(CookieJarArgs),
|
||||
|
||||
@@ -189,24 +186,6 @@ pub enum AgentCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct TestArgs {
|
||||
/// Request, folder, or workspace ID; sends HTTP requests sequentially in sidebar order
|
||||
pub id: String,
|
||||
|
||||
/// Stop after the first failed check or request error
|
||||
#[arg(long)]
|
||||
pub fail_fast: bool,
|
||||
|
||||
/// Write a JSON report without URLs, headers, bodies, or assertion values
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub json: Option<PathBuf>,
|
||||
|
||||
/// Write a JUnit XML report without request or assertion values
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub junit: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SendArgs {
|
||||
/// Request, folder, or workspace ID
|
||||
|
||||
@@ -10,5 +10,3 @@ pub mod response;
|
||||
pub mod send;
|
||||
pub mod template_function;
|
||||
pub mod workspace;
|
||||
|
||||
pub mod test;
|
||||
@@ -1,442 +0,0 @@
|
||||
use crate::cli::TestArgs;
|
||||
use crate::commands::request::resolve_cookie_jar_id;
|
||||
use crate::context::CliContext;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_assertions::{AssertionReport, HttpAssertions};
|
||||
use yaak_models::models::{Folder, HttpRequest};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
|
||||
// Reports intentionally contain no names, URLs, selectors, expected/actual values, or transport
|
||||
// error strings (which can embed URLs/tokens). The stored response has details for local inspection.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CheckResult {
|
||||
assertion_id: String,
|
||||
outcome: String,
|
||||
reason: String,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RequestResult {
|
||||
request_id: String,
|
||||
outcome: String,
|
||||
reason: String,
|
||||
checks: Vec<CheckResult>,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RunReport {
|
||||
version: u32,
|
||||
exit_code: i32,
|
||||
requests: Vec<RequestResult>,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
ctx: &CliContext,
|
||||
args: TestArgs,
|
||||
environment: Option<&str>,
|
||||
cookie_jar: Option<&str>,
|
||||
) -> i32 {
|
||||
match run_inner(ctx, &args, environment, cookie_jar).await {
|
||||
Ok(report) => {
|
||||
if let Err(e) = write_reports(&args, &report) {
|
||||
eprintln!("Could not write test report: {e}");
|
||||
return 2;
|
||||
}
|
||||
for request in &report.requests {
|
||||
println!(
|
||||
"{} {}: {}",
|
||||
request.outcome.to_uppercase(),
|
||||
request.request_id,
|
||||
request.reason
|
||||
);
|
||||
for check in &request.checks {
|
||||
println!(
|
||||
" {} {}: {}",
|
||||
check.outcome.to_uppercase(),
|
||||
check.assertion_id,
|
||||
check.reason
|
||||
);
|
||||
}
|
||||
}
|
||||
let count = |outcome| report.requests.iter().filter(|r| r.outcome == outcome).count();
|
||||
println!(
|
||||
"Test summary: {} passed, {} failed, {} errors, {} unchecked, {} skipped",
|
||||
count("passed"),
|
||||
count("failed"),
|
||||
count("error"),
|
||||
count("unchecked"),
|
||||
count("skipped")
|
||||
);
|
||||
report.exit_code
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Invalid test configuration: {error}");
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_inner(
|
||||
ctx: &CliContext,
|
||||
args: &TestArgs,
|
||||
environment: Option<&str>,
|
||||
cookie_jar: Option<&str>,
|
||||
) -> Result<RunReport, String> {
|
||||
let (workspace_id, requests) = select_requests(ctx, &args.id)?;
|
||||
let jar = resolve_cookie_jar_id(ctx, &workspace_id, cookie_jar)?;
|
||||
if let Some(id) = environment {
|
||||
ctx.db().get_environment_for_workspace(&workspace_id, id).map_err(|_| {
|
||||
"Selected environment is missing or does not belong to this workspace".to_owned()
|
||||
})?;
|
||||
}
|
||||
// Validate the entire selection before sending anything, including unchecked setup requests.
|
||||
let mut enabled = 0;
|
||||
for request in &requests {
|
||||
yaak_assertions::validate(&request.assertions)
|
||||
.map_err(|e| format!("{}: {e}", request.id))?;
|
||||
enabled += request.assertions.checks.iter().filter(|c| c.enabled).count();
|
||||
}
|
||||
if enabled == 0 {
|
||||
return Err("Selected requests have no enabled assertions".into());
|
||||
}
|
||||
if args.json.is_some() && args.json == args.junit {
|
||||
return Err("JSON and JUnit reports require different files".into());
|
||||
}
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
let cancel_task = tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
});
|
||||
let plugin_context = PluginContext::new(Some("cli".into()), Some(workspace_id));
|
||||
let response_dir = ctx.data_dir().join("responses");
|
||||
let mut report = RunReport { version: 1, exit_code: 0, requests: Vec::new() };
|
||||
let mut stopped = false;
|
||||
for request in requests {
|
||||
if stopped || *cancel_rx.borrow() {
|
||||
report.requests.push(skipped(
|
||||
&request.id,
|
||||
&request.assertions,
|
||||
if *cancel_rx.borrow() { "Canceled" } else { "Stopped after failure" },
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let id = request.id.clone();
|
||||
let definition = request.assertions.clone();
|
||||
let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
|
||||
query_manager: ctx.query_manager(),
|
||||
blob_manager: ctx.blob_manager(),
|
||||
request,
|
||||
environment_id: environment,
|
||||
update_source: UpdateSource::Sync,
|
||||
cookie_jar_id: jar.clone(),
|
||||
response_dir: &response_dir,
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
existing_response: None,
|
||||
plugin_manager: ctx.plugin_manager(),
|
||||
encryption_manager: ctx.encryption_manager.clone(),
|
||||
plugin_context: &plugin_context,
|
||||
cancelled_rx: Some(cancel_rx.clone()),
|
||||
connection_manager: ctx.connection_manager(),
|
||||
})
|
||||
.await;
|
||||
let request_result = match result {
|
||||
Ok(result) if result.response.error.is_none() && !*cancel_rx.borrow() => {
|
||||
summarize(&id, &definition, result.response.assertion_results.as_ref())
|
||||
}
|
||||
_ => RequestResult {
|
||||
request_id: id,
|
||||
outcome: "error".into(),
|
||||
reason: if *cancel_rx.borrow() {
|
||||
"Request canceled"
|
||||
} else {
|
||||
"Request did not complete successfully"
|
||||
}
|
||||
.into(),
|
||||
checks: definition
|
||||
.checks
|
||||
.iter()
|
||||
.map(|c| CheckResult {
|
||||
assertion_id: c.id.clone(),
|
||||
outcome: if c.enabled { "error" } else { "skipped" }.into(),
|
||||
reason: if c.enabled {
|
||||
"Request did not complete successfully"
|
||||
} else {
|
||||
"Disabled"
|
||||
}
|
||||
.into(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
};
|
||||
if matches!(request_result.outcome.as_str(), "failed" | "error") {
|
||||
report.exit_code = 1;
|
||||
stopped = args.fail_fast;
|
||||
}
|
||||
report.requests.push(request_result);
|
||||
}
|
||||
if *cancel_rx.borrow() {
|
||||
report.exit_code = 130;
|
||||
}
|
||||
cancel_task.abort();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn summarize(
|
||||
id: &str,
|
||||
definition: &HttpAssertions,
|
||||
report: Option<&AssertionReport>,
|
||||
) -> RequestResult {
|
||||
let checks: Vec<CheckResult> = report
|
||||
.map(|r| {
|
||||
r.results
|
||||
.iter()
|
||||
.map(|c| CheckResult {
|
||||
assertion_id: c.assertion_id.clone(),
|
||||
outcome: c.outcome.clone(),
|
||||
reason: c.reason.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let enabled = definition.checks.iter().filter(|c| c.enabled).count();
|
||||
let (outcome, reason) = if report.is_some_and(|r| r.error.is_some())
|
||||
|| checks.iter().any(|c| matches!(c.outcome.as_str(), "error" | "invalid"))
|
||||
{
|
||||
("error", "Assertions could not be evaluated")
|
||||
} else if checks.iter().any(|c| c.outcome == "failed") {
|
||||
("failed", "Assertions failed")
|
||||
} else if enabled == 0 {
|
||||
("unchecked", "No enabled assertions")
|
||||
} else if checks.iter().filter(|c| c.outcome == "passed").count() != enabled {
|
||||
("error", "Assertion results are incomplete")
|
||||
} else {
|
||||
("passed", "All enabled assertions passed")
|
||||
};
|
||||
RequestResult { request_id: id.into(), outcome: outcome.into(), reason: reason.into(), checks }
|
||||
}
|
||||
|
||||
fn skipped(id: &str, definition: &HttpAssertions, reason: &str) -> RequestResult {
|
||||
RequestResult {
|
||||
request_id: id.into(),
|
||||
outcome: "skipped".into(),
|
||||
reason: reason.into(),
|
||||
checks: definition
|
||||
.checks
|
||||
.iter()
|
||||
.map(|c| CheckResult {
|
||||
assertion_id: c.id.clone(),
|
||||
outcome: "skipped".into(),
|
||||
reason: reason.into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_requests(ctx: &CliContext, id: &str) -> Result<(String, Vec<HttpRequest>), String> {
|
||||
let db = ctx.db();
|
||||
if let Ok(request) = db.get_any_request(id) {
|
||||
return match request {
|
||||
AnyRequest::HttpRequest(r) => Ok((r.workspace_id.clone(), vec![r])),
|
||||
_ => Err("Assertions currently support HTTP requests only".into()),
|
||||
};
|
||||
}
|
||||
let (workspace_id, parent) = if let Ok(folder) = db.get_folder(id) {
|
||||
(folder.workspace_id, Some(folder.id))
|
||||
} else if let Ok(workspace) = db.get_workspace(id) {
|
||||
(workspace.id, None)
|
||||
} else {
|
||||
return Err("Could not resolve request, folder, or workspace".into());
|
||||
};
|
||||
let folders = db.list_folders(&workspace_id).map_err(|e| e.to_string())?;
|
||||
let requests = db.list_http_requests(&workspace_id).map_err(|e| e.to_string())?;
|
||||
// Refuse mixed protocols rather than silently producing a green partial suite.
|
||||
let mut folder_ids = HashSet::new();
|
||||
let ordered = ordered_requests(&folders, &requests, parent.as_deref(), &mut folder_ids)?;
|
||||
let selected = |folder: Option<&String>| {
|
||||
parent.is_none() || folder.is_some_and(|f| folder_ids.contains(f))
|
||||
};
|
||||
if db
|
||||
.list_grpc_requests(&workspace_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.any(|r| selected(r.folder_id.as_ref()))
|
||||
|| db
|
||||
.list_websocket_requests(&workspace_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.any(|r| selected(r.folder_id.as_ref()))
|
||||
{
|
||||
return Err(
|
||||
"Selection includes gRPC or WebSocket requests; choose an HTTP request or folder"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if parent.is_none() && ordered.len() != requests.len() {
|
||||
return Err("Request folder hierarchy is invalid".into());
|
||||
}
|
||||
Ok((workspace_id, ordered))
|
||||
}
|
||||
|
||||
fn ordered_requests(
|
||||
folders: &[Folder],
|
||||
requests: &[HttpRequest],
|
||||
parent: Option<&str>,
|
||||
visited: &mut HashSet<String>,
|
||||
) -> Result<Vec<HttpRequest>, String> {
|
||||
if let Some(id) = parent {
|
||||
if !visited.insert(id.to_owned()) {
|
||||
return Err("Request folder hierarchy contains a cycle".into());
|
||||
}
|
||||
}
|
||||
let mut children: Vec<_> = folders
|
||||
.iter()
|
||||
.filter(|f| f.folder_id.as_deref() == parent)
|
||||
.map(|f| (f.sort_priority, f.created_at, f.id.as_str(), None::<&HttpRequest>))
|
||||
.collect();
|
||||
children.extend(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|r| r.folder_id.as_deref() == parent)
|
||||
.map(|r| (r.sort_priority, r.created_at, r.id.as_str(), Some(r))),
|
||||
);
|
||||
children.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(b.2)));
|
||||
let mut ordered = Vec::new();
|
||||
for (_, _, id, request) in children {
|
||||
if let Some(request) = request {
|
||||
ordered.push(request.clone());
|
||||
} else {
|
||||
ordered.extend(ordered_requests(folders, requests, Some(id), visited)?);
|
||||
}
|
||||
}
|
||||
Ok(ordered)
|
||||
}
|
||||
|
||||
fn write_reports(args: &TestArgs, report: &RunReport) -> Result<(), String> {
|
||||
if let Some(path) = &args.json {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(report).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(path) = &args.junit {
|
||||
std::fs::write(path, junit(report)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xml(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|&c| {
|
||||
matches!(c, '\t' | '\n' | '\r') || c >= ' ' && c != '\u{fffe}' && c != '\u{ffff}'
|
||||
})
|
||||
.collect::<String>()
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
fn junit(report: &RunReport) -> String {
|
||||
let mut cases = Vec::new();
|
||||
for request in &report.requests {
|
||||
for check in &request.checks {
|
||||
cases.push((
|
||||
request.request_id.as_str(),
|
||||
check.assertion_id.as_str(),
|
||||
check.outcome.as_str(),
|
||||
check.reason.as_str(),
|
||||
));
|
||||
}
|
||||
if request.checks.is_empty()
|
||||
|| request.outcome == "error"
|
||||
&& !request.checks.iter().any(|c| c.outcome == "error" || c.outcome == "invalid")
|
||||
{
|
||||
cases.push((
|
||||
request.request_id.as_str(),
|
||||
"request",
|
||||
if request.outcome == "unchecked" { "skipped" } else { &request.outcome },
|
||||
request.reason.as_str(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let count = |outcome| cases.iter().filter(|c| c.2 == outcome).count();
|
||||
let mut out = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuite name=\"Yaak\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\">\n",
|
||||
cases.len(),
|
||||
count("failed"),
|
||||
count("error") + count("invalid"),
|
||||
count("skipped")
|
||||
);
|
||||
for (request, check, outcome, reason) in cases {
|
||||
out.push_str(&format!(
|
||||
" <testcase classname=\"{}\" name=\"{}\">",
|
||||
xml(request),
|
||||
xml(check)
|
||||
));
|
||||
let tag = match outcome {
|
||||
"failed" => Some("failure"),
|
||||
"error" | "invalid" => Some("error"),
|
||||
"skipped" => Some("skipped"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tag) = tag {
|
||||
out.push_str(&format!("<{tag} message=\"{}\"/>", xml(reason)));
|
||||
}
|
||||
out.push_str("</testcase>\n");
|
||||
}
|
||||
out.push_str("</testsuite>\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn sidebar_order_interleaves_folders_and_requests() {
|
||||
let folders =
|
||||
vec![Folder { id: "folder".into(), sort_priority: 2.0, ..Default::default() }];
|
||||
let requests = vec![
|
||||
HttpRequest { id: "last".into(), sort_priority: 3.0, ..Default::default() },
|
||||
HttpRequest {
|
||||
id: "nested".into(),
|
||||
folder_id: Some("folder".into()),
|
||||
..Default::default()
|
||||
},
|
||||
HttpRequest { id: "first".into(), sort_priority: 1.0, ..Default::default() },
|
||||
];
|
||||
let ordered = ordered_requests(&folders, &requests, None, &mut HashSet::new()).unwrap();
|
||||
assert_eq!(
|
||||
ordered.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["first", "nested", "last"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn junit_escapes_attributes_and_counts_all_outcomes() {
|
||||
let report = RunReport {
|
||||
version: 1,
|
||||
exit_code: 1,
|
||||
requests: vec![
|
||||
RequestResult {
|
||||
request_id: "rq_<&\"".into(),
|
||||
outcome: "failed".into(),
|
||||
reason: "Assertions failed".into(),
|
||||
checks: vec![CheckResult {
|
||||
assertion_id: "check".into(),
|
||||
outcome: "failed".into(),
|
||||
reason: "Value did not match".into(),
|
||||
}],
|
||||
},
|
||||
skipped("rq_skip", &HttpAssertions::default(), "Stopped after failure"),
|
||||
],
|
||||
};
|
||||
let xml = junit(&report);
|
||||
assert!(xml.contains("tests=\"2\" failures=\"1\" errors=\"0\" skipped=\"1\""));
|
||||
assert!(xml.contains("classname=\"rq_<&"\""));
|
||||
assert!(xml.contains("<skipped "));
|
||||
}
|
||||
}
|
||||
@@ -129,32 +129,6 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::Test(args) => {
|
||||
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||
match resolve_send_execution_context(
|
||||
&context,
|
||||
&args.id,
|
||||
environment.as_deref(),
|
||||
cookie_jar.as_deref(),
|
||||
) {
|
||||
Ok(execution_context) => {
|
||||
context.init_plugins(execution_context).await;
|
||||
let exit_code = commands::test::run(
|
||||
&context,
|
||||
args,
|
||||
environment.as_deref(),
|
||||
cookie_jar.as_deref(),
|
||||
)
|
||||
.await;
|
||||
context.shutdown().await;
|
||||
exit_code
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}");
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::CookieJar(args) => {
|
||||
let context = CliContext::new(data_dir.clone(), app_id);
|
||||
let exit_code = commands::cookie_jar::run(&context, args);
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
mod common;
|
||||
|
||||
use common::http_server::TestHttpServer;
|
||||
use common::{cli_cmd, query_manager, seed_workspace};
|
||||
use predicates::str::contains;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::models::{HttpAssertion, HttpAssertions, HttpRequest};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
fn request(id: &str, url: &str, expected: &str) -> HttpRequest {
|
||||
HttpRequest {
|
||||
id: id.into(),
|
||||
workspace_id: "wk_test".into(),
|
||||
url: url.into(),
|
||||
name: "SECRET_REQUEST_NAME".into(),
|
||||
assertions: HttpAssertions {
|
||||
version: 1,
|
||||
checks: vec![HttpAssertion {
|
||||
id: "assertion".into(),
|
||||
selector: "$.token".into(),
|
||||
expected: expected.into(),
|
||||
..Default::default()
|
||||
}],
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn save(dir: &std::path::Path, request: &HttpRequest) {
|
||||
query_manager(dir).with_tx(|tx| tx.upsert_http_request(request, &UpdateSource::Sync)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_assertions_persists_snapshots_and_writes_safe_reports() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
seed_workspace(dir.path(), "wk_test");
|
||||
let server = TestHttpServer::spawn_ok(r#"{"token":"SECRET_TOKEN"}"#);
|
||||
let mut req = request("rq_test", &server.url, "SECRET_TOKEN");
|
||||
save(dir.path(), &req);
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "rq_test", "--environment", "missing"])
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(contains("Selected environment"));
|
||||
assert!(
|
||||
query_manager(dir.path())
|
||||
.connect()
|
||||
.list_http_responses("wk_test", None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let json = dir.path().join("results.json");
|
||||
let junit = dir.path().join("results.xml");
|
||||
let output = cli_cmd(dir.path())
|
||||
.args(["test", "rq_test", "--json"])
|
||||
.arg(&json)
|
||||
.arg("--junit")
|
||||
.arg(&junit)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("1 passed, 0 failed"))
|
||||
.get_output()
|
||||
.clone();
|
||||
for text in [
|
||||
String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
std::fs::read_to_string(&json).unwrap(),
|
||||
std::fs::read_to_string(&junit).unwrap(),
|
||||
] {
|
||||
assert!(!text.contains("SECRET"));
|
||||
assert!(!text.contains(&server.url));
|
||||
assert!(!text.contains("$.token"));
|
||||
}
|
||||
let report: serde_json::Value = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap();
|
||||
assert_eq!(report["requests"][0]["checks"][0]["outcome"], "passed");
|
||||
let manager = query_manager(dir.path());
|
||||
let history = manager.connect().list_http_responses("wk_test", None).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
let original_id = history[0].id.clone();
|
||||
assert_eq!(history[0].assertion_results.as_ref().unwrap().definition, req.assertions);
|
||||
req.assertions.checks[0].expected = "DIFFERENT_SECRET".into();
|
||||
save(dir.path(), &req);
|
||||
assert_eq!(
|
||||
manager
|
||||
.connect()
|
||||
.get_http_response(&original_id)
|
||||
.unwrap()
|
||||
.assertion_results
|
||||
.unwrap()
|
||||
.definition
|
||||
.checks[0]
|
||||
.expected,
|
||||
"SECRET_TOKEN"
|
||||
);
|
||||
cli_cmd(dir.path()).args(["test", "rq_test"]).assert().code(1).stdout(contains("1 failed"));
|
||||
// Ordinary send still prints the response body and succeeds even when a check fails.
|
||||
cli_cmd(dir.path())
|
||||
.args(["send", "rq_test"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("SECRET_TOKEN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_suite_and_empty_suite_do_not_send_and_fail_fast_records_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
seed_workspace(dir.path(), "wk_test");
|
||||
let server = TestHttpServer::spawn_ok(r#"{"token":"actual"}"#);
|
||||
let mut first = request("rq_a", &server.url, "different");
|
||||
first.sort_priority = 0.0;
|
||||
let mut second = request("rq_b", &server.url, "actual");
|
||||
second.sort_priority = 1.0;
|
||||
second.assertions.checks[0].selector = "$[".into();
|
||||
save(dir.path(), &first);
|
||||
save(dir.path(), &second);
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "wk_test"])
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(contains("valid JSONPath"));
|
||||
assert!(
|
||||
query_manager(dir.path())
|
||||
.connect()
|
||||
.list_http_responses("wk_test", None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
second.assertions.checks[0].selector = "token".into();
|
||||
save(dir.path(), &second);
|
||||
let json = dir.path().join("results.json");
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "wk_test", "--fail-fast", "--json"])
|
||||
.arg(&json)
|
||||
.assert()
|
||||
.code(1)
|
||||
.stdout(contains("1 failed, 0 errors, 0 unchecked, 1 skipped"));
|
||||
let report: serde_json::Value = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap();
|
||||
assert_eq!(report["requests"][1]["requestId"], "rq_b");
|
||||
assert_eq!(report["requests"][1]["outcome"], "skipped");
|
||||
assert_eq!(
|
||||
query_manager(dir.path()).connect().list_http_responses("wk_test", None).unwrap().len(),
|
||||
1
|
||||
);
|
||||
first.assertions.checks.clear();
|
||||
save(dir.path(), &first);
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "rq_a"])
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(contains("no enabled assertions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_errors_are_distinct_from_failed_comparisons() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
seed_workspace(dir.path(), "wk_test");
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
drop(listener);
|
||||
save(dir.path(), &request("rq_error", &url, "SECRET"));
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "rq_error"])
|
||||
.assert()
|
||||
.code(1)
|
||||
.stdout(contains("0 failed, 1 errors"));
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Strings intentionally retain unknown operators/types so they fail validation, rather than
|
||||
* disappearing during import or being treated as an empty, passing collection.
|
||||
*/
|
||||
export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
|
||||
|
||||
export type HttpAssertions = { version: number, checks: Array<HttpAssertion>, };
|
||||
+108
-26
@@ -1,53 +1,135 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { HttpAssertions } from "./gen_assertions";
|
||||
|
||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: CookieDomain;
|
||||
expires: CookieExpires;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: CookieSameSite | null;
|
||||
};
|
||||
|
||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
||||
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
||||
export type HttpResponseEventData =
|
||||
| {
|
||||
type: "setting";
|
||||
name: string;
|
||||
value: string;
|
||||
source_model?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
}
|
||||
| { type: "info"; message: string }
|
||||
| {
|
||||
type: "redirect";
|
||||
url: string;
|
||||
status: number;
|
||||
behavior: string;
|
||||
dropped_body: boolean;
|
||||
dropped_headers: Array<string>;
|
||||
}
|
||||
| {
|
||||
type: "send_url";
|
||||
method: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
| { type: "receive_url"; version: string; status: string }
|
||||
| { type: "header_up"; name: string; value: string }
|
||||
| { type: "header_down"; name: string; value: string }
|
||||
| { type: "chunk_sent"; bytes: number }
|
||||
| { type: "chunk_received"; bytes: number }
|
||||
| {
|
||||
type: "dns_resolved";
|
||||
hostname: string;
|
||||
addresses: Array<string>;
|
||||
duration: bigint;
|
||||
overridden: boolean;
|
||||
};
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
/**
|
||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, httpVersion: HttpVersion, };
|
||||
export type HttpSendSettings = {
|
||||
validateCertificates: boolean;
|
||||
followRedirects: boolean;
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number;
|
||||
sendCookies: boolean;
|
||||
storeCookies: boolean;
|
||||
httpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
@@ -127,21 +127,6 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
||||
r.elapsed_headers = elapsed;
|
||||
}
|
||||
r.error = Some(error);
|
||||
r.assertion_results = (!unrendered_request.assertions.is_empty()).then(|| {
|
||||
yaak::assertions::evaluate(
|
||||
&unrendered_request.assertions,
|
||||
yaak::assertions::Response {
|
||||
status: r.status,
|
||||
headers: &[],
|
||||
body: yaak::assertions::Body::Unavailable,
|
||||
completion: if *cancelled_rx.borrow() {
|
||||
yaak::assertions::Completion::Canceled
|
||||
} else {
|
||||
yaak::assertions::Completion::Error
|
||||
},
|
||||
},
|
||||
)
|
||||
});
|
||||
});
|
||||
// The send failed, so whatever body exists is the partial one
|
||||
// already on disk under the response's id.
|
||||
|
||||
@@ -838,40 +838,6 @@ async fn cmd_default_headers<R: Runtime>(
|
||||
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_validate_http_assertions<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdValidateHttpAssertionsReq,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
Ok(yaak::assertions::validation_errors(&req.assertions))
|
||||
}
|
||||
|
||||
async fn cmd_http_response_json_children<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpResponseJsonChildrenReq,
|
||||
) -> Result<yaak::jsonpath::JsonPathChildren> {
|
||||
let queries = ctx.query_manager().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let store = yaak::response_body::FileResponseBodyStore::new(&queries);
|
||||
yaak::response_body::json_path_children(&store, &req.response_id, &req.parent)
|
||||
.map_err(crate::error::Error::GenericError)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::Error::GenericError(e.to_string()))?
|
||||
}
|
||||
|
||||
async fn cmd_preview_http_assertions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPreviewHttpAssertionsReq,
|
||||
) -> Result<yaak::assertions::AssertionReport> {
|
||||
let queries = ctx.query_manager().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
yaak::response_body::preview_assertions(&queries, &req.response_id, &req.assertions)
|
||||
.map_err(crate::error::Error::GenericError)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::Error::GenericError(e.to_string()))?
|
||||
}
|
||||
|
||||
async fn models_upsert<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertReq) -> Result<String> {
|
||||
Ok(yaak_commands::models::models_upsert(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub(crate) async fn cmd_sync_calculate<R: Runtime>(
|
||||
let db = app_handle.db();
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
let db_candidates = get_db_candidates(&db, &version, workspace_id, sync_dir)?;
|
||||
let fs_candidates = get_fs_candidates(sync_dir)?
|
||||
let fs_candidates = get_fs_candidates(sync_dir, &db_candidates)?
|
||||
.into_iter()
|
||||
// Only keep items in the same workspace
|
||||
.filter(|fs| fs.model.workspace_id() == workspace_id)
|
||||
@@ -39,7 +39,7 @@ pub(crate) async fn cmd_sync_calculate<R: Runtime>(
|
||||
|
||||
pub(crate) async fn cmd_sync_calculate_fs(dir: &Path) -> Result<Vec<SyncOp>> {
|
||||
let db_candidates = Vec::new();
|
||||
let fs_candidates = get_fs_candidates(dir)?;
|
||||
let fs_candidates = get_fs_candidates(dir, &db_candidates)?;
|
||||
Ok(compute_sync_ops(db_candidates, fs_candidates))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
yaak-jsonpath = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
ts-rs = { workspace = true }
|
||||
yaak-git = { workspace = true }
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
export type AssertionReport = { definition: HttpAssertions, results: Array<AssertionResult>, error: string | null, };
|
||||
|
||||
export type AssertionResult = { assertionId: string, outcome: string, reason: string, actual: JsonValue | null, };
|
||||
|
||||
/**
|
||||
* Strings intentionally retain unknown operators/types so they fail validation, rather than
|
||||
* disappearing during import or being treated as an empty, passing collection.
|
||||
*/
|
||||
export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
|
||||
|
||||
export type HttpAssertions = { version: number, checks: Array<HttpAssertion>, };
|
||||
@@ -1,13 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type JsonPathChild = {
|
||||
/**
|
||||
* A single, escaped child accessor (e.g. .user, [0], or ["unusual.key"]).
|
||||
*/
|
||||
selector: string, label: string, kind: string, };
|
||||
|
||||
export type JsonPathChildren = { children: Array<JsonPathChild>,
|
||||
/**
|
||||
* Discovery is sampled/bounded, never an assertion that other keys don't exist.
|
||||
*/
|
||||
truncated: boolean, };
|
||||
+462
-63
@@ -1,123 +1,522 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AssertionReport, HttpAssertions } from "./gen_assertions";
|
||||
|
||||
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | ImportSource | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
||||
export type AnyModel =
|
||||
| CookieJar
|
||||
| Environment
|
||||
| Folder
|
||||
| GraphQlIntrospection
|
||||
| GrpcConnection
|
||||
| GrpcEvent
|
||||
| GrpcRequest
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
| SyncState
|
||||
| WebsocketConnection
|
||||
| WebsocketEvent
|
||||
| WebsocketRequest
|
||||
| Workspace
|
||||
| WorkspaceMeta;
|
||||
|
||||
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
||||
export type ClientCertificate = {
|
||||
host: string;
|
||||
port: number | null;
|
||||
crtFile: string | null;
|
||||
keyFile: string | null;
|
||||
pfxFile: string | null;
|
||||
passphrase: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: CookieDomain;
|
||||
expires: CookieExpires;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: CookieSameSite | null;
|
||||
};
|
||||
|
||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
||||
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||
|
||||
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||
export type CookieJar = {
|
||||
model: "cookie_jar";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
cookies: Array<Cookie>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||
export type DnsOverride = {
|
||||
hostname: string;
|
||||
ipv4: Array<string>;
|
||||
ipv6: Array<string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||
|
||||
export type EncryptedKey = { encryptedKey: string, };
|
||||
export type EncryptedKey = { encryptedKey: string };
|
||||
|
||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||
export type Environment = {
|
||||
model: "environment";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
parentModel: string;
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>;
|
||||
color: string | null;
|
||||
sortPriority: number;
|
||||
};
|
||||
|
||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type Folder = {
|
||||
model: "folder";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
||||
export type GraphQlIntrospection = {
|
||||
model: "graphql_introspection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
|
||||
export type GrpcConnection = {
|
||||
model: "grpc_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
method: string;
|
||||
service: string;
|
||||
status: number;
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
|
||||
export type GrpcEvent = {
|
||||
model: "grpc_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
eventType: GrpcEventType;
|
||||
metadata: { [key in string]?: string };
|
||||
status: number | null;
|
||||
};
|
||||
|
||||
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
|
||||
export type GrpcEventType =
|
||||
| "info"
|
||||
| "error"
|
||||
| "client_message"
|
||||
| "server_message"
|
||||
| "connection_start"
|
||||
| "connection_end";
|
||||
|
||||
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type GrpcRequest = {
|
||||
model: "grpc_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authenticationType: string | null;
|
||||
authentication: Record<string, any>;
|
||||
description: string;
|
||||
message: string;
|
||||
metadata: Array<HttpRequestHeader>;
|
||||
method: string | null;
|
||||
name: string;
|
||||
service: string | null;
|
||||
sortPriority: number;
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, assertionResults: AssertionReport | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
export type HttpResponse = {
|
||||
model: "http_response";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
elapsedHeaders: number;
|
||||
elapsedDns: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
remoteAddr: string | null;
|
||||
requestContentLength: number | null;
|
||||
requestHeaders: Array<HttpResponseHeader>;
|
||||
status: number;
|
||||
statusReason: string | null;
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||
export type HttpResponseEvent = {
|
||||
model: "http_response_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
responseId: string;
|
||||
event: HttpResponseEventData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
||||
export type HttpResponseEventData =
|
||||
| {
|
||||
type: "setting";
|
||||
name: string;
|
||||
value: string;
|
||||
source_model?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
}
|
||||
| { type: "info"; message: string }
|
||||
| {
|
||||
type: "redirect";
|
||||
url: string;
|
||||
status: number;
|
||||
behavior: string;
|
||||
dropped_body: boolean;
|
||||
dropped_headers: Array<string>;
|
||||
}
|
||||
| {
|
||||
type: "send_url";
|
||||
method: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
| { type: "receive_url"; version: string; status: string }
|
||||
| { type: "header_up"; name: string; value: string }
|
||||
| { type: "header_down"; name: string; value: string }
|
||||
| { type: "chunk_sent"; bytes: number }
|
||||
| { type: "chunk_received"; bytes: number }
|
||||
| {
|
||||
type: "dns_resolved";
|
||||
hostname: string;
|
||||
addresses: Array<string>;
|
||||
duration: bigint;
|
||||
overridden: boolean;
|
||||
};
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type ImportSource = { model: "import_source", id: string, createdAt: string, updatedAt: string, workspaceId: string, importer: string, origin: string, originLabel: string, lastImportedAt: string, };
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
||||
export type KeyValue = {
|
||||
model: "key_value";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
key: string;
|
||||
namespace: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
|
||||
export type Plugin = {
|
||||
model: "plugin";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
checkedAt: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
source: PluginSource;
|
||||
};
|
||||
|
||||
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||
|
||||
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
|
||||
export type ProxySetting =
|
||||
| {
|
||||
type: "enabled";
|
||||
http: string;
|
||||
https: string;
|
||||
auth: ProxySettingAuth | null;
|
||||
bypass: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
| { type: "disabled" };
|
||||
|
||||
export type ProxySettingAuth = { user: string, password: string, };
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, promptFeedback: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
appearance: string;
|
||||
clientCertificates: Array<ClientCertificate>;
|
||||
coloredMethods: boolean;
|
||||
editorFont: string | null;
|
||||
editorFontSize: number;
|
||||
editorKeymap: EditorKeymap;
|
||||
editorSoftWrap: boolean;
|
||||
hideWindowControls: boolean;
|
||||
useNativeTitlebar: boolean;
|
||||
interfaceFont: string | null;
|
||||
interfaceFontSize: number;
|
||||
interfaceScale: number;
|
||||
openWorkspaceNewWindow: boolean | null;
|
||||
proxy: ProxySetting | null;
|
||||
themeDark: string;
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
hotkeys: { [key in string]?: Array<string> };
|
||||
};
|
||||
|
||||
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
|
||||
export type SyncModel =
|
||||
| ({ type: "workspace" } & Workspace)
|
||||
| ({ type: "environment" } & Environment)
|
||||
| ({ type: "folder" } & Folder)
|
||||
| ({ type: "http_request" } & HttpRequest)
|
||||
| ({ type: "grpc_request" } & GrpcRequest)
|
||||
| ({ type: "websocket_request" } & WebsocketRequest);
|
||||
|
||||
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
||||
export type SyncState = {
|
||||
model: "sync_state";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
flushedAt: string;
|
||||
modelId: string;
|
||||
checksum: string;
|
||||
relPath: string;
|
||||
syncDir: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
|
||||
export type WebsocketConnection = {
|
||||
model: "websocket_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
|
||||
export type WebsocketEvent = {
|
||||
model: "websocket_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
isServer: boolean;
|
||||
message: Array<number>;
|
||||
messageType: WebsocketEventType;
|
||||
};
|
||||
|
||||
export type WebsocketEventType = "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||
export type WebsocketEventType =
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
message: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, settingHttpVersion: HttpVersion, };
|
||||
export type Workspace = {
|
||||
model: "workspace";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
encryptionKeyChallenge: string | null;
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||
export type WorkspaceMeta = {
|
||||
model: "workspace_meta";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
encryptionKey: EncryptedKey | null;
|
||||
settingSyncDir: string | null;
|
||||
};
|
||||
+1
-9
@@ -1,10 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } from "./gen_api";
|
||||
import type { AssertionReport, HttpAssertions } from "./gen_assertions";
|
||||
import type { CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose } from "./gen_events";
|
||||
import type { BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote, GitStatusSummary, GitWorktreeStatus, PullResult, PushResult } from "./gen_git";
|
||||
import type { ServiceDefinition } from "./gen_grpc";
|
||||
import type { JsonPathChildren } from "./gen_jsonpath";
|
||||
import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models";
|
||||
import type { PluginMetadata } from "./gen_search";
|
||||
import type { SyncOp } from "./gen_sync";
|
||||
@@ -149,8 +147,6 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, };
|
||||
|
||||
export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
|
||||
|
||||
export type CmdHttpResponseJsonChildrenReq = { responseId: string, parent: string, };
|
||||
|
||||
export type CmdImportDataReq = { filePaths: Array<string>, urls: Array<string>, destination: ImportDestination, };
|
||||
|
||||
export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, };
|
||||
@@ -179,8 +175,6 @@ export type CmdPluginsUpdateAllReq = Record<string, never>;
|
||||
|
||||
export type CmdPluginsUpdatesReq = Record<string, never>;
|
||||
|
||||
export type CmdPreviewHttpAssertionsReq = { responseId: string, assertions: HttpAssertions, };
|
||||
|
||||
export type CmdReloadPluginsReq = Record<string, never>;
|
||||
|
||||
export type CmdRenderTemplateReq = { template: string, workspaceId: string, environmentId: string | null, purpose: RenderPurpose | null, ignoreError: boolean | null, };
|
||||
@@ -217,8 +211,6 @@ export type CmdTemplateFunctionSummariesReq = Record<string, never>;
|
||||
|
||||
export type CmdTemplateTokensToStringReq = { tokens: Tokens, };
|
||||
|
||||
export type CmdValidateHttpAssertionsReq = { assertions: HttpAssertions, };
|
||||
|
||||
export type CmdWebsocketRequestActionsReq = Record<string, never>;
|
||||
|
||||
export type CmdWorkspaceActionsReq = Record<string, never>;
|
||||
@@ -264,6 +256,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, };
|
||||
|
||||
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
|
||||
|
||||
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_validate_http_assertions: [CmdValidateHttpAssertionsReq, { [key in string]?: string }], cmd_preview_http_assertions: [CmdPreviewHttpAssertionsReq, AssertionReport], cmd_http_response_json_children: [CmdHttpResponseJsonChildrenReq, JsonPathChildren], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branchLine truncated
|
||||
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfoLine truncated
|
||||
|
||||
export type WatchResult = { unlistenEvent: string, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
|
||||
@@ -41,29 +41,6 @@ use yaak_templates::Tokens;
|
||||
|
||||
// -- Response types that belong to the schema rather than to an engine crate --
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdValidateHttpAssertionsReq {
|
||||
pub assertions: yaak_models::models::HttpAssertions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdPreviewHttpAssertionsReq {
|
||||
pub response_id: String,
|
||||
pub assertions: yaak_models::models::HttpAssertions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdHttpResponseJsonChildrenReq {
|
||||
pub response_id: String,
|
||||
pub parent: String,
|
||||
}
|
||||
|
||||
/// What the frontend learns about the host it is talking to.
|
||||
#[derive(Debug, Default, Serialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -949,9 +926,6 @@ macro_rules! with_commands {
|
||||
($callback:ident) => {
|
||||
$callback! {
|
||||
cmd_metadata(CmdMetadataReq) -> AppMetaData,
|
||||
cmd_validate_http_assertions(CmdValidateHttpAssertionsReq) -> HashMap<String, String>,
|
||||
cmd_preview_http_assertions(CmdPreviewHttpAssertionsReq) -> yaak_models::models::AssertionReport,
|
||||
cmd_http_response_json_children(CmdHttpResponseJsonChildrenReq) -> yaak_jsonpath::JsonPathChildren,
|
||||
cmd_template_tokens_to_string(CmdTemplateTokensToStringReq) -> String,
|
||||
cmd_render_template(CmdRenderTemplateReq) -> String,
|
||||
cmd_send_feedback(CmdSendFeedbackReq) -> (),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-assertions"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
yaak-jsonpath = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
ts-rs = { workspace = true, features = ["serde-json-impl"] }
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
export type AssertionReport = { definition: HttpAssertions, results: Array<AssertionResult>, error: string | null, };
|
||||
|
||||
export type AssertionResult = { assertionId: string, outcome: string, reason: string, actual: JsonValue | null, };
|
||||
|
||||
/**
|
||||
* Strings intentionally retain unknown operators/types so they fail validation, rather than
|
||||
* disappearing during import or being treated as an empty, passing collection.
|
||||
*/
|
||||
export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
|
||||
|
||||
export type HttpAssertions = { version: number, checks: Array<HttpAssertion>, };
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
|
||||
@@ -1,540 +0,0 @@
|
||||
//! Pure response assertions, shared by the desktop, CLI, and browser worker.
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashSet;
|
||||
use ts_rs::TS;
|
||||
pub use yaak_jsonpath::MAX_BODY_BYTES;
|
||||
use yaak_jsonpath::{JsonPath, normalized_path};
|
||||
|
||||
pub const MAX_CHECKS: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_assertions.ts")]
|
||||
pub struct HttpAssertions {
|
||||
pub version: u32,
|
||||
pub checks: Vec<HttpAssertion>,
|
||||
}
|
||||
|
||||
impl Default for HttpAssertions {
|
||||
fn default() -> Self {
|
||||
Self { version: 1, checks: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpAssertions {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.version == 1 && self.checks.is_empty()
|
||||
}
|
||||
pub fn needs_body(&self) -> bool {
|
||||
self.checks.iter().any(|c| c.enabled && c.target == "json")
|
||||
}
|
||||
}
|
||||
|
||||
/// Strings intentionally retain unknown operators/types so they fail validation, rather than
|
||||
/// disappearing during import or being treated as an empty, passing collection.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_assertions.ts")]
|
||||
pub struct HttpAssertion {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
pub target: String,
|
||||
pub selector: String,
|
||||
pub operator: String,
|
||||
pub expected: String,
|
||||
pub expected_type: String,
|
||||
}
|
||||
|
||||
impl Default for HttpAssertion {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
enabled: true,
|
||||
target: "json".into(),
|
||||
selector: String::new(),
|
||||
operator: "equals".into(),
|
||||
expected: String::new(),
|
||||
expected_type: "text".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_assertions.ts")]
|
||||
pub struct AssertionResult {
|
||||
pub assertion_id: String,
|
||||
pub outcome: String,
|
||||
pub reason: String,
|
||||
pub actual: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_assertions.ts")]
|
||||
pub struct AssertionReport {
|
||||
pub definition: HttpAssertions,
|
||||
pub results: Vec<AssertionResult>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Completion {
|
||||
Complete,
|
||||
Error,
|
||||
Canceled,
|
||||
}
|
||||
pub enum Body<'a> {
|
||||
Bytes(&'a [u8]),
|
||||
TooLarge,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub struct Response<'a> {
|
||||
pub status: i32,
|
||||
pub headers: &'a [(String, String)],
|
||||
pub body: Body<'a>,
|
||||
pub completion: Completion,
|
||||
}
|
||||
|
||||
fn expected_value(check: &HttpAssertion) -> Result<Value, &'static str> {
|
||||
match check.expected_type.as_str() {
|
||||
"text" => Ok(Value::String(check.expected.clone())),
|
||||
"number" => match serde_json::from_str::<Value>(check.expected.trim()) {
|
||||
Ok(v) if v.is_number() => Ok(v),
|
||||
_ => Err("Expected value must be a number"),
|
||||
},
|
||||
"boolean" => match check.expected.trim() {
|
||||
"true" => Ok(Value::Bool(true)),
|
||||
"false" => Ok(Value::Bool(false)),
|
||||
_ => Err("Expected value must be true or false"),
|
||||
},
|
||||
"null" => Ok(Value::Null),
|
||||
_ => Err("Unsupported expected value type"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_check(check: &HttpAssertion) -> Result<(), &'static str> {
|
||||
if check.id.is_empty() {
|
||||
return Err("Assertion ID is required");
|
||||
}
|
||||
if check.selector.len() > 1024 || check.expected.len() > 16 * 1024 {
|
||||
return Err("Assertion input is too long");
|
||||
}
|
||||
match check.target.as_str() {
|
||||
"json" => {
|
||||
if check.selector.trim().is_empty()
|
||||
|| JsonPath::parse(&normalized_path(&check.selector)).is_err()
|
||||
{
|
||||
return Err("Enter a valid JSONPath, for example $.user.id");
|
||||
}
|
||||
}
|
||||
"header" if !check.selector.trim().is_empty() => {}
|
||||
"status" => {}
|
||||
"header" => return Err("Header name is required"),
|
||||
_ => return Err("Unsupported assertion target"),
|
||||
}
|
||||
if check.operator == "exists" {
|
||||
return Ok(());
|
||||
}
|
||||
if !matches!(
|
||||
check.operator.as_str(),
|
||||
"equals" | "not_equals" | "greater_than" | "less_than" | "contains"
|
||||
) {
|
||||
return Err("Unsupported comparison");
|
||||
}
|
||||
let expected = expected_value(check)?;
|
||||
if matches!(check.operator.as_str(), "greater_than" | "less_than") && !expected.is_number() {
|
||||
return Err("Numeric comparisons require a number");
|
||||
}
|
||||
if check.operator == "contains" && !expected.is_string() {
|
||||
return Err("Contains requires a text value");
|
||||
}
|
||||
if check.target == "status"
|
||||
&& (!expected.is_number()
|
||||
|| !matches!(
|
||||
check.operator.as_str(),
|
||||
"equals" | "not_equals" | "greater_than" | "less_than"
|
||||
))
|
||||
{
|
||||
return Err("Status comparisons require a number");
|
||||
}
|
||||
if check.target == "header" && !expected.is_string() {
|
||||
return Err("Header comparisons require text");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate(definition: &HttpAssertions) -> Result<(), String> {
|
||||
if definition.version != 1 {
|
||||
return Err("Unsupported assertion version".into());
|
||||
}
|
||||
if definition.checks.len() > MAX_CHECKS {
|
||||
return Err("At most 100 assertions are supported per request".into());
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
for check in &definition.checks {
|
||||
if !ids.insert(&check.id) {
|
||||
return Err("Assertion IDs must be unique".into());
|
||||
}
|
||||
if check.enabled {
|
||||
validate_check(check).map_err(str::to_owned)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validation_errors(definition: &HttpAssertions) -> std::collections::HashMap<String, String> {
|
||||
let mut errors = std::collections::HashMap::new();
|
||||
if definition.version != 1 || definition.checks.len() > MAX_CHECKS {
|
||||
errors.insert(String::new(), validate(definition).unwrap_err());
|
||||
return errors;
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
for check in &definition.checks {
|
||||
if !ids.insert(&check.id) {
|
||||
errors.insert(check.id.clone(), "Assertion IDs must be unique".into());
|
||||
}
|
||||
if check.enabled
|
||||
&& let Err(message) = validate_check(check)
|
||||
{
|
||||
errors.insert(check.id.clone(), message.into());
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
fn numeric_cmp(a: &Value, b: &Value) -> Option<Ordering> {
|
||||
if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
|
||||
return Some(a.cmp(&b));
|
||||
}
|
||||
if let (Some(a), Some(b)) = (a.as_u64(), b.as_u64()) {
|
||||
return Some(a.cmp(&b));
|
||||
}
|
||||
if a.as_i64().is_some_and(|n| n < 0) && b.as_u64().is_some() {
|
||||
return Some(Ordering::Less);
|
||||
}
|
||||
if b.as_i64().is_some_and(|n| n < 0) && a.as_u64().is_some() {
|
||||
return Some(Ordering::Greater);
|
||||
}
|
||||
for value in [a, b] {
|
||||
if value.as_u64().is_some_and(|n| n > 9_007_199_254_740_992)
|
||||
|| value.as_i64().is_some_and(|n| n < -9_007_199_254_740_992)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let (a, b) = (a.as_f64()?, b.as_f64()?);
|
||||
// Avoid rounding distinct large integers into the same floating-point value.
|
||||
if a.abs() > 9_007_199_254_740_992.0 || b.abs() > 9_007_199_254_740_992.0 {
|
||||
return None;
|
||||
}
|
||||
a.partial_cmp(&b)
|
||||
}
|
||||
|
||||
fn compare(actual: &Value, expected: &Value, op: &str) -> Result<bool, &'static str> {
|
||||
if op == "contains" {
|
||||
return actual
|
||||
.as_str()
|
||||
.zip(expected.as_str())
|
||||
.map(|(a, b)| a.contains(b))
|
||||
.ok_or("Value is not text");
|
||||
}
|
||||
if actual.is_number() && expected.is_number() {
|
||||
let cmp = numeric_cmp(actual, expected)
|
||||
.ok_or("Number is outside the supported comparison range")?;
|
||||
return Ok(match op {
|
||||
"equals" => cmp.is_eq(),
|
||||
"not_equals" => !cmp.is_eq(),
|
||||
"greater_than" => cmp.is_gt(),
|
||||
"less_than" => cmp.is_lt(),
|
||||
_ => false,
|
||||
});
|
||||
}
|
||||
if matches!(op, "greater_than" | "less_than") {
|
||||
return Err("Value is not a number");
|
||||
}
|
||||
if !(actual.is_string() && expected.is_string()
|
||||
|| actual.is_boolean() && expected.is_boolean()
|
||||
|| actual.is_null() && expected.is_null())
|
||||
{
|
||||
return Err("Value has a different type");
|
||||
}
|
||||
Ok(if op == "not_equals" { actual != expected } else { actual == expected })
|
||||
}
|
||||
|
||||
pub fn evaluate(definition: &HttpAssertions, response: Response<'_>) -> AssertionReport {
|
||||
let error = validate(definition).err();
|
||||
if definition.checks.len() > MAX_CHECKS {
|
||||
return AssertionReport { definition: definition.clone(), results: Vec::new(), error };
|
||||
}
|
||||
let json = if error.is_none()
|
||||
&& matches!(response.completion, Completion::Complete)
|
||||
&& definition.needs_body()
|
||||
{
|
||||
match response.body {
|
||||
Body::Bytes(bytes) if bytes.len() <= MAX_BODY_BYTES => {
|
||||
serde_json::from_slice::<Value>(bytes)
|
||||
.map_err(|_| "Response body is not valid UTF-8 JSON")
|
||||
}
|
||||
Body::TooLarge | Body::Bytes(_) => {
|
||||
Err("Response body exceeds the 10 MiB assertion limit")
|
||||
}
|
||||
Body::Unavailable => Err("Response body is unavailable"),
|
||||
}
|
||||
} else {
|
||||
Ok(Value::Null)
|
||||
};
|
||||
let results = definition
|
||||
.checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
let result = |outcome: &str, reason: &str, actual: Option<Value>| AssertionResult {
|
||||
assertion_id: check.id.clone(),
|
||||
outcome: outcome.into(),
|
||||
reason: reason.into(),
|
||||
actual,
|
||||
};
|
||||
if !check.enabled {
|
||||
return result("skipped", "Disabled", None);
|
||||
}
|
||||
if let Some(error) = &error {
|
||||
return result("invalid", error, None);
|
||||
}
|
||||
match response.completion {
|
||||
Completion::Canceled => return result("error", "Request canceled", None),
|
||||
Completion::Error => {
|
||||
return result("error", "Request did not complete successfully", None);
|
||||
}
|
||||
Completion::Complete => {}
|
||||
}
|
||||
let status = Value::from(response.status);
|
||||
let header_values: Vec<Value> = if check.target == "header" {
|
||||
response
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case(check.selector.trim()))
|
||||
.map(|(_, value)| Value::String(value.clone()))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// Borrow JSON matches: recursive selectors can select overlapping subtrees.
|
||||
// Cloning every selected subtree would multiply memory well beyond the body limit.
|
||||
let values: Result<Vec<&Value>, &str> = match check.target.as_str() {
|
||||
"status" => Ok(vec![&status]),
|
||||
"header" => Ok(header_values.iter().collect()),
|
||||
"json" => json.as_ref().map_err(|e| *e).map(|body| {
|
||||
JsonPath::parse(&normalized_path(&check.selector))
|
||||
.expect("validated path")
|
||||
.query(body)
|
||||
.all()
|
||||
}),
|
||||
_ => unreachable!("validated target"),
|
||||
};
|
||||
let values = match values {
|
||||
Ok(values) => values,
|
||||
Err(message) => return result("error", message, None),
|
||||
};
|
||||
if values.is_empty() {
|
||||
return result("failed", "Value was not found", None);
|
||||
}
|
||||
if check.operator == "exists" {
|
||||
return result("passed", "Value exists", None);
|
||||
}
|
||||
if check.target == "json" && values.len() != 1 {
|
||||
return result("error", "JSONPath must select one value for this comparison", None);
|
||||
}
|
||||
let expected = expected_value(check).expect("validated expected value");
|
||||
let mut reason = "Value did not match";
|
||||
let mut all_different = true;
|
||||
for actual in &values {
|
||||
match compare(actual, &expected, &check.operator) {
|
||||
Ok(true) if check.operator != "not_equals" => {
|
||||
return result("passed", "Comparison passed", None);
|
||||
}
|
||||
Ok(true) => {}
|
||||
Ok(false) => all_different = false,
|
||||
Err(message) => {
|
||||
reason = message;
|
||||
all_different = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if check.operator == "not_equals" && all_different {
|
||||
return result("passed", "Comparison passed", None);
|
||||
}
|
||||
// Details are local to the response; CLI reports deliberately omit actual values.
|
||||
let actual = values
|
||||
.first()
|
||||
.filter(|v| {
|
||||
v.is_null()
|
||||
|| v.is_boolean()
|
||||
|| v.is_number()
|
||||
|| v.as_str().is_some_and(|s| s.len() <= 1024)
|
||||
})
|
||||
.map(|v| (*v).clone());
|
||||
result("failed", reason, actual)
|
||||
})
|
||||
.collect();
|
||||
AssertionReport { definition: definition.clone(), results, error }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn check(path: &str, op: &str, expected: &str, kind: &str) -> HttpAssertion {
|
||||
HttpAssertion {
|
||||
id: "check".into(),
|
||||
selector: path.into(),
|
||||
operator: op.into(),
|
||||
expected: expected.into(),
|
||||
expected_type: kind.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn run(check: HttpAssertion, bytes: &[u8]) -> AssertionResult {
|
||||
evaluate(
|
||||
&HttpAssertions { checks: vec![check], ..Default::default() },
|
||||
Response {
|
||||
status: 200,
|
||||
headers: &[],
|
||||
body: Body::Bytes(bytes),
|
||||
completion: Completion::Complete,
|
||||
},
|
||||
)
|
||||
.results
|
||||
.remove(0)
|
||||
}
|
||||
#[test]
|
||||
fn json_paths_and_strict_scalar_types() {
|
||||
let body = br#"{"user":{"id":123,"name":"Greg","active":true,"deleted":null},"items":[1,2],"a.b":"value"}"#;
|
||||
for (path, op, expected, kind, outcome) in [
|
||||
("$.user.id", "equals", "123", "number", "passed"),
|
||||
("user.id", "equals", "123.0", "number", "passed"),
|
||||
("user.id", "equals", "123", "text", "failed"),
|
||||
("user.id", "not_equals", "123", "text", "failed"),
|
||||
("user.id", "greater_than", "122", "number", "passed"),
|
||||
("user.id", "less_than", "124", "number", "passed"),
|
||||
("user.name", "contains", "reg", "text", "passed"),
|
||||
("user.active", "equals", "true", "boolean", "passed"),
|
||||
("user.deleted", "exists", "", "text", "passed"),
|
||||
("user.deleted", "equals", "", "null", "passed"),
|
||||
("user.missing", "equals", "", "null", "failed"),
|
||||
("user.missing", "not_equals", "anything", "text", "failed"),
|
||||
("items[*]", "equals", "1", "number", "error"),
|
||||
("items[*]", "exists", "", "text", "passed"),
|
||||
("['a.b']", "equals", "value", "text", "passed"),
|
||||
("user", "equals", "{}", "text", "failed"),
|
||||
] {
|
||||
assert_eq!(
|
||||
run(check(path, op, expected, kind), body).outcome,
|
||||
outcome,
|
||||
"{path} {op} {expected} {kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn invalid_configs_are_not_failures_or_passes() {
|
||||
for c in [
|
||||
check("$[", "equals", "1", "number"),
|
||||
check("id", "equals", "NaN", "number"),
|
||||
check("id", "greater_than", "2", "text"),
|
||||
check("id", "script", "", "text"),
|
||||
check("id", "equals", "yes", "boolean"),
|
||||
] {
|
||||
assert_eq!(run(c, b"{}").outcome, "invalid");
|
||||
}
|
||||
let c = check("id", "exists", "", "text");
|
||||
assert!(validate(&HttpAssertions { version: 2, checks: vec![] }).is_err());
|
||||
assert!(
|
||||
validate(&HttpAssertions { checks: vec![c.clone(), c], ..Default::default() }).is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_headers_match_case_insensitively_without_joining() {
|
||||
let headers = vec![
|
||||
("X-Test".into(), "one".into()),
|
||||
("x-test".into(), "two".into()),
|
||||
];
|
||||
for (op, expected, outcome) in [
|
||||
("equals", "two", "passed"),
|
||||
("equals", "one,two", "failed"),
|
||||
("not_equals", "one", "failed"),
|
||||
("not_equals", "three", "passed"),
|
||||
] {
|
||||
let mut c = check("X-TEST", op, expected, "text");
|
||||
c.target = "header".into();
|
||||
let report = evaluate(
|
||||
&HttpAssertions { checks: vec![c], ..Default::default() },
|
||||
Response {
|
||||
status: 200,
|
||||
headers: &headers,
|
||||
body: Body::Unavailable,
|
||||
completion: Completion::Complete,
|
||||
},
|
||||
);
|
||||
assert_eq!(report.results[0].outcome, outcome);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn incomplete_requests_cannot_pass_even_a_status_check() {
|
||||
for completion in [Completion::Error, Completion::Canceled] {
|
||||
let mut c = check("", "equals", "200", "number");
|
||||
c.target = "status".into();
|
||||
let report = evaluate(
|
||||
&HttpAssertions { checks: vec![c], ..Default::default() },
|
||||
Response { status: 200, headers: &[], body: Body::Bytes(b"{}"), completion },
|
||||
);
|
||||
assert_eq!(report.results[0].outcome, "error");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn body_errors_do_not_prevent_status_checks() {
|
||||
let c = check("id", "exists", "", "text");
|
||||
for body in [
|
||||
Body::Unavailable,
|
||||
Body::TooLarge,
|
||||
Body::Bytes(b"not json"),
|
||||
Body::Bytes(b"\xff"),
|
||||
] {
|
||||
let mut status = check("", "equals", "200", "number");
|
||||
status.target = "status".into();
|
||||
status.id = "status".into();
|
||||
let report = evaluate(
|
||||
&HttpAssertions { checks: vec![c.clone(), status], ..Default::default() },
|
||||
Response { status: 200, headers: &[], body, completion: Completion::Complete },
|
||||
);
|
||||
assert_eq!(report.results[0].outcome, "error");
|
||||
assert_eq!(report.results[1].outcome, "passed");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn disabled_checks_and_large_numbers() {
|
||||
let mut c = check("invalid path", "unknown", "", "bad");
|
||||
c.enabled = false;
|
||||
assert_eq!(run(c, b"{}").outcome, "skipped");
|
||||
assert_eq!(
|
||||
run(check("id", "equals", "9007199254740993", "number"), br#"{"id":9007199254740992}"#)
|
||||
.outcome,
|
||||
"failed"
|
||||
);
|
||||
assert_eq!(
|
||||
run(check("id", "equals", "9007199254740993", "number"), br#"{"id":9007199254740993}"#)
|
||||
.outcome,
|
||||
"passed"
|
||||
);
|
||||
assert_eq!(
|
||||
run(
|
||||
check("id", "equals", "9007199254740992.0", "number"),
|
||||
br#"{"id":9007199254740993}"#
|
||||
)
|
||||
.outcome,
|
||||
"failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Strings intentionally retain unknown operators/types so they fail validation, rather than
|
||||
* disappearing during import or being treated as an empty, passing collection.
|
||||
*/
|
||||
export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
|
||||
|
||||
export type HttpAssertions = { version: number, checks: Array<HttpAssertion>, };
|
||||
Generated
+162
-37
@@ -1,54 +1,179 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { HttpAssertions } from "./gen_assertions";
|
||||
|
||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||
export type DnsOverride = {
|
||||
hostname: string;
|
||||
ipv4: Array<string>;
|
||||
ipv6: Array<string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||
export type Environment = {
|
||||
model: "environment";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
parentModel: string;
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>;
|
||||
color: string | null;
|
||||
sortPriority: number;
|
||||
};
|
||||
|
||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type Folder = {
|
||||
model: "folder";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type GrpcRequest = {
|
||||
model: "grpc_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authenticationType: string | null;
|
||||
authentication: Record<string, any>;
|
||||
description: string;
|
||||
message: string;
|
||||
metadata: Array<HttpRequestHeader>;
|
||||
method: string | null;
|
||||
name: string;
|
||||
service: string | null;
|
||||
sortPriority: number;
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
|
||||
export type SyncModel =
|
||||
| ({ type: "workspace" } & Workspace)
|
||||
| ({ type: "environment" } & Environment)
|
||||
| ({ type: "folder" } & Folder)
|
||||
| ({ type: "http_request" } & HttpRequest)
|
||||
| ({ type: "grpc_request" } & GrpcRequest)
|
||||
| ({ type: "websocket_request" } & WebsocketRequest);
|
||||
|
||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
message: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, settingHttpVersion: HttpVersion, };
|
||||
export type Workspace = {
|
||||
model: "workspace";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
encryptionKeyChallenge: string | null;
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
@@ -135,7 +135,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||
let obj = entry.to_object(&repo)?;
|
||||
let content = obj.as_blob().unwrap().content();
|
||||
let name = Path::new(entry.name().unwrap_or_default());
|
||||
SyncModel::from_bytes(content.into(), name)?.map(|m| m.0)
|
||||
SyncModel::from_bytes(content.into(), name).ok().flatten().map(|m| m.0)
|
||||
}
|
||||
Err(_) => None,
|
||||
},
|
||||
@@ -143,7 +143,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||
|
||||
let next = {
|
||||
let full_path = repo.workdir().unwrap().join(rela_path.clone());
|
||||
SyncModel::from_file(full_path.as_path())?.map(|m| m.0)
|
||||
SyncModel::from_file(full_path.as_path()).ok().flatten().map(|m| m.0)
|
||||
};
|
||||
|
||||
entries.push(GitStatusEntry {
|
||||
|
||||
@@ -7,7 +7,7 @@ publish = false
|
||||
[dependencies]
|
||||
async-compression = { version = "0.4", features = ["tokio", "gzip", "deflate", "brotli", "zstd"] }
|
||||
async-trait = "0.1"
|
||||
brotli = "8"
|
||||
brotli = "9"
|
||||
bytes = "1.11.1"
|
||||
cookie = "0.18.1"
|
||||
flate2 = "1"
|
||||
|
||||
@@ -181,7 +181,11 @@ fn strip_query_params(url: &str, names: &[&str]) -> String {
|
||||
}
|
||||
|
||||
fn build_url(r: &HttpRequest) -> String {
|
||||
let (url_string, params) = apply_path_placeholders(&ensure_proto(&r.url), &r.url_parameters);
|
||||
// Trim stray whitespace: a leading space breaks scheme
|
||||
// detection in `ensure_proto` (IdnaError); a trailing space becomes `/path%20` -> 404.
|
||||
let trimmed_url = r.url.trim();
|
||||
let (url_string, params) =
|
||||
apply_path_placeholders(&ensure_proto(trimmed_url), &r.url_parameters);
|
||||
let mut url = append_query_params(
|
||||
&url_string,
|
||||
params
|
||||
@@ -817,6 +821,64 @@ mod tests {
|
||||
assert_eq!(result, "https://example.com/api?foo=bar#section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_trims_leading_whitespace() {
|
||||
// A leading space must not break scheme detection (it used to produce
|
||||
// `http:// https://...`, which fails to parse with an IdnaError).
|
||||
let r = HttpRequest {
|
||||
url: " https://example.com/api".to_string(),
|
||||
url_parameters: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = build_url(&r);
|
||||
assert_eq!(result, "https://example.com/api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_trims_trailing_whitespace() {
|
||||
let r = HttpRequest {
|
||||
url: "https://example.com/api ".to_string(),
|
||||
url_parameters: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = build_url(&r);
|
||||
assert_eq!(result, "https://example.com/api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_trims_whitespace_with_params() {
|
||||
// A trailing space must not end up percent-encoded in the path once query
|
||||
// parameters are appended (e.g. `/api%20?foo=bar`, which 404s).
|
||||
let r = HttpRequest {
|
||||
url: " https://example.com/api ".to_string(),
|
||||
url_parameters: vec![HttpUrlParameter {
|
||||
enabled: true,
|
||||
name: "foo".to_string(),
|
||||
value: "bar".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = build_url(&r);
|
||||
assert_eq!(result, "https://example.com/api?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_trims_whitespace_without_scheme() {
|
||||
let r = HttpRequest {
|
||||
url: " example.com/api\t".to_string(),
|
||||
url_parameters: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = build_url(&r);
|
||||
// ensure_proto defaults to http:// for regular domains
|
||||
assert_eq!(result, "http://example.com/api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_with_existing_query_and_fragment() {
|
||||
let r = HttpRequest {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-jsonpath"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_json_path = "0.7.2"
|
||||
ts-rs = { workspace = true }
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type JsonPathChild = {
|
||||
/**
|
||||
* A single, escaped child accessor (e.g. .user, [0], or ["unusual.key"]).
|
||||
*/
|
||||
selector: string, label: string, kind: string, };
|
||||
|
||||
export type JsonPathChildren = { children: Array<JsonPathChild>,
|
||||
/**
|
||||
* Discovery is sampled/bounded, never an assertion that other keys don't exist.
|
||||
*/
|
||||
truncated: boolean, };
|
||||
@@ -1,226 +0,0 @@
|
||||
//! JSON selection and discovery shared by assertions and host APIs. No I/O or plugin runtime.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use ts_rs::TS;
|
||||
|
||||
pub use serde_json_path::JsonPath;
|
||||
pub const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
|
||||
pub const MAX_SELECTOR_BYTES: usize = 1024;
|
||||
const MAX_CHILDREN: usize = 100;
|
||||
const MAX_PARENTS: usize = 20;
|
||||
const MAX_ARRAY_INDICES: usize = 20;
|
||||
|
||||
pub fn normalized_path(selector: &str) -> String {
|
||||
let s = selector.trim();
|
||||
if s.starts_with('$') {
|
||||
s.into()
|
||||
} else if s.starts_with('[') {
|
||||
format!("${s}")
|
||||
} else {
|
||||
format!("$.{s}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(selector: &str) -> Result<JsonPath, &'static str> {
|
||||
if selector.trim().is_empty() || selector.len() > MAX_SELECTOR_BYTES {
|
||||
return Err("Enter a valid JSONPath");
|
||||
}
|
||||
JsonPath::parse(&normalized_path(selector)).map_err(|_| "Enter a valid JSONPath")
|
||||
}
|
||||
|
||||
pub fn parse_body(bytes: &[u8]) -> Result<Value, &'static str> {
|
||||
if bytes.len() > MAX_BODY_BYTES {
|
||||
return Err("Response body exceeds the 10 MiB limit");
|
||||
}
|
||||
serde_json::from_slice(bytes).map_err(|_| "Response body is not valid UTF-8 JSON")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_jsonpath.ts")]
|
||||
pub struct JsonPathChild {
|
||||
/// A single, escaped child accessor (e.g. .user, [0], or ["unusual.key"]).
|
||||
pub selector: String,
|
||||
pub label: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_jsonpath.ts")]
|
||||
pub struct JsonPathChildren {
|
||||
pub children: Vec<JsonPathChild>,
|
||||
/// Discovery is sampled/bounded, never an assertion that other keys don't exist.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
fn kind(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "text",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn key_selector(key: &str) -> String {
|
||||
let mut chars = key.chars();
|
||||
let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
|
||||
if first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
format!(".{key}")
|
||||
} else {
|
||||
format!("[{}]", serde_json::to_string(key).expect("string is JSON serializable"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate just the next level. Wildcard parents sample up to 20 matches, merging keys/types.
|
||||
/// Returns structure only; response values (possibly secrets) never appear in suggestions.
|
||||
pub fn children(document: &Value, parent: &str) -> Result<JsonPathChildren, &'static str> {
|
||||
let query = parse(parent)?;
|
||||
let parents = query.query(document).all();
|
||||
let mut result =
|
||||
JsonPathChildren { truncated: parents.len() > MAX_PARENTS, ..Default::default() };
|
||||
let mut indices = BTreeMap::<String, usize>::new();
|
||||
for value in parents.into_iter().take(MAX_PARENTS) {
|
||||
let mut add = |selector: String, label: String, kind: &str| {
|
||||
if selector.len() + parent.len() > MAX_SELECTOR_BYTES {
|
||||
result.truncated = true;
|
||||
return;
|
||||
}
|
||||
if let Some(&index) = indices.get(&selector) {
|
||||
if result.children[index].kind != kind {
|
||||
result.children[index].kind = "mixed".into();
|
||||
}
|
||||
} else if result.children.len() < MAX_CHILDREN {
|
||||
indices.insert(selector.clone(), result.children.len());
|
||||
result.children.push(JsonPathChild { selector, label, kind: kind.into() });
|
||||
} else {
|
||||
result.truncated = true;
|
||||
}
|
||||
};
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
add(key_selector(key), key.clone(), kind(value));
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
if !array.is_empty() {
|
||||
add("[*]".into(), "[*]".into(), "all items");
|
||||
}
|
||||
for (index, value) in array.iter().take(MAX_ARRAY_INDICES).enumerate() {
|
||||
add(format!("[{index}]"), format!("[{index}]"), kind(value));
|
||||
}
|
||||
if array.len() > MAX_ARRAY_INDICES {
|
||||
result.truncated = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn discovers_one_level_at_a_time_and_merges_wildcard_children() {
|
||||
let document = json!({"users": [{"name": "SECRET", "id": 1}, {"id": "two", "active": true}], "total": 2});
|
||||
let root = children(&document, "$").unwrap();
|
||||
assert_eq!(
|
||||
root.children.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(),
|
||||
["total", "users"]
|
||||
);
|
||||
assert!(!serde_json::to_string(&root).unwrap().contains("SECRET"));
|
||||
let users = children(&document, "users").unwrap();
|
||||
assert_eq!(
|
||||
users.children.iter().map(|c| c.selector.as_str()).collect::<Vec<_>>(),
|
||||
["[*]", "[0]", "[1]"]
|
||||
);
|
||||
let fields = children(&document, "$.users[*]").unwrap();
|
||||
assert_eq!(fields.children.iter().find(|c| c.label == "id").unwrap().kind, "mixed");
|
||||
assert_eq!(fields.children.iter().find(|c| c.label == "active").unwrap().kind, "boolean");
|
||||
assert!(children(&document, "$.users[0].id").unwrap().children.is_empty());
|
||||
assert!(children(&document, "$.missing").unwrap().children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestions_round_trip_unusual_keys_without_interpreting_them() {
|
||||
for key in [
|
||||
"",
|
||||
"a.b",
|
||||
"space key",
|
||||
"quote\"key",
|
||||
"single'key",
|
||||
"back\\slash",
|
||||
"bracket]key",
|
||||
"*",
|
||||
"..",
|
||||
"$",
|
||||
"@number()",
|
||||
"line\nbreak\t\u{0}",
|
||||
"emoji 🤔",
|
||||
"__proto__",
|
||||
"constructor",
|
||||
] {
|
||||
let document = json!({key: "needle", "decoy": "wrong"});
|
||||
let found = children(&document, "$")
|
||||
.unwrap()
|
||||
.children
|
||||
.into_iter()
|
||||
.find(|c| c.label == key)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
parse(&format!("${}", found.selector)).unwrap().query(&document).all(),
|
||||
vec![&json!("needle")],
|
||||
"key {key:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_is_bounded_and_marks_sampling() {
|
||||
let document = json!((0..30).map(|i| json!({"id": i})).collect::<Vec<_>>());
|
||||
let items = children(&document, "$").unwrap();
|
||||
assert_eq!(items.children.len(), MAX_ARRAY_INDICES + 1);
|
||||
assert!(items.truncated);
|
||||
assert!(children(&document, "$[*]").unwrap().truncated);
|
||||
let many = Value::Object((0..110).map(|i| (format!("key{i}"), Value::Null)).collect());
|
||||
let result = children(&many, "$").unwrap();
|
||||
assert_eq!(result.children.len(), MAX_CHILDREN);
|
||||
assert!(result.truncated);
|
||||
assert!(parse_body(&vec![b' '; MAX_BODY_BYTES + 1]).is_err());
|
||||
}
|
||||
|
||||
// Characterize the existing plugin's expressions before migrating its engine.
|
||||
#[test]
|
||||
fn standard_plugin_filters_work_but_javascript_extensions_are_not_supported() {
|
||||
let document = json!({"items": [{"id": 1}, {"id": 2}, {"id": 3}], "space key": 4});
|
||||
for (path, expected) in [
|
||||
("$.items[1].id", json!([2])),
|
||||
("$['space key']", json!([4])),
|
||||
("$.items[*].id", json!([1, 2, 3])),
|
||||
("$..id", json!([1, 2, 3])),
|
||||
("$.items[0:2].id", json!([1, 2])),
|
||||
("$.items[0,2].id", json!([1, 3])),
|
||||
("$.items[?(@.id > 1)].id", json!([2, 3])),
|
||||
("$.missing", json!([])),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::to_value(parse(path).unwrap().query(&document).all()).unwrap(),
|
||||
expected,
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
assert!(parse("$.items[(@.length-1)].id").is_err());
|
||||
assert!(parse("$[\"items\"].$").is_err());
|
||||
// serde_json cannot represent the unpaired UTF-16 surrogate accepted by JS JSON.parse.
|
||||
assert!(parse_body(br#"{"\ud800":"value"}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
yaak-assertions = { workspace = true }
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
yaak-database = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
export type AssertionReport = { definition: HttpAssertions, results: Array<AssertionResult>, error: string | null, };
|
||||
|
||||
export type AssertionResult = { assertionId: string, outcome: string, reason: string, actual: JsonValue | null, };
|
||||
|
||||
/**
|
||||
* Strings intentionally retain unknown operators/types so they fail validation, rather than
|
||||
* disappearing during import or being treated as an empty, passing collection.
|
||||
*/
|
||||
export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
|
||||
|
||||
export type HttpAssertions = { version: number, checks: Array<HttpAssertion>, };
|
||||
+506
-81
@@ -1,74 +1,309 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ModelChangeEvent } from "./ModelChangeEvent";
|
||||
import type { AssertionReport, HttpAssertions } from "./gen_assertions";
|
||||
|
||||
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | ImportSource | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
||||
export type AnyModel =
|
||||
| CookieJar
|
||||
| Environment
|
||||
| Folder
|
||||
| GraphQlIntrospection
|
||||
| GrpcConnection
|
||||
| GrpcEvent
|
||||
| GrpcRequest
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
| SyncState
|
||||
| WebsocketConnection
|
||||
| WebsocketEvent
|
||||
| WebsocketRequest
|
||||
| Workspace
|
||||
| WorkspaceMeta;
|
||||
|
||||
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
||||
export type ClientCertificate = {
|
||||
host: string;
|
||||
port: number | null;
|
||||
crtFile: string | null;
|
||||
keyFile: string | null;
|
||||
pfxFile: string | null;
|
||||
passphrase: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: CookieDomain;
|
||||
expires: CookieExpires;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: CookieSameSite | null;
|
||||
};
|
||||
|
||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
||||
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||
|
||||
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||
export type CookieJar = {
|
||||
model: "cookie_jar";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
cookies: Array<Cookie>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||
export type DnsOverride = {
|
||||
hostname: string;
|
||||
ipv4: Array<string>;
|
||||
ipv6: Array<string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||
|
||||
export type EncryptedKey = { encryptedKey: string, };
|
||||
export type EncryptedKey = { encryptedKey: string };
|
||||
|
||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||
export type Environment = {
|
||||
model: "environment";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
parentModel: string;
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>;
|
||||
color: string | null;
|
||||
sortPriority: number;
|
||||
};
|
||||
|
||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type Folder = {
|
||||
model: "folder";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
||||
export type GraphQlIntrospection = {
|
||||
model: "graphql_introspection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
|
||||
export type GrpcConnection = {
|
||||
model: "grpc_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
method: string;
|
||||
service: string;
|
||||
status: number;
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
|
||||
export type GrpcEvent = {
|
||||
model: "grpc_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
eventType: GrpcEventType;
|
||||
metadata: { [key in string]?: string };
|
||||
status: number | null;
|
||||
};
|
||||
|
||||
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
|
||||
export type GrpcEventType =
|
||||
| "info"
|
||||
| "error"
|
||||
| "client_message"
|
||||
| "server_message"
|
||||
| "connection_start"
|
||||
| "connection_end";
|
||||
|
||||
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type GrpcRequest = {
|
||||
model: "grpc_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authenticationType: string | null;
|
||||
authentication: Record<string, any>;
|
||||
description: string;
|
||||
message: string;
|
||||
metadata: Array<HttpRequestHeader>;
|
||||
method: string | null;
|
||||
name: string;
|
||||
service: string | null;
|
||||
sortPriority: number;
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, assertionResults: AssertionReport | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
export type HttpResponse = {
|
||||
model: "http_response";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
elapsedHeaders: number;
|
||||
elapsedDns: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
remoteAddr: string | null;
|
||||
requestContentLength: number | null;
|
||||
requestHeaders: Array<HttpResponseHeader>;
|
||||
status: number;
|
||||
statusReason: string | null;
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||
export type HttpResponseEvent = {
|
||||
model: "http_response_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
responseId: string;
|
||||
event: HttpResponseEventData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
||||
export type HttpResponseEventData =
|
||||
| {
|
||||
type: "setting";
|
||||
name: string;
|
||||
value: string;
|
||||
source_model?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
}
|
||||
| { type: "info"; message: string }
|
||||
| {
|
||||
type: "redirect";
|
||||
url: string;
|
||||
status: number;
|
||||
behavior: string;
|
||||
dropped_body: boolean;
|
||||
dropped_headers: Array<string>;
|
||||
}
|
||||
| {
|
||||
type: "send_url";
|
||||
method: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
| { type: "receive_url"; version: string; status: string }
|
||||
| { type: "header_up"; name: string; value: string }
|
||||
| { type: "header_down"; name: string; value: string }
|
||||
| { type: "chunk_sent"; bytes: number }
|
||||
| { type: "chunk_received"; bytes: number }
|
||||
| {
|
||||
type: "dns_resolved";
|
||||
hostname: string;
|
||||
addresses: Array<string>;
|
||||
duration: bigint;
|
||||
overridden: boolean;
|
||||
};
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
@@ -77,79 +312,269 @@ export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, httpVersion: HttpVersion, };
|
||||
export type HttpSendSettings = {
|
||||
validateCertificates: boolean;
|
||||
followRedirects: boolean;
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number;
|
||||
sendCookies: boolean;
|
||||
storeCookies: boolean;
|
||||
httpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type ImportSource = { model: "import_source", id: string, createdAt: string, updatedAt: string, workspaceId: string, importer: string, origin: string, originLabel: string, lastImportedAt: string, };
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type ImportSourceResource = { model: "import_source_resource", createdAt: string, updatedAt: string, importSourceId: string, sourceKey: string, modelType: string,
|
||||
/**
|
||||
* `None` once the user has decided not to import this key
|
||||
*/
|
||||
modelId?: string,
|
||||
/**
|
||||
* Hash of the resource as last applied or decided from the source, if one was recorded
|
||||
*/
|
||||
contentHash?: string, };
|
||||
export type ImportSourceResource = {
|
||||
model: "import_source_resource";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
importSourceId: string;
|
||||
sourceKey: string;
|
||||
modelType: string;
|
||||
/**
|
||||
* `None` once the user has decided not to import this key
|
||||
*/
|
||||
modelId?: string;
|
||||
/**
|
||||
* Hash of the resource as last applied or decided from the source, if one was recorded
|
||||
*/
|
||||
contentHash?: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
||||
export type KeyValue = {
|
||||
model: "key_value";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
key: string;
|
||||
namespace: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, };
|
||||
export type ModelPayload = {
|
||||
model: AnyModel;
|
||||
updateSource: UpdateSource;
|
||||
change: ModelChangeEvent;
|
||||
};
|
||||
|
||||
export type ParentAuthentication = { authentication: Record<string, any>, authenticationType: string | null, };
|
||||
export type ParentAuthentication = {
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
};
|
||||
|
||||
export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
|
||||
export type ParentHeaders = { headers: Array<HttpRequestHeader> };
|
||||
|
||||
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
|
||||
export type Plugin = {
|
||||
model: "plugin";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
checkedAt: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
source: PluginSource;
|
||||
};
|
||||
|
||||
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
|
||||
export type PluginKeyValue = {
|
||||
model: "plugin_key_value";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
pluginName: string;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||
|
||||
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
|
||||
export type ProxySetting =
|
||||
| {
|
||||
type: "enabled";
|
||||
http: string;
|
||||
https: string;
|
||||
auth: ProxySettingAuth | null;
|
||||
bypass: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
| { type: "disabled" };
|
||||
|
||||
export type ProxySettingAuth = { user: string, password: string, };
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, promptFeedback: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
appearance: string;
|
||||
clientCertificates: Array<ClientCertificate>;
|
||||
coloredMethods: boolean;
|
||||
editorFont: string | null;
|
||||
editorFontSize: number;
|
||||
editorKeymap: EditorKeymap;
|
||||
editorSoftWrap: boolean;
|
||||
hideWindowControls: boolean;
|
||||
useNativeTitlebar: boolean;
|
||||
interfaceFont: string | null;
|
||||
interfaceFontSize: number;
|
||||
interfaceScale: number;
|
||||
openWorkspaceNewWindow: boolean | null;
|
||||
proxy: ProxySetting | null;
|
||||
themeDark: string;
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
hotkeys: { [key in string]?: Array<string> };
|
||||
};
|
||||
|
||||
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
||||
export type SyncState = {
|
||||
model: "sync_state";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
flushedAt: string;
|
||||
modelId: string;
|
||||
checksum: string;
|
||||
relPath: string;
|
||||
syncDir: string;
|
||||
};
|
||||
|
||||
export type UpdateSource = { "type": "background" } | { "type": "import" } | { "type": "plugin" } | { "type": "sync" } | { "type": "window", label: string, };
|
||||
export type UpdateSource =
|
||||
| { type: "background" }
|
||||
| { type: "import" }
|
||||
| { type: "plugin" }
|
||||
| { type: "sync" }
|
||||
| { type: "window"; label: string };
|
||||
|
||||
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
|
||||
export type WebsocketConnection = {
|
||||
model: "websocket_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
|
||||
export type WebsocketEvent = {
|
||||
model: "websocket_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
isServer: boolean;
|
||||
message: Array<number>;
|
||||
messageType: WebsocketEventType;
|
||||
};
|
||||
|
||||
export type WebsocketEventType = "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||
export type WebsocketEventType =
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketMessageType = "text" | "binary";
|
||||
|
||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
message: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, settingHttpVersion: HttpVersion, };
|
||||
export type Workspace = {
|
||||
model: "workspace";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
encryptionKeyChallenge: string | null;
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||
export type WorkspaceMeta = {
|
||||
model: "workspace_meta";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
encryptionKey: EncryptedKey | null;
|
||||
settingSyncDir: string | null;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AnyModel } from "../bindings/gen_models";
|
||||
|
||||
export * from "../bindings/gen_models";
|
||||
export * from "../bindings/gen_assertions";
|
||||
export * from "../bindings/gen_util";
|
||||
export * from "./store";
|
||||
export * from "./atoms";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE http_requests ADD COLUMN assertions TEXT NOT NULL DEFAULT '{"version":1,"checks":[]}';
|
||||
ALTER TABLE http_responses ADD COLUMN assertion_results TEXT;
|
||||
@@ -18,7 +18,6 @@ use std::collections::HashMap;
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::str::FromStr;
|
||||
use ts_rs::TS;
|
||||
pub use yaak_assertions::{AssertionReport, AssertionResult, HttpAssertion, HttpAssertions};
|
||||
use yaak_database::{Result as DbResult, UpdateSource};
|
||||
pub use yaak_database::{UpsertModelInfo, upsert_date};
|
||||
|
||||
@@ -1335,7 +1334,6 @@ impl Default for HttpRequest {
|
||||
body_type: None,
|
||||
description: String::new(),
|
||||
headers: Vec::new(),
|
||||
assertions: HttpAssertions::default(),
|
||||
method: "GET".to_string(),
|
||||
name: String::new(),
|
||||
sort_priority: 0.0,
|
||||
@@ -1372,8 +1370,6 @@ pub struct HttpRequest {
|
||||
pub body_type: Option<String>,
|
||||
pub description: String,
|
||||
pub headers: Vec<HttpRequestHeader>,
|
||||
#[serde(skip_serializing_if = "HttpAssertions::is_empty")]
|
||||
pub assertions: HttpAssertions,
|
||||
pub method: String,
|
||||
pub name: String,
|
||||
pub sort_priority: f64,
|
||||
@@ -1428,7 +1424,6 @@ impl UpsertModelInfo for HttpRequest {
|
||||
(Authentication, serde_json::to_string(&self.authentication)?.into()),
|
||||
(AuthenticationType, self.authentication_type.into()),
|
||||
(Headers, serde_json::to_string(&self.headers)?.into()),
|
||||
(HttpRequestIden::Assertions, serde_json::to_string(&self.assertions)?.into()),
|
||||
(SortPriority, self.sort_priority.into()),
|
||||
(SettingSendCookies, serde_json::to_string(&self.setting_send_cookies)?.into()),
|
||||
(SettingStoreCookies, serde_json::to_string(&self.setting_store_cookies)?.into()),
|
||||
@@ -1451,7 +1446,6 @@ impl UpsertModelInfo for HttpRequest {
|
||||
FolderId,
|
||||
Method,
|
||||
Headers,
|
||||
HttpRequestIden::Assertions,
|
||||
Body,
|
||||
BodyType,
|
||||
Authentication,
|
||||
@@ -1492,15 +1486,6 @@ impl UpsertModelInfo for HttpRequest {
|
||||
description: row.get("description")?,
|
||||
folder_id: row.get("folder_id")?,
|
||||
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),
|
||||
assertions: serde_json::from_str(&row.get::<_, String>("assertions")?).map_err(
|
||||
|e| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
method: row.get("method")?,
|
||||
name: row.get("name")?,
|
||||
sort_priority: row.get("sort_priority")?,
|
||||
@@ -1971,7 +1956,6 @@ pub struct HttpResponse {
|
||||
pub elapsed_headers: i32,
|
||||
pub elapsed_dns: i32,
|
||||
pub error: Option<String>,
|
||||
pub assertion_results: Option<AssertionReport>,
|
||||
pub headers: Vec<HttpResponseHeader>,
|
||||
pub remote_addr: Option<String>,
|
||||
pub request_content_length: Option<i32>,
|
||||
@@ -2021,10 +2005,6 @@ impl UpsertModelInfo for HttpResponse {
|
||||
(ElapsedHeaders, self.elapsed_headers.into()),
|
||||
(ElapsedDns, self.elapsed_dns.into()),
|
||||
(Error, self.error.into()),
|
||||
(
|
||||
AssertionResults,
|
||||
self.assertion_results.map(|r| serde_json::to_string(&r)).transpose()?.into(),
|
||||
),
|
||||
(Headers, serde_json::to_string(&self.headers)?.into()),
|
||||
(RemoteAddr, self.remote_addr.into()),
|
||||
(RequestHeaders, serde_json::to_string(&self.request_headers)?.into()),
|
||||
@@ -2047,7 +2027,6 @@ impl UpsertModelInfo for HttpResponse {
|
||||
HttpResponseIden::ElapsedHeaders,
|
||||
HttpResponseIden::ElapsedDns,
|
||||
HttpResponseIden::Error,
|
||||
HttpResponseIden::AssertionResults,
|
||||
HttpResponseIden::Headers,
|
||||
HttpResponseIden::RemoteAddr,
|
||||
HttpResponseIden::RequestContentLength,
|
||||
@@ -2074,17 +2053,6 @@ impl UpsertModelInfo for HttpResponse {
|
||||
created_at: r.get("created_at")?,
|
||||
updated_at: r.get("updated_at")?,
|
||||
error: r.get("error")?,
|
||||
assertion_results: r
|
||||
.get::<_, Option<String>>("assertion_results")?
|
||||
.map(|v| serde_json::from_str(&v))
|
||||
.transpose()
|
||||
.map_err(|e| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
0,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
)
|
||||
})?,
|
||||
url: r.get("url")?,
|
||||
content_length: r.get("content_length")?,
|
||||
content_length_compressed: r.get("content_length_compressed").unwrap_or_default(),
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { JsonValue } from "./serde_json/JsonValue";
|
||||
|
||||
export type AssertionReport = { definition: HttpAssertions, results: Array<AssertionResult>, error: string | null, };
|
||||
|
||||
export type AssertionResult = { assertionId: string, outcome: string, reason: string, actual: JsonValue | null, };
|
||||
|
||||
/**
|
||||
* Strings intentionally retain unknown operators/types so they fail validation, rather than
|
||||
* disappearing during import or being treated as an empty, passing collection.
|
||||
*/
|
||||
export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
|
||||
|
||||
export type HttpAssertions = { version: number, checks: Array<HttpAssertion>, };
|
||||
+455
-62
@@ -1,121 +1,514 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AssertionReport, HttpAssertions } from "./gen_assertions";
|
||||
|
||||
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | ImportSource | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
||||
export type AnyModel =
|
||||
| CookieJar
|
||||
| Environment
|
||||
| Folder
|
||||
| GraphQlIntrospection
|
||||
| GrpcConnection
|
||||
| GrpcEvent
|
||||
| GrpcRequest
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
| SyncState
|
||||
| WebsocketConnection
|
||||
| WebsocketEvent
|
||||
| WebsocketRequest
|
||||
| Workspace
|
||||
| WorkspaceMeta;
|
||||
|
||||
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
||||
export type ClientCertificate = {
|
||||
host: string;
|
||||
port: number | null;
|
||||
crtFile: string | null;
|
||||
keyFile: string | null;
|
||||
pfxFile: string | null;
|
||||
passphrase: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: CookieDomain;
|
||||
expires: CookieExpires;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: CookieSameSite | null;
|
||||
};
|
||||
|
||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
||||
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||
|
||||
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||
export type CookieJar = {
|
||||
model: "cookie_jar";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
cookies: Array<Cookie>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||
export type DnsOverride = {
|
||||
hostname: string;
|
||||
ipv4: Array<string>;
|
||||
ipv6: Array<string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||
|
||||
export type EncryptedKey = { encryptedKey: string, };
|
||||
export type EncryptedKey = { encryptedKey: string };
|
||||
|
||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||
export type Environment = {
|
||||
model: "environment";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
parentModel: string;
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>;
|
||||
color: string | null;
|
||||
sortPriority: number;
|
||||
};
|
||||
|
||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type Folder = {
|
||||
model: "folder";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
||||
export type GraphQlIntrospection = {
|
||||
model: "graphql_introspection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
|
||||
export type GrpcConnection = {
|
||||
model: "grpc_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
method: string;
|
||||
service: string;
|
||||
status: number;
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
|
||||
export type GrpcEvent = {
|
||||
model: "grpc_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
eventType: GrpcEventType;
|
||||
metadata: { [key in string]?: string };
|
||||
status: number | null;
|
||||
};
|
||||
|
||||
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
|
||||
export type GrpcEventType =
|
||||
| "info"
|
||||
| "error"
|
||||
| "client_message"
|
||||
| "server_message"
|
||||
| "connection_start"
|
||||
| "connection_end";
|
||||
|
||||
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type GrpcRequest = {
|
||||
model: "grpc_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authenticationType: string | null;
|
||||
authentication: Record<string, any>;
|
||||
description: string;
|
||||
message: string;
|
||||
metadata: Array<HttpRequestHeader>;
|
||||
method: string | null;
|
||||
name: string;
|
||||
service: string | null;
|
||||
sortPriority: number;
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, assertionResults: AssertionReport | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
export type HttpResponse = {
|
||||
model: "http_response";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
elapsedHeaders: number;
|
||||
elapsedDns: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
remoteAddr: string | null;
|
||||
requestContentLength: number | null;
|
||||
requestHeaders: Array<HttpResponseHeader>;
|
||||
status: number;
|
||||
statusReason: string | null;
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||
export type HttpResponseEvent = {
|
||||
model: "http_response_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
responseId: string;
|
||||
event: HttpResponseEventData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
||||
export type HttpResponseEventData =
|
||||
| {
|
||||
type: "setting";
|
||||
name: string;
|
||||
value: string;
|
||||
source_model?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
}
|
||||
| { type: "info"; message: string }
|
||||
| {
|
||||
type: "redirect";
|
||||
url: string;
|
||||
status: number;
|
||||
behavior: string;
|
||||
dropped_body: boolean;
|
||||
dropped_headers: Array<string>;
|
||||
}
|
||||
| {
|
||||
type: "send_url";
|
||||
method: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
| { type: "receive_url"; version: string; status: string }
|
||||
| { type: "header_up"; name: string; value: string }
|
||||
| { type: "header_down"; name: string; value: string }
|
||||
| { type: "chunk_sent"; bytes: number }
|
||||
| { type: "chunk_received"; bytes: number }
|
||||
| {
|
||||
type: "dns_resolved";
|
||||
hostname: string;
|
||||
addresses: Array<string>;
|
||||
duration: bigint;
|
||||
overridden: boolean;
|
||||
};
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type ImportSource = { model: "import_source", id: string, createdAt: string, updatedAt: string, workspaceId: string, importer: string, origin: string, originLabel: string, lastImportedAt: string, };
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
||||
export type KeyValue = {
|
||||
model: "key_value";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
key: string;
|
||||
namespace: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
|
||||
export type Plugin = {
|
||||
model: "plugin";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
checkedAt: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
source: PluginSource;
|
||||
};
|
||||
|
||||
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||
|
||||
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
|
||||
export type ProxySetting =
|
||||
| {
|
||||
type: "enabled";
|
||||
http: string;
|
||||
https: string;
|
||||
auth: ProxySettingAuth | null;
|
||||
bypass: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
| { type: "disabled" };
|
||||
|
||||
export type ProxySettingAuth = { user: string, password: string, };
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, promptFeedback: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
appearance: string;
|
||||
clientCertificates: Array<ClientCertificate>;
|
||||
coloredMethods: boolean;
|
||||
editorFont: string | null;
|
||||
editorFontSize: number;
|
||||
editorKeymap: EditorKeymap;
|
||||
editorSoftWrap: boolean;
|
||||
hideWindowControls: boolean;
|
||||
useNativeTitlebar: boolean;
|
||||
interfaceFont: string | null;
|
||||
interfaceFontSize: number;
|
||||
interfaceScale: number;
|
||||
openWorkspaceNewWindow: boolean | null;
|
||||
proxy: ProxySetting | null;
|
||||
themeDark: string;
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
hotkeys: { [key in string]?: Array<string> };
|
||||
};
|
||||
|
||||
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
||||
export type SyncState = {
|
||||
model: "sync_state";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
flushedAt: string;
|
||||
modelId: string;
|
||||
checksum: string;
|
||||
relPath: string;
|
||||
syncDir: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
|
||||
export type WebsocketConnection = {
|
||||
model: "websocket_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
|
||||
export type WebsocketEvent = {
|
||||
model: "websocket_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
isServer: boolean;
|
||||
message: Array<number>;
|
||||
messageType: WebsocketEventType;
|
||||
};
|
||||
|
||||
export type WebsocketEventType = "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||
export type WebsocketEventType =
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
message: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, settingHttpVersion: HttpVersion, };
|
||||
export type Workspace = {
|
||||
model: "workspace";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
encryptionKeyChallenge: string | null;
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||
export type WorkspaceMeta = {
|
||||
model: "workspace_meta";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
encryptionKey: EncryptedKey | null;
|
||||
settingSyncDir: string | null;
|
||||
};
|
||||
@@ -18,3 +18,6 @@ tokio = { workspace = true, features = ["fs", "sync", "macros", "rt"] }
|
||||
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
||||
yaak-models = { workspace = true }
|
||||
serde_path_to_error = "0.1.20"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Strings intentionally retain unknown operators/types so they fail validation, rather than
|
||||
* disappearing during import or being treated as an empty, passing collection.
|
||||
*/
|
||||
export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
|
||||
|
||||
export type HttpAssertions = { version: number, checks: Array<HttpAssertion>, };
|
||||
Generated
+174
-38
@@ -1,56 +1,192 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { HttpAssertions } from "./gen_assertions";
|
||||
|
||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||
export type DnsOverride = {
|
||||
hostname: string;
|
||||
ipv4: Array<string>;
|
||||
ipv6: Array<string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||
export type Environment = {
|
||||
model: "environment";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
parentModel: string;
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>;
|
||||
color: string | null;
|
||||
sortPriority: number;
|
||||
};
|
||||
|
||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type Folder = {
|
||||
model: "folder";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type GrpcRequest = {
|
||||
model: "grpc_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authenticationType: string | null;
|
||||
authentication: Record<string, any>;
|
||||
description: string;
|
||||
message: string;
|
||||
metadata: Array<HttpRequestHeader>;
|
||||
method: string | null;
|
||||
name: string;
|
||||
service: string | null;
|
||||
sortPriority: number;
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
|
||||
export type SyncModel =
|
||||
| ({ type: "workspace" } & Workspace)
|
||||
| ({ type: "environment" } & Environment)
|
||||
| ({ type: "folder" } & Folder)
|
||||
| ({ type: "http_request" } & HttpRequest)
|
||||
| ({ type: "grpc_request" } & GrpcRequest)
|
||||
| ({ type: "websocket_request" } & WebsocketRequest);
|
||||
|
||||
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
||||
export type SyncState = {
|
||||
model: "sync_state";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
flushedAt: string;
|
||||
modelId: string;
|
||||
checksum: string;
|
||||
relPath: string;
|
||||
syncDir: string;
|
||||
};
|
||||
|
||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
message: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, settingHttpVersion: HttpVersion, };
|
||||
export type Workspace = {
|
||||
model: "workspace";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
encryptionKeyChallenge: string | null;
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::error::Error::UnknownModel;
|
||||
use crate::error::Result;
|
||||
use chrono::NaiveDateTime;
|
||||
use log::{debug, warn};
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_yaml::{Mapping, Value};
|
||||
use sha1::{Digest, Sha1};
|
||||
@@ -98,11 +98,13 @@ fn migrate_environment(obj: &mut Mapping) {
|
||||
}
|
||||
|
||||
impl SyncModel {
|
||||
/// Returns `Ok(None)` for files that don't look like Yaak models, and an error for ones that
|
||||
/// do but can't be parsed.
|
||||
pub fn from_bytes(content: Vec<u8>, file_path: &Path) -> Result<Option<(SyncModel, String)>> {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&content);
|
||||
let checksum = hex::encode(hasher.finalize());
|
||||
let content_str = String::from_utf8(content.clone()).unwrap_or_default();
|
||||
let content_str = String::from_utf8(content).unwrap_or_default();
|
||||
|
||||
// Check for some strings that will be in a model file for sure. If these strings
|
||||
// don't exist, then it's probably not a Yaak file.
|
||||
@@ -111,34 +113,19 @@ impl SyncModel {
|
||||
}
|
||||
|
||||
let ext = file_path.extension().unwrap_or_default();
|
||||
if ext == "yml" || ext == "yaml" {
|
||||
Ok(match serde_yaml::from_str::<SyncModel>(&content_str) {
|
||||
Ok(m) => Some((m, checksum)),
|
||||
Err(e) => {
|
||||
warn!("Error parsing {:?} {:?}", file_path.file_name(), e);
|
||||
None
|
||||
}
|
||||
})
|
||||
let model = if ext == "yml" || ext == "yaml" {
|
||||
serde_yaml::from_str::<SyncModel>(&content_str)?
|
||||
} else if ext == "json" {
|
||||
Ok(match serde_json::from_str::<SyncModel>(&content_str) {
|
||||
Ok(m) => Some((m, checksum)),
|
||||
Err(e) => {
|
||||
warn!("Error parsing {:?} {:?}", file_path.file_name(), e);
|
||||
None
|
||||
}
|
||||
})
|
||||
serde_json::from_str::<SyncModel>(&content_str)?
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some((model, checksum)))
|
||||
}
|
||||
|
||||
pub fn from_file(file_path: &Path) -> Result<Option<(SyncModel, String)>> {
|
||||
let content = match fs::read(file_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
Self::from_bytes(content, file_path)
|
||||
Self::from_bytes(fs::read(file_path)?, file_path)
|
||||
}
|
||||
|
||||
pub fn to_file_contents(&self, rel_path: &Path) -> Result<(Vec<u8>, String)> {
|
||||
@@ -309,54 +296,3 @@ color: null
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod assertion_tests {
|
||||
use super::*;
|
||||
use yaak_models::models::{HttpAssertion, HttpAssertions};
|
||||
|
||||
#[test]
|
||||
fn assertions_round_trip_through_yaml_and_old_requests_default_to_no_checks() {
|
||||
let path = Path::new("request.yaml");
|
||||
let request = HttpRequest {
|
||||
id: "rq_test".into(),
|
||||
workspace_id: "wk_test".into(),
|
||||
assertions: HttpAssertions {
|
||||
version: 1,
|
||||
checks: vec![HttpAssertion {
|
||||
id: "check".into(),
|
||||
selector: "$.id".into(),
|
||||
expected: "123".into(),
|
||||
expected_type: "number".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let (bytes, _) = SyncModel::HttpRequest(request.clone()).to_file_contents(path).unwrap();
|
||||
let (roundtrip, _) = SyncModel::from_bytes(bytes, path).unwrap().unwrap();
|
||||
let SyncModel::HttpRequest(roundtrip) = roundtrip else {
|
||||
panic!("wrong model")
|
||||
};
|
||||
assert_eq!(roundtrip.assertions, request.assertions);
|
||||
let old =
|
||||
b"model: http_request\nid: rq_old\nworkspaceId: wk_test\nurl: https://example.com\n"
|
||||
.to_vec();
|
||||
let (old, _) = SyncModel::from_bytes(old, path).unwrap().unwrap();
|
||||
let SyncModel::HttpRequest(old) = old else {
|
||||
panic!("wrong model")
|
||||
};
|
||||
assert!(old.assertions.is_empty());
|
||||
let (bytes, _) = SyncModel::HttpRequest(old).to_file_contents(path).unwrap();
|
||||
assert!(!String::from_utf8(bytes).unwrap().contains("assertions:"));
|
||||
// Unknown versions survive sync so the runner can reject them instead of silently passing.
|
||||
let mut future = request;
|
||||
future.assertions.version = 99;
|
||||
let (bytes, _) = SyncModel::HttpRequest(future).to_file_contents(path).unwrap();
|
||||
let (future, _) = SyncModel::from_bytes(bytes, path).unwrap().unwrap();
|
||||
let SyncModel::HttpRequest(future) = future else {
|
||||
panic!("wrong model")
|
||||
};
|
||||
assert_eq!(future.assertions.version, 99);
|
||||
}
|
||||
}
|
||||
+182
-19
@@ -1,4 +1,4 @@
|
||||
use crate::error::Result;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::models::SyncModel;
|
||||
use chrono::Utc;
|
||||
use log::{info, warn};
|
||||
@@ -7,8 +7,9 @@ use std::collections::HashMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use ts_rs::TS;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::{ClientDb, WriteDb};
|
||||
@@ -178,33 +179,77 @@ pub fn get_db_candidates(
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
pub fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {
|
||||
/// Read sync files, validating paths tracked by existing database models.
|
||||
///
|
||||
/// Workspace sync callers must pass all candidates from [`get_db_candidates`] for
|
||||
/// the same workspace and directory. An empty slice is for import discovery
|
||||
/// without database sync state; it does not validate tracked model identities.
|
||||
pub fn get_fs_candidates(dir: &Path, db_candidates: &[DbCandidate]) -> Result<Vec<FsCandidate>> {
|
||||
// Ensure the root directory exists
|
||||
fs::create_dir_all(dir)?;
|
||||
|
||||
let tracked_paths: HashMap<PathBuf, (&SyncModel, &SyncState)> = db_candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| match candidate {
|
||||
DbCandidate::Added(_) | DbCandidate::Deleted(_) => None,
|
||||
DbCandidate::Modified(model, state) | DbCandidate::Unmodified(model, state) => {
|
||||
Some((PathBuf::from(&state.rel_path), (model, state)))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let entries = fs::read_dir(dir)?;
|
||||
for dir_entry in entries {
|
||||
let dir_entry = match dir_entry {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let dir_entry = dir_entry?;
|
||||
let path = dir_entry.path();
|
||||
let rel_path = PathBuf::from(dir_entry.file_name());
|
||||
let tracked = tracked_paths.get(&rel_path);
|
||||
let invalid =
|
||||
|reason: String| Err(Error::InvalidSyncFile(format!("{}: {reason}", path.display())));
|
||||
|
||||
if !dir_entry.file_type()?.is_file() {
|
||||
if tracked.is_some() {
|
||||
return invalid("expected a regular sync file".into());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
let path = dir_entry.path();
|
||||
let (model, checksum) = match SyncModel::from_file(&path) {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse sync file {e}");
|
||||
return Err(e);
|
||||
// A present tracked file must not disappear from the candidates: that
|
||||
// would turn a read/parse failure into a database deletion.
|
||||
let (model, checksum) = match (SyncModel::from_file(&path), tracked) {
|
||||
(Ok(Some(m)), _) => m,
|
||||
(Ok(None), None) => continue,
|
||||
(Err(e), None) => {
|
||||
warn!("Skipping invalid sync file {}: {e}", path.display());
|
||||
continue;
|
||||
}
|
||||
(Ok(None), Some(_)) => return invalid("file does not contain a Yaak model".into()),
|
||||
(Err(e), Some(_)) => return invalid(e.to_string()),
|
||||
};
|
||||
|
||||
let rel_path = Path::new(&dir_entry.file_name()).to_path_buf();
|
||||
if let Some((previous, state)) = tracked {
|
||||
if model.id() != state.model_id {
|
||||
return invalid(format!(
|
||||
"expected model ID {}, found {}",
|
||||
state.model_id,
|
||||
model.id()
|
||||
));
|
||||
}
|
||||
// A wrong/defaulted workspace ID would be filtered out by the caller,
|
||||
// making this tracked model look deleted even if its ID is intact.
|
||||
if model.workspace_id() != state.workspace_id {
|
||||
return invalid(format!(
|
||||
"expected workspace ID {}, found {}",
|
||||
state.workspace_id,
|
||||
model.workspace_id()
|
||||
));
|
||||
}
|
||||
if std::mem::discriminant(&model) != std::mem::discriminant(*previous) {
|
||||
return invalid("model type does not match the tracked model".into());
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(FsCandidate { rel_path, model, checksum })
|
||||
}
|
||||
|
||||
@@ -391,8 +436,7 @@ pub fn apply_fs_sync_ops(
|
||||
let rel_path = derive_model_filename(&model);
|
||||
let abs_path = sync_dir.join(rel_path.clone());
|
||||
let (content, checksum) = model.to_file_contents(&rel_path)?;
|
||||
let mut f = File::create(&abs_path)?;
|
||||
f.write_all(&content)?;
|
||||
write_sync_file(&abs_path, |file| file.write_all(&content))?;
|
||||
SyncStateOp::Create { model_id: model.id(), checksum, rel_path }
|
||||
}
|
||||
SyncOp::FsUpdate { model, state } => {
|
||||
@@ -400,8 +444,7 @@ pub fn apply_fs_sync_ops(
|
||||
let rel_path = Path::new(&state.rel_path);
|
||||
let abs_path = Path::new(&state.sync_dir).join(&rel_path);
|
||||
let (content, checksum) = model.to_file_contents(&rel_path)?;
|
||||
let mut f = File::create(&abs_path)?;
|
||||
f.write_all(&content)?;
|
||||
write_sync_file(&abs_path, |file| file.write_all(&content))?;
|
||||
SyncStateOp::Update {
|
||||
state: state.to_owned(),
|
||||
checksum,
|
||||
@@ -447,6 +490,39 @@ pub fn apply_fs_sync_ops(
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
/// Publish a complete file without exposing partial writes to sync readers.
|
||||
///
|
||||
/// The temporary file's extension keeps it out of sync candidates. A crash may
|
||||
/// leave it behind, but it is never read as a model.
|
||||
fn write_sync_file(path: &Path, write: impl FnOnce(&mut File) -> io::Result<()>) -> Result<()> {
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
// Opening the existing file for writing keeps read-only files from being replaced
|
||||
let permissions = match fs::OpenOptions::new().write(true).open(path) {
|
||||
Ok(file) => Some(file.metadata()?.permissions()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let tmp_path = path.with_file_name(format!(
|
||||
".{}.{}-{}.tmp",
|
||||
path.file_name().unwrap_or_default().to_string_lossy(),
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let result = File::create_new(&tmp_path).and_then(|mut file| {
|
||||
if let Some(permissions) = permissions {
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
write(&mut file)?;
|
||||
drop(file);
|
||||
fs::rename(&tmp_path, path)
|
||||
});
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
impl PendingDbSyncOps {
|
||||
/// Upserts are collected per model type and written in one batch so
|
||||
/// foreign keys are satisfied.
|
||||
@@ -603,3 +679,90 @@ fn delete_model(db: &WriteDb, blobs: &BlobManager, model: &SyncModel) -> Result<
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod write_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_partial_write_keeps_previous_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("request.yaml");
|
||||
fs::write(&path, b"complete previous contents").unwrap();
|
||||
let result = write_sync_file(&path, |file| {
|
||||
file.write_all(b"partial new contents")?;
|
||||
Err(io::Error::new(io::ErrorKind::WriteZero, "simulated write failure"))
|
||||
});
|
||||
assert!(
|
||||
matches!(result, Err(Error::IoError(error)) if error.kind() == io::ErrorKind::WriteZero)
|
||||
);
|
||||
assert_eq!(fs::read(&path).unwrap(), b"complete previous contents");
|
||||
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readers_ignore_the_in_progress_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("request.yaml");
|
||||
let request = HttpRequest {
|
||||
id: "rq_write_test".into(),
|
||||
workspace_id: "wk_write_test".into(),
|
||||
name: "Previous request".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let model = SyncModel::HttpRequest(request.clone());
|
||||
let (old_contents, checksum) = model.to_file_contents(&path).unwrap();
|
||||
fs::write(&path, &old_contents).unwrap();
|
||||
let db_candidates = vec![DbCandidate::Unmodified(
|
||||
model,
|
||||
SyncState {
|
||||
model_id: request.id.clone(),
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
rel_path: "request.yaml".into(),
|
||||
checksum,
|
||||
..Default::default()
|
||||
},
|
||||
)];
|
||||
let model = SyncModel::HttpRequest(HttpRequest { name: "New request".into(), ..request });
|
||||
let (new_contents, _) = model.to_file_contents(&path).unwrap();
|
||||
let split = new_contents.len() / 2;
|
||||
write_sync_file(&path, |file| {
|
||||
file.write_all(&new_contents[..split])?;
|
||||
assert_eq!(fs::read(&path).unwrap(), old_contents);
|
||||
let fs_candidates = get_fs_candidates(dir.path(), &db_candidates).unwrap();
|
||||
assert_eq!(fs_candidates.len(), 1);
|
||||
assert!(compute_sync_ops(db_candidates.clone(), fs_candidates).is_empty());
|
||||
file.write_all(&new_contents[split..])
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), new_contents);
|
||||
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writes_preserve_existing_permissions_and_new_file_defaults() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("request.yaml");
|
||||
fs::write(&path, b"old contents").unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
|
||||
write_sync_file(&path, |file| {
|
||||
// Keep unpublished data at least as private as the original file.
|
||||
assert_eq!(file.metadata()?.permissions().mode() & 0o777 & !0o640, 0);
|
||||
file.write_all(b"new contents")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640);
|
||||
|
||||
let reference = dir.path().join("reference.yaml");
|
||||
File::create(&reference).unwrap();
|
||||
let new_path = dir.path().join("new.yaml");
|
||||
write_sync_file(&new_path, |file| file.write_all(b"new contents")).unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(&new_path).unwrap().permissions().mode() & 0o777,
|
||||
fs::metadata(&reference).unwrap().permissions().mode() & 0o777
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{HttpRequest, SyncState, Workspace};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_sync::error::{Error, Result};
|
||||
use yaak_sync::models::SyncModel;
|
||||
use yaak_sync::sync::{
|
||||
SyncOp, apply_db_sync_ops, apply_fs_sync_ops, apply_sync_state_ops, compute_sync_ops,
|
||||
get_db_candidates, get_fs_candidates,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
db: QueryManager,
|
||||
blobs: BlobManager,
|
||||
dir: TempDir,
|
||||
workspace_id: String,
|
||||
request: HttpRequest,
|
||||
request_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(request_filename: &str) -> Self {
|
||||
let (db, blobs, _events) = yaak_models::init_in_memory().unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = Workspace { id: "wk_sync_test".into(), ..Default::default() };
|
||||
let request = HttpRequest {
|
||||
id: "rq_sync_test".into(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Keep this request".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let request_path = dir.path().join(request_filename);
|
||||
let fixture = Self {
|
||||
db,
|
||||
blobs,
|
||||
dir,
|
||||
workspace_id: workspace.id.clone(),
|
||||
request: request.clone(),
|
||||
request_path,
|
||||
};
|
||||
for (model, path) in [
|
||||
(SyncModel::Workspace(workspace), fixture.dir.path().join("workspace.yaml")),
|
||||
(SyncModel::HttpRequest(request), fixture.request_path.clone()),
|
||||
] {
|
||||
let (contents, _) = model.to_file_contents(&path).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
let fs_candidates = get_fs_candidates(fixture.dir.path(), &[]).unwrap();
|
||||
fixture.apply(compute_sync_ops(Vec::new(), fs_candidates));
|
||||
assert!(fixture.calculate().unwrap().is_empty());
|
||||
fixture
|
||||
}
|
||||
|
||||
fn calculate(&self) -> Result<Vec<SyncOp>> {
|
||||
let db_candidates =
|
||||
get_db_candidates(&self.db.connect(), "test", &self.workspace_id, self.dir.path())?;
|
||||
let fs_candidates = get_fs_candidates(self.dir.path(), &db_candidates)?
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.model.workspace_id() == self.workspace_id)
|
||||
.collect();
|
||||
Ok(compute_sync_ops(db_candidates, fs_candidates))
|
||||
}
|
||||
|
||||
fn apply(&self, ops: Vec<SyncOp>) {
|
||||
let pending = apply_fs_sync_ops(&self.workspace_id, self.dir.path(), ops).unwrap();
|
||||
let workspace = &self.workspace_id;
|
||||
let dir = self.dir.path();
|
||||
self.db
|
||||
.with_tx::<_, Error>(|tx| {
|
||||
let states = apply_db_sync_ops(tx, &self.blobs, workspace, dir, pending)?;
|
||||
apply_sync_state_ops(tx, workspace, dir, states)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn states(&self) -> Vec<SyncState> {
|
||||
let mut states = self
|
||||
.db
|
||||
.connect()
|
||||
.list_sync_states_for_workspace(&self.workspace_id, self.dir.path())
|
||||
.unwrap();
|
||||
states.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
states
|
||||
}
|
||||
|
||||
fn db_request(&self) -> HttpRequest {
|
||||
self.db.connect().get_http_request(&self.request.id).unwrap()
|
||||
}
|
||||
|
||||
fn edit(&self, edit: impl FnOnce(&mut serde_json::Value)) {
|
||||
let mut value = serde_json::to_value(SyncModel::HttpRequest(self.request.clone())).unwrap();
|
||||
edit(&mut value);
|
||||
let content = if self.request_path.extension().unwrap() == "json" {
|
||||
serde_json::to_string(&value).unwrap()
|
||||
} else {
|
||||
serde_yaml::to_string(&value).unwrap()
|
||||
};
|
||||
fs::write(&self.request_path, content).unwrap();
|
||||
}
|
||||
|
||||
fn assert_invalid_file(&self) -> String {
|
||||
let before_request = self.db_request();
|
||||
let before_states = serde_json::to_value(self.states()).unwrap();
|
||||
let error = self.calculate().unwrap_err();
|
||||
assert!(matches!(&error, Error::InvalidSyncFile(_)), "{error:?}");
|
||||
assert!(error.to_string().contains(self.request_path.to_str().unwrap()));
|
||||
assert_eq!(self.db_request(), before_request);
|
||||
assert_eq!(serde_json::to_value(self.states()).unwrap(), before_states);
|
||||
error.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_model_type_changes_abort_sync() {
|
||||
let fixture = Fixture::new("request.yaml");
|
||||
fixture.edit(|value| {
|
||||
value["model"] = "websocket_request".into();
|
||||
});
|
||||
fixture.assert_invalid_file();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_tracked_files_abort_without_changing_data() {
|
||||
for (filename, contents) in [
|
||||
("request.yaml", b"model: http_request\nid: [broken\n".as_slice()),
|
||||
("request.json", b"{\"model\":\"http_request\",\"id\":".as_slice()),
|
||||
("custom-request.yml", b"name: half-written\n".as_slice()),
|
||||
("custom-request.yml", b"model: http_request\n".as_slice()),
|
||||
("custom-request.yml", [0xff, 0xfe].as_slice()),
|
||||
] {
|
||||
let fixture = Fixture::new(filename);
|
||||
fs::write(&fixture.request_path, contents).unwrap();
|
||||
fixture.assert_invalid_file();
|
||||
assert_eq!(fs::read(&fixture.request_path).unwrap(), contents);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_file_with_git_conflict_markers_aborts_sync() {
|
||||
let fixture = Fixture::new("request.yaml");
|
||||
let contents = fs::read_to_string(&fixture.request_path).unwrap().replace(
|
||||
"name: Keep this request\n",
|
||||
"<<<<<<< HEAD\nname: Mine\n=======\nname: Theirs\n>>>>>>> origin/main\n",
|
||||
);
|
||||
fs::write(&fixture.request_path, contents).unwrap();
|
||||
let error = fixture.assert_invalid_file();
|
||||
assert!(error.contains("line "), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tracked_file_aborts_sync() {
|
||||
let fixture = Fixture::new("request.yaml");
|
||||
fs::write(&fixture.request_path, "").unwrap();
|
||||
fixture.assert_invalid_file();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unreadable_tracked_file_aborts_sync() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let fixture = Fixture::new("request.yaml");
|
||||
fs::set_permissions(&fixture.request_path, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
// Root bypasses ordinary POSIX permission checks.
|
||||
if fs::read(&fixture.request_path).is_ok() {
|
||||
return;
|
||||
}
|
||||
let error = fixture.assert_invalid_file();
|
||||
assert!(error.contains("Permission denied"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_identities_abort_sync_for_yaml_and_json() {
|
||||
for filename in ["request.yaml", "request.json"] {
|
||||
for field in ["id", "workspaceId"] {
|
||||
let fixture = Fixture::new(filename);
|
||||
fixture.edit(|value| value[field] = "different".into());
|
||||
fixture.assert_invalid_file();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_path_replaced_by_a_directory_aborts_sync() {
|
||||
let fixture = Fixture::new("request.yaml");
|
||||
fs::remove_file(&fixture.request_path).unwrap();
|
||||
fs::create_dir(&fixture.request_path).unwrap();
|
||||
fixture.assert_invalid_file();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_tracked_file_still_deletes_the_request() {
|
||||
let fixture = Fixture::new("request.yaml");
|
||||
fs::remove_file(&fixture.request_path).unwrap();
|
||||
let ops = fixture.calculate().unwrap();
|
||||
assert!(
|
||||
matches!(ops.as_slice(), [SyncOp::DbDelete { model, .. }] if model.id() == fixture.request.id)
|
||||
);
|
||||
fixture.apply(ops);
|
||||
assert!(fixture.db.connect().get_http_request(&fixture.request.id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_files_are_still_ignored() {
|
||||
let fixture = Fixture::new("request.yaml");
|
||||
for (name, contents) in [
|
||||
("README.md", "model: http_request\nid: [broken\n"),
|
||||
("config.yaml", "model: unrelated\nid: example\n"),
|
||||
("unrelated.json", "{\"model\":\"config\",\"id\":"),
|
||||
("empty.yaml", ""),
|
||||
] {
|
||||
fs::write(fixture.dir.path().join(name), contents).unwrap();
|
||||
}
|
||||
fs::create_dir(fixture.dir.path().join("other-files")).unwrap();
|
||||
assert!(fixture.calculate().unwrap().is_empty());
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::tag_scan::TagStrings;
|
||||
|
||||
enum FormatState {
|
||||
TemplateTag,
|
||||
String,
|
||||
@@ -7,6 +9,7 @@ enum FormatState {
|
||||
/// Formats JSON that might contain template tags (skipped entirely)
|
||||
pub fn format_json(text: &str, tab: &str) -> String {
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut tag_strings = TagStrings::default();
|
||||
|
||||
let mut new_json = "".to_string();
|
||||
let mut depth = 0;
|
||||
@@ -43,15 +46,22 @@ pub fn format_json(text: &str, tab: &str) -> String {
|
||||
}
|
||||
// Close Template tag states
|
||||
if let FormatState::TemplateTag = state {
|
||||
// A quoted argument is allowed to contain `]}`, so skip over strings whole
|
||||
if current_char == '\'' {
|
||||
new_json.push(current_char);
|
||||
if let Some(rest) = tag_strings.take(&mut chars) {
|
||||
new_json.push_str(&rest);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if rest_of_chars.take(2).collect::<String>() == "]}" {
|
||||
state = FormatState::None;
|
||||
new_json.push_str("]}");
|
||||
chars.next(); // Skip the second closing bracket
|
||||
continue;
|
||||
} else {
|
||||
new_json.push(current_char);
|
||||
continue;
|
||||
}
|
||||
new_json.push(current_char);
|
||||
continue;
|
||||
}
|
||||
|
||||
if rest_of_chars.take(3).collect::<String>() == "${[" {
|
||||
@@ -315,6 +325,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_template_tags_with_quoted_brackets() {
|
||||
// The `]}` inside the quotes must not end the tag, or the rest gets re-indented
|
||||
assert_eq!(
|
||||
format_json(r#"{"foo":${[ fn(a='x]}y') ]} }"#, " "),
|
||||
r#"
|
||||
{
|
||||
"foo": ${[ fn(a='x]}y') ]}
|
||||
}
|
||||
"#
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_template_tags_with_escaped_quote() {
|
||||
assert_eq!(
|
||||
format_json(r#"{"foo":${[ fn(a='it\'s ]}') ]} }"#, " "),
|
||||
r#"
|
||||
{
|
||||
"foo": ${[ fn(a='it\'s ]}') ]}
|
||||
}
|
||||
"#
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graphql_response() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod format_json;
|
||||
pub mod parser;
|
||||
pub mod renderer;
|
||||
pub mod strip_json_comments;
|
||||
mod tag_scan;
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm;
|
||||
|
||||
|
||||
@@ -45,14 +45,35 @@ pub enum Val {
|
||||
Null,
|
||||
}
|
||||
|
||||
/// Strings are printed as plain single-quoted values whenever they can survive a round-trip
|
||||
/// through both the parser and the client's editor. A quoted `]}` is fine — the editor's
|
||||
/// grammar and regexes skip over quoted strings — but newlines and other control characters
|
||||
/// don't belong in a single-line tag, so those fall back to base64.
|
||||
fn requires_b64(text: &str) -> bool {
|
||||
text.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Escape the only two characters the parser treats specially inside a single-quoted string.
|
||||
fn escape_str(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\'' => out.push_str("\\'"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Display for Val {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let str = match self {
|
||||
Val::Str { text } => {
|
||||
if text.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '_') {
|
||||
format!("'{}'", text)
|
||||
} else {
|
||||
if requires_b64(text) {
|
||||
format!("b64'{}'", BASE64_URL_SAFE_NO_PAD.encode(text))
|
||||
} else {
|
||||
format!("'{}'", escape_str(text))
|
||||
}
|
||||
}
|
||||
Val::Var { name } => name.to_string(),
|
||||
@@ -835,10 +856,64 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn token_display_complex_str() -> Result<()> {
|
||||
assert_eq!(
|
||||
Val::Str { text: "Hello 'You'".to_string() }.to_string(),
|
||||
"b64'SGVsbG8gJ1lvdSc'"
|
||||
);
|
||||
assert_eq!(Val::Str { text: "Hello 'You'".to_string() }.to_string(), r#"'Hello \'You\''"#);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_display_str_plain() -> Result<()> {
|
||||
// Strings the parser can read back verbatim print in plain quotes
|
||||
assert_eq!(Val::Str { text: "X-Api-Key".to_string() }.to_string(), "'X-Api-Key'");
|
||||
assert_eq!(Val::Str { text: "$.token".to_string() }.to_string(), "'$.token'");
|
||||
assert_eq!(Val::Str { text: "it's".to_string() }.to_string(), r#"'it\'s'"#);
|
||||
assert_eq!(Val::Str { text: r"back\slash".to_string() }.to_string(), r"'back\\slash'");
|
||||
assert_eq!(Val::Str { text: r#"{"a":1}"#.to_string() }.to_string(), r#"'{"a":1}'"#);
|
||||
assert_eq!(Val::Str { text: "héllo".to_string() }.to_string(), "'héllo'");
|
||||
|
||||
// Quoting is enough to keep `]}` from closing the tag early in the client editor
|
||||
assert_eq!(Val::Str { text: "a]}b".to_string() }.to_string(), "'a]}b'");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_display_str_b64_fallback() -> Result<()> {
|
||||
// Control characters don't belong in a single-line tag
|
||||
assert_eq!(Val::Str { text: "line\nbreak".to_string() }.to_string(), "b64'bGluZQpicmVhaw'");
|
||||
assert_eq!(Val::Str { text: "line\rbreak".to_string() }.to_string(), "b64'bGluZQ1icmVhaw'");
|
||||
assert_eq!(Val::Str { text: "tab\there".to_string() }.to_string(), "b64'dGFiCWhlcmU'");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_str_round_trip() -> Result<()> {
|
||||
let cases = [
|
||||
"X-Api-Key",
|
||||
"$.token",
|
||||
"hello world",
|
||||
"it's",
|
||||
r"back\slash",
|
||||
r#"{"a":1}"#,
|
||||
"a]}b",
|
||||
"line\nbreak",
|
||||
"héllo",
|
||||
r"trailing\",
|
||||
"'",
|
||||
"",
|
||||
];
|
||||
|
||||
for text in cases {
|
||||
let val = Val::Str { text: text.to_string() };
|
||||
let template = Token::Tag { val: val.clone() }.to_string();
|
||||
let tokens = Parser::new(&template).parse()?;
|
||||
assert_eq!(
|
||||
tokens.tokens,
|
||||
vec![Token::Tag { val: val.clone() }, Token::Eof],
|
||||
"failed to round-trip {text:?} (printed as {template})"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -879,7 +954,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
.to_string(),
|
||||
r#"${[ foo(arg=b64'diAneCc', arg2=my_var) ]}"#
|
||||
r#"${[ foo(arg='v \'x\'', arg2=my_var) ]}"#
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::tag_scan::TagStrings;
|
||||
|
||||
/// Strips JSON comments only if the result is valid JSON. If stripping comments
|
||||
/// produces invalid JSON, the original text is returned unchanged.
|
||||
pub fn maybe_strip_json_comments(text: &str) -> String {
|
||||
@@ -17,6 +19,7 @@ pub fn maybe_strip_json_comments(text: &str) -> String {
|
||||
/// - Comments inside strings and template tags are left alone
|
||||
pub fn strip_json_comments(text: &str) -> String {
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut tag_strings = TagStrings::default();
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut in_string = false;
|
||||
let mut in_template_tag = false;
|
||||
@@ -45,6 +48,13 @@ pub fn strip_json_comments(text: &str) -> String {
|
||||
// Handle template tags
|
||||
if in_template_tag {
|
||||
result.push(current_char);
|
||||
// A quoted argument is allowed to contain `]}`, so skip over strings whole
|
||||
if current_char == '\'' {
|
||||
if let Some(rest) = tag_strings.take(&mut chars) {
|
||||
result.push_str(&rest);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if current_char == ']' && chars.peek() == Some(&'}') {
|
||||
result.push(chars.next().unwrap());
|
||||
in_template_tag = false;
|
||||
@@ -280,6 +290,34 @@ mod tests {
|
||||
assert_eq!(strip_json_comments(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quoted_tag_close_inside_template_tag() {
|
||||
// The `]}` is quoted, so the tag runs to the real close and the `//` stays put
|
||||
let input = r#"{
|
||||
"foo": ${[ fn(a='x]}y // not a comment') ]}
|
||||
}"#;
|
||||
assert_eq!(strip_json_comments(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escaped_quote_inside_template_tag() {
|
||||
let input = r#"{
|
||||
"foo": ${[ fn(a='it\'s ]} // still fine') ]}
|
||||
}"#;
|
||||
assert_eq!(strip_json_comments(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unterminated_quote_closes_tag_at_first_bracket() {
|
||||
// A half-typed string must not swallow the rest of the document
|
||||
assert_eq!(
|
||||
strip_json_comments(r#"{"foo": ${[ fn(a='oops ]}, "bar": 1 // gone
|
||||
}"#),
|
||||
r#"{"foo": ${[ fn(a='oops ]}, "bar": 1
|
||||
}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_comments() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use std::iter::Peekable;
|
||||
|
||||
/// Reads the single-quoted strings inside template tags, moving left to right through one text.
|
||||
///
|
||||
/// Kept as a struct rather than a free function because it remembers one thing between calls;
|
||||
/// see [`TagStrings::take`].
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TagStrings {
|
||||
/// Set once a scan has run off the end of the text, after which no string can close.
|
||||
none_left: bool,
|
||||
}
|
||||
|
||||
impl TagStrings {
|
||||
/// Consumes the body of a single-quoted string, assuming the opening quote has already been
|
||||
/// read, and returns it (including the closing quote).
|
||||
///
|
||||
/// Returns `None` when the quote is never closed, leaving the iterator untouched. The parser
|
||||
/// and the editor's Lezer grammar both only recognize a terminated `'…'`, so an unterminated
|
||||
/// quote has to stay an ordinary character and let the tag close at the next `]}`.
|
||||
///
|
||||
/// Scanning forward from every quote would be quadratic on text full of unpaired quotes, so
|
||||
/// a failed scan is remembered: once one runs off the end, no later quote can open a
|
||||
/// terminated string either. Any unescaped quote after it would have closed the failed scan,
|
||||
/// so every remaining quote must be one the failed scan consumed as an escaped character —
|
||||
/// and a scan starting there continues from the same place, in the same state, to the same
|
||||
/// end. With that, every character of the text is read a bounded number of times.
|
||||
pub(crate) fn take<I>(&mut self, chars: &mut Peekable<I>) -> Option<String>
|
||||
where
|
||||
I: Iterator<Item = char> + Clone,
|
||||
{
|
||||
if self.none_left {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut lookahead = chars.clone();
|
||||
let mut consumed = String::new();
|
||||
while let Some(c) = lookahead.next() {
|
||||
consumed.push(c);
|
||||
match c {
|
||||
// A backslash escapes whatever follows it, including a quote
|
||||
'\\' => match lookahead.next() {
|
||||
Some(escaped) => consumed.push(escaped),
|
||||
None => break,
|
||||
},
|
||||
'\'' => {
|
||||
*chars = lookahead;
|
||||
return Some(consumed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.none_left = true;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn take(text: &str) -> (Option<String>, String) {
|
||||
let mut chars = text.chars().peekable();
|
||||
let taken = TagStrings::default().take(&mut chars);
|
||||
(taken, chars.collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_a_terminated_string() {
|
||||
assert_eq!(take("abc' rest"), (Some("abc'".to_string()), " rest".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_a_string_containing_a_tag_close() {
|
||||
assert_eq!(take("a]}b') ]}"), (Some("a]}b'".to_string()), ") ]}".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_a_string_with_an_escaped_quote() {
|
||||
assert_eq!(take(r"it\'s' rest"), (Some(r"it\'s'".to_string()), " rest".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_an_unterminated_string_alone() {
|
||||
assert_eq!(take("oops ]}"), (None, "oops ]}".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_a_trailing_backslash_alone() {
|
||||
assert_eq!(take(r"oops\"), (None, r"oops\".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_linear_on_many_unpaired_quotes() {
|
||||
// Every quote here is escaped, so none of them can ever close a string. Rescanning to
|
||||
// the end from each one would be quadratic, so this should finish in no time.
|
||||
let text = r"\'".repeat(20_000);
|
||||
let mut strings = TagStrings::default();
|
||||
let mut chars = text.chars().peekable();
|
||||
let started = Instant::now();
|
||||
|
||||
let mut quotes = 0;
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\'' {
|
||||
quotes += 1;
|
||||
assert_eq!(strings.take(&mut chars), None);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(quotes, 20_000);
|
||||
assert!(started.elapsed() < Duration::from_secs(1), "took {:?}", started.elapsed());
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,6 @@ crate-type = ["cdylib", "rlib"]
|
||||
# only exists for wasm is a target-scoped dependency for the same reason.
|
||||
|
||||
[dependencies]
|
||||
yaak-jsonpath = { workspace = true }
|
||||
yaak-assertions = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -120,10 +120,6 @@ export function __wbg_Error_bce6d499ff0a4aff(arg0, arg1) {
|
||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_Number_b7972a139bfbfdf0(arg0) {
|
||||
const ret = Number(arg0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_String_8564e559799eccda(arg0, arg1) {
|
||||
const ret = String(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
@@ -516,7 +512,7 @@ export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
|
||||
const a = state0.a;
|
||||
state0.a = 0;
|
||||
try {
|
||||
return wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_(a, state0.b, arg0, arg1);
|
||||
return wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(a, state0.b, arg0, arg1);
|
||||
} finally {
|
||||
state0.a = a;
|
||||
}
|
||||
@@ -701,23 +697,23 @@ export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
|
||||
console.warn(arg0, arg1);
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1911, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1131, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 214, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_);
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 107, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 177, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 216, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_);
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000005(arg0) {
|
||||
@@ -754,30 +750,30 @@ export function __wbindgen_init_externref_table() {
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
}
|
||||
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_(arg0, arg1) {
|
||||
wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_(arg0, arg1, arg2, arg3);
|
||||
function wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(arg0, arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
+5
-5
@@ -17,11 +17,11 @@ export const rust_sqlite_wasm_malloc: (a: number) => number;
|
||||
export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
|
||||
export const sqlite3_os_end: () => number;
|
||||
export const sqlite3_os_init: () => number;
|
||||
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8: (a: number, b: number) => void;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __wbindgen_exn_store: (a: number) => void;
|
||||
|
||||
@@ -252,47 +252,6 @@ struct InsertResponseEventsReq {
|
||||
events: Vec<HttpResponseEventData>,
|
||||
}
|
||||
|
||||
fn evaluate_http_assertions(
|
||||
host: &Host,
|
||||
definition: &yaak_assertions::HttpAssertions,
|
||||
response: &yaak_models::models::HttpResponse,
|
||||
canceled: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
let db = host.blobs.connect();
|
||||
let read = || -> std::result::Result<Option<Vec<u8>>, yaak_models::error::Error> {
|
||||
if db.get_body_size(&response.id)? > yaak_assertions::MAX_BODY_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(db.get_chunks(&response.id)?.into_iter().flat_map(|c| c.data).collect()))
|
||||
};
|
||||
let bytes =
|
||||
if definition.needs_body() && response.error.is_none() { read().ok() } else { None };
|
||||
let body = match &bytes {
|
||||
Some(None) => yaak_assertions::Body::TooLarge,
|
||||
Some(Some(bytes)) if !bytes.is_empty() || response.content_length.unwrap_or(0) == 0 => {
|
||||
yaak_assertions::Body::Bytes(bytes)
|
||||
}
|
||||
_ => yaak_assertions::Body::Unavailable,
|
||||
};
|
||||
let headers =
|
||||
response.headers.iter().map(|h| (h.name.clone(), h.value.clone())).collect::<Vec<_>>();
|
||||
to_json(yaak_assertions::evaluate(
|
||||
definition,
|
||||
yaak_assertions::Response {
|
||||
status: response.status,
|
||||
headers: &headers,
|
||||
body,
|
||||
completion: if canceled {
|
||||
yaak_assertions::Completion::Canceled
|
||||
} else if response.error.is_some() {
|
||||
yaak_assertions::Completion::Error
|
||||
} else {
|
||||
yaak_assertions::Completion::Complete
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
host: &Host,
|
||||
cmd: &str,
|
||||
@@ -349,69 +308,6 @@ fn dispatch(
|
||||
to_json(serde_json::to_string(&list).map_err(js_error)?)
|
||||
}
|
||||
|
||||
"cmd_validate_http_assertions" => {
|
||||
#[derive(Deserialize)]
|
||||
struct Req {
|
||||
assertions: yaak_assertions::HttpAssertions,
|
||||
}
|
||||
let req: Req = from_js(payload)?;
|
||||
to_json(yaak_assertions::validation_errors(&req.assertions))
|
||||
}
|
||||
"cmd_http_response_json_children" => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Req {
|
||||
response_id: String,
|
||||
parent: String,
|
||||
}
|
||||
let req: Req = from_js(payload)?;
|
||||
yaak_jsonpath::parse(&req.parent).map_err(js_error)?;
|
||||
let response =
|
||||
host.queries.connect().get_http_response(&req.response_id).map_err(js_error)?;
|
||||
if !matches!(response.state, yaak_models::models::HttpResponseState::Closed) {
|
||||
return Err(js_error("Wait for the response to finish"));
|
||||
}
|
||||
let db = host.blobs.connect();
|
||||
if db.get_body_size(&req.response_id).map_err(js_error)? > yaak_jsonpath::MAX_BODY_BYTES
|
||||
{
|
||||
return Err(js_error("Suggestions are limited to JSON responses up to 10 MiB"));
|
||||
}
|
||||
let bytes: Vec<u8> = db
|
||||
.get_chunks(&req.response_id)
|
||||
.map_err(js_error)?
|
||||
.into_iter()
|
||||
.flat_map(|c| c.data)
|
||||
.collect();
|
||||
let document = yaak_jsonpath::parse_body(&bytes).map_err(js_error)?;
|
||||
to_json(yaak_jsonpath::children(&document, &req.parent).map_err(js_error)?)
|
||||
}
|
||||
"cmd_preview_http_assertions" => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Req {
|
||||
response_id: String,
|
||||
assertions: yaak_assertions::HttpAssertions,
|
||||
}
|
||||
let req: Req = from_js(payload)?;
|
||||
let response =
|
||||
host.queries.connect().get_http_response(&req.response_id).map_err(js_error)?;
|
||||
if !matches!(response.state, yaak_models::models::HttpResponseState::Closed) {
|
||||
return Err(js_error("Wait for the response to finish"));
|
||||
}
|
||||
// Return a preview only; never patch the saved response or send anything.
|
||||
evaluate_http_assertions(host, &req.assertions, &response, false)
|
||||
}
|
||||
"evaluate_http_assertions" => {
|
||||
#[derive(Deserialize)]
|
||||
struct Req {
|
||||
definition: yaak_assertions::HttpAssertions,
|
||||
response: yaak_models::models::HttpResponse,
|
||||
canceled: bool,
|
||||
}
|
||||
let req: Req = from_js(payload)?;
|
||||
evaluate_http_assertions(host, &req.definition, &req.response, req.canceled)
|
||||
}
|
||||
|
||||
"models_upsert" => {
|
||||
let req: ModelReq = from_js(payload)?;
|
||||
let id = host
|
||||
|
||||
@@ -5,8 +5,6 @@ edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
yaak-jsonpath = { workspace = true }
|
||||
yaak-assertions = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
|
||||
log = { workspace = true }
|
||||
|
||||
+107
-2
@@ -1,4 +1,5 @@
|
||||
use crate::Result;
|
||||
use crate::import_templates::remap_request_references;
|
||||
use chrono::Utc;
|
||||
use log::info;
|
||||
use serde_json::Value;
|
||||
@@ -312,11 +313,14 @@ pub fn plan_import_resources(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut request_ids = BTreeMap::new();
|
||||
let http_requests = resources
|
||||
.http_requests
|
||||
.into_iter()
|
||||
.map(|mut request| {
|
||||
request.id = HttpRequest::generate_id();
|
||||
let new_id = HttpRequest::generate_id();
|
||||
request_ids.insert(request.id.clone(), new_id.clone());
|
||||
request.id = new_id;
|
||||
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
||||
request.folder_id = resolve_folder_id(request.folder_id);
|
||||
request
|
||||
@@ -428,7 +432,7 @@ pub fn plan_import_resources(
|
||||
));
|
||||
}
|
||||
|
||||
let resources = BatchUpsertResult {
|
||||
let mut resources = BatchUpsertResult {
|
||||
workspaces,
|
||||
environments,
|
||||
folders,
|
||||
@@ -436,6 +440,7 @@ pub fn plan_import_resources(
|
||||
grpc_requests,
|
||||
websocket_requests,
|
||||
};
|
||||
remap_request_references(&mut resources, &request_ids)?;
|
||||
|
||||
let mut plan = ImportPlan {
|
||||
importer,
|
||||
@@ -961,6 +966,13 @@ fn merge_with_linked_source(
|
||||
}
|
||||
}
|
||||
|
||||
let request_remap = plan
|
||||
.resources
|
||||
.http_requests
|
||||
.iter()
|
||||
.filter_map(|v| remap.get(&v.id).map(|id| (v.id.clone(), id.clone())))
|
||||
.collect();
|
||||
remap_request_references(&mut plan.resources, &request_remap)?;
|
||||
let remap_ref = |id: Option<String>| id.map(|v| remap.get(&v).cloned().unwrap_or(v));
|
||||
for v in &mut plan.resources.folders {
|
||||
if let Some(existing_id) = remap.get(&v.id) {
|
||||
@@ -2600,6 +2612,99 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insomnia_plugin_output_produces_sendable_graphql_and_linked_requests() {
|
||||
// The JS fixture suite asserts this exact payload against the real importer.
|
||||
let imported: yaak_plugins::events::ImportResponse = serde_json::from_str(include_str!(
|
||||
"../../../plugins/importer-insomnia/tests/fixtures/chained.output.json"
|
||||
))
|
||||
.expect("parse plugin output");
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"Insomnia".into(),
|
||||
ImportDestination::NewWorkspace,
|
||||
imported.resources,
|
||||
imported.source_keys,
|
||||
None,
|
||||
)
|
||||
.expect("plan Insomnia import");
|
||||
let committed = commit_import_plan(&query_manager, plan).expect("commit Insomnia import");
|
||||
let query = &committed.http_requests[0];
|
||||
let login = &committed.http_requests[1];
|
||||
assert!(query.authentication["token"].as_str().unwrap().contains(&login.id));
|
||||
assert!(!query.authentication["token"].as_str().unwrap().contains("GENERATE_ID"));
|
||||
let sendable =
|
||||
yaak_http::types::SendableHttpRequest::from_http_request(query, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let Some(yaak_http::types::SendableBody::Bytes(body)) = sendable.body else {
|
||||
panic!("expected GraphQL JSON body");
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&body).unwrap(),
|
||||
json!({
|
||||
"query": "query Get($id: ID!) { node(id: $id) { id } }",
|
||||
"variables": { "id": "123" },
|
||||
"operationName": "Get",
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
sendable.headers.iter().any(|(name, value)| name.eq_ignore_ascii_case("content-type")
|
||||
&& value == "application/json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_references_follow_import_and_reimport_ids_without_touching_literals() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let mut resources = imported_resources();
|
||||
let template =
|
||||
"${[ response.body.path(request='rq_root', path='$.token', result='first') ]}";
|
||||
resources.http_requests[1].authentication =
|
||||
BTreeMap::from([("token".into(), json!(template))]);
|
||||
resources.http_requests[1].body =
|
||||
BTreeMap::from([("text".into(), json!("rq_root is literal text"))]);
|
||||
resources.environments[0].variables[0].value = template.into();
|
||||
resources.folders[0].authentication = BTreeMap::from([("token".into(), json!(template))]);
|
||||
resources.workspaces[0].authentication =
|
||||
BTreeMap::from([("token".into(), json!(template))]);
|
||||
|
||||
let first = first_import_with(&query_manager, resources.clone());
|
||||
let workspace_id = &first.workspaces[0].id;
|
||||
let first_id = &first.http_requests[0].id;
|
||||
let expected = format!(
|
||||
"${{[ response.body.path(request='{first_id}', path='$.token', result='first') ]}}"
|
||||
);
|
||||
assert_eq!(first.http_requests[1].authentication["token"], json!(expected));
|
||||
assert_eq!(first.environments[0].variables[0].value, expected);
|
||||
assert_eq!(first.folders[0].authentication["token"], json!(expected));
|
||||
assert_eq!(first.workspaces[0].authentication["token"], json!(expected));
|
||||
assert_eq!(first.http_requests[1].body["text"], json!("rq_root is literal text"));
|
||||
|
||||
// Reimport allocates temporary IDs, then reuses the previously linked IDs. Neither
|
||||
// phase may leave a reference behind, or spuriously flag a source/local conflict.
|
||||
let plan = replan(&query_manager, workspace_id, resources.clone());
|
||||
assert_eq!(&plan.resources.http_requests[0].id, first_id);
|
||||
assert_eq!(plan.resources.http_requests[1].authentication["token"], json!(expected));
|
||||
assert!(plan.items.iter().all(|item| item.action == ImportPlanAction::Unchanged));
|
||||
commit_import_plan(&query_manager, plan).expect("commit reimport");
|
||||
assert_eq!(query_manager.connect().list_http_requests(workspace_id).unwrap().len(), 2);
|
||||
|
||||
// A separate import must point at its own new request, not at the first workspace.
|
||||
let second = first_import_with(&query_manager, resources);
|
||||
let second_id = &second.http_requests[0].id;
|
||||
assert_ne!(first_id, second_id);
|
||||
assert!(
|
||||
second.http_requests[1].authentication["token"].as_str().unwrap().contains(second_id)
|
||||
);
|
||||
assert!(
|
||||
!second.http_requests[1].authentication["token"].as_str().unwrap().contains(first_id)
|
||||
);
|
||||
}
|
||||
|
||||
fn first_import(query_manager: &QueryManager) -> BatchUpsertResult {
|
||||
first_import_with(query_manager, imported_resources())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_templates::{Parser, Token, Val};
|
||||
|
||||
/// Rewrite request references at each ID-assignment step (initial import and linked reimport).
|
||||
/// Literal text, missing targets and arguments to unrelated functions must not be rewritten.
|
||||
pub(crate) fn remap_request_references(
|
||||
resources: &mut BatchUpsertResult,
|
||||
ids: &BTreeMap<String, String>,
|
||||
) -> serde_json::Result<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut value = serde_json::to_value(&resources)?;
|
||||
remap_value(&mut value, ids);
|
||||
*resources = serde_json::from_value(value)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remap_value(value: &mut Value, ids: &BTreeMap<String, String>) {
|
||||
match value {
|
||||
Value::String(text) => *text = remap_text(text, ids),
|
||||
Value::Array(values) => values.iter_mut().for_each(|v| remap_value(v, ids)),
|
||||
Value::Object(values) => values.values_mut().for_each(|v| remap_value(v, ids)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn remap_argument(value: &mut Val, ids: &BTreeMap<String, String>) -> bool {
|
||||
let Val::Fn { name, args } = value else {
|
||||
return false;
|
||||
};
|
||||
let request_arg = match name.as_str() {
|
||||
"response" | "response.body.raw" | "response.body.path" | "response.header" => "request",
|
||||
"request.body" | "request.body.raw" | "request.body.path" | "request.header"
|
||||
| "request.param" | "request.name" => "requestId",
|
||||
_ => "",
|
||||
};
|
||||
let mut changed = false;
|
||||
for arg in args {
|
||||
if arg.name == request_arg
|
||||
&& let Val::Str { text } = &mut arg.value
|
||||
&& let Some(id) = ids.get(text)
|
||||
&& id != text
|
||||
{
|
||||
*text = id.clone();
|
||||
changed = true;
|
||||
}
|
||||
changed |= remap_argument(&mut arg.value, ids);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn remap_text(text: &str, ids: &BTreeMap<String, String>) -> String {
|
||||
// Parse individual unescaped tags. Parsing/reprinting the whole string would unescape raw
|
||||
// template-looking text, and change formatting even when no reference needs updating.
|
||||
let bytes = text.as_bytes();
|
||||
let mut output = String::new();
|
||||
let mut copied = 0;
|
||||
let mut cursor = 0;
|
||||
while let Some(relative) = text[cursor..].find("${[") {
|
||||
let start = cursor + relative;
|
||||
cursor = start + 3;
|
||||
let escapes = bytes[..start].iter().rev().take_while(|&&b| b == b'\\').count();
|
||||
if escapes % 2 != 0 {
|
||||
continue;
|
||||
}
|
||||
let mut end = cursor;
|
||||
let mut quoted = false;
|
||||
while end < bytes.len() {
|
||||
if quoted && bytes[end] == b'\\' {
|
||||
end += 2;
|
||||
continue;
|
||||
}
|
||||
if bytes[end] == b'\'' {
|
||||
quoted = !quoted;
|
||||
}
|
||||
if !quoted && bytes[end..].starts_with(b"]}") {
|
||||
end += 2;
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
if end > bytes.len() || !text[start..end].ends_with("]}") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut tokens) = Parser::new(&text[start..end]).parse()
|
||||
&& let [Token::Tag { val }, Token::Eof] = tokens.tokens.as_mut_slice()
|
||||
&& remap_argument(val, ids)
|
||||
{
|
||||
output.push_str(&text[copied..start]);
|
||||
output.push_str(&tokens.to_string());
|
||||
copied = end;
|
||||
}
|
||||
cursor = end;
|
||||
}
|
||||
output.push_str(&text[copied..]);
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([("req_old".into(), "rq_new".into())])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_remaps_known_literal_request_arguments() {
|
||||
let text = "${[ response.header(request='req_old', header='req_old') ]}";
|
||||
assert_eq!(
|
||||
remap_text(text, &ids()),
|
||||
"${[ response.header(request='rq_new', header='req_old') ]}"
|
||||
);
|
||||
for text in [
|
||||
"req_old",
|
||||
"${[ custom(request='req_old') ]}",
|
||||
"${[ response.header(request=req_old) ]}",
|
||||
"${[ response.header(request='req_missing') ]}",
|
||||
"${[ response.header(request='req_old' ]}",
|
||||
] {
|
||||
assert_eq!(remap_text(text, &ids()), text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_encoded_nested_and_escaped_tags_without_changing_surrounding_text() {
|
||||
let text = r#"café \${[ response.header(request='req_old') ]} ${[ json.jsonpath(input=response.body.raw(request=b64'cmVxX29sZA')) ]} end"#;
|
||||
assert_eq!(
|
||||
remap_text(text, &ids()),
|
||||
r#"café \${[ response.header(request='req_old') ]} ${[ json.jsonpath(input=response.body.raw(request='rq_new')) ]} end"#
|
||||
);
|
||||
let text = "${[ response.header(request='req_old', header=']}' ) ]}";
|
||||
assert!(remap_text(text, &ids()).contains("request='rq_new'"));
|
||||
}
|
||||
|
||||
/// Only the request ID may change. Every other argument keeps its name, order, spacing and
|
||||
/// plain single-quoted form, so a printer that falls back to base64 for values like
|
||||
/// `X-Api-Key` or `$.token` fails here.
|
||||
#[test]
|
||||
fn remapping_a_request_keeps_the_other_arguments_verbatim() {
|
||||
assert_eq!(
|
||||
remap_text("${[ response.header(request='req_old', header='X-Api-Key') ]}", &ids()),
|
||||
"${[ response.header(request='rq_new', header='X-Api-Key') ]}"
|
||||
);
|
||||
assert_eq!(
|
||||
remap_text(
|
||||
"${[ response.body.path(request='req_old', path='$.token', behavior='never') ]}",
|
||||
&ids()
|
||||
),
|
||||
"${[ response.body.path(request='rq_new', path='$.token', behavior='never') ]}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An Insomnia tag that never got converted is plain text, even though it names a request
|
||||
/// that is being remapped. It must survive byte-for-byte, including alongside a real tag.
|
||||
#[test]
|
||||
fn unconverted_nunjucks_text_containing_a_request_id_is_untouched() {
|
||||
let text = "{% response 'raw', 'req_old', '', 'never', 60 %}";
|
||||
assert_eq!(remap_text(text, &ids()), text);
|
||||
|
||||
assert_eq!(
|
||||
remap_text(
|
||||
"${[ response.body.path(request='req_old', path='$.a') ]} and {% response 'raw', 'req_old' %}",
|
||||
&ids()
|
||||
),
|
||||
"${[ response.body.path(request='rq_new', path='$.a') ]} and {% response 'raw', 'req_old' %}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
pub mod error;
|
||||
pub mod import;
|
||||
mod import_templates;
|
||||
pub mod plugin_events;
|
||||
pub mod response_body;
|
||||
pub mod send;
|
||||
|
||||
pub use error::Error;
|
||||
pub type Result<T> = error::Result<T>;
|
||||
pub use yaak_assertions as assertions;
|
||||
|
||||
pub use yaak_jsonpath as jsonpath;
|
||||
@@ -109,109 +109,6 @@ impl ResponseBodyStore for FileResponseBodyStore<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover the next JSONPath level without sending body contents to the UI.
|
||||
/// Hosts can expose this as RPC/plugin APIs while sharing selection with assertions.
|
||||
pub fn json_path_children(
|
||||
store: &dyn ResponseBodyStore,
|
||||
response_id: &str,
|
||||
parent: &str,
|
||||
) -> std::result::Result<yaak_jsonpath::JsonPathChildren, String> {
|
||||
yaak_jsonpath::parse(parent)?;
|
||||
let info = store.info(response_id).map_err(|e| e.to_string())?;
|
||||
if !info.complete {
|
||||
return Err("Wait for the response to finish".into());
|
||||
}
|
||||
if info.content_length > yaak_jsonpath::MAX_BODY_BYTES as u64 {
|
||||
return Err("Suggestions are limited to JSON responses up to 10 MiB".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let chunk = store
|
||||
.read_chunk(
|
||||
response_id,
|
||||
bytes.len() as u64,
|
||||
(yaak_jsonpath::MAX_BODY_BYTES + 1 - bytes.len()) as u64,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if chunk.is_empty() {
|
||||
break;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
if bytes.len() > yaak_jsonpath::MAX_BODY_BYTES {
|
||||
return Err("Suggestions are limited to JSON responses up to 10 MiB".into());
|
||||
}
|
||||
}
|
||||
let document = yaak_jsonpath::parse_body(&bytes)?;
|
||||
Ok(yaak_jsonpath::children(&document, parent)?)
|
||||
}
|
||||
|
||||
/// Evaluate the caller's draft against a saved response, without rendering,
|
||||
/// sending, or writing either the request or its historical assertion results.
|
||||
pub fn preview_assertions(
|
||||
queries: &QueryManager,
|
||||
response_id: &str,
|
||||
definition: &yaak_assertions::HttpAssertions,
|
||||
) -> std::result::Result<yaak_assertions::AssertionReport, String> {
|
||||
use yaak_assertions::{Body, Completion, MAX_BODY_BYTES, Response};
|
||||
|
||||
let response = queries.connect().get_http_response(response_id).map_err(|e| e.to_string())?;
|
||||
if !matches!(response.state, HttpResponseState::Closed) {
|
||||
return Err("Wait for the response to finish".into());
|
||||
}
|
||||
let store = FileResponseBodyStore::new(queries);
|
||||
let bytes;
|
||||
let mut body = Body::Unavailable;
|
||||
if definition.needs_body() && response.error.is_none() {
|
||||
// File sizes are only a hint: also cap actual reads, including the
|
||||
// overflow byte. A missing/removed body must not prevent status checks.
|
||||
let read = || -> Result<Option<Vec<u8>>> {
|
||||
if store.info(response_id)?.content_length > MAX_BODY_BYTES as u64 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let chunk = store.read_chunk(
|
||||
response_id,
|
||||
bytes.len() as u64,
|
||||
(MAX_BODY_BYTES + 1 - bytes.len()) as u64,
|
||||
)?;
|
||||
if chunk.is_empty() {
|
||||
return Ok(Some(bytes));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
if bytes.len() > MAX_BODY_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
};
|
||||
match read() {
|
||||
Ok(None) => body = Body::TooLarge,
|
||||
Ok(Some(value)) => {
|
||||
bytes = value;
|
||||
if !bytes.is_empty() || response.content_length.unwrap_or(0) == 0 {
|
||||
body = Body::Bytes(&bytes);
|
||||
}
|
||||
}
|
||||
Err(_) => {} // The evaluator reports unavailable body checks individually.
|
||||
}
|
||||
}
|
||||
let headers =
|
||||
response.headers.iter().map(|h| (h.name.clone(), h.value.clone())).collect::<Vec<_>>();
|
||||
Ok(yaak_assertions::evaluate(
|
||||
definition,
|
||||
Response {
|
||||
status: response.status,
|
||||
headers: &headers,
|
||||
body,
|
||||
completion: if response.error.is_some() {
|
||||
Completion::Error
|
||||
} else {
|
||||
Completion::Complete
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -280,143 +177,6 @@ mod tests {
|
||||
(query_manager, temp_dir, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_discovery_waits_for_completion_and_only_returns_structure() {
|
||||
let (qm, _tmp, id) = seed(Some(br#"{"user":{"name":"SECRET","active":true}}"#));
|
||||
let store = FileResponseBodyStore::new(&qm);
|
||||
assert!(json_path_children(&store, &id, "$").unwrap_err().contains("finish"));
|
||||
let mut response = qm.connect().get_http_response(&id).unwrap();
|
||||
response.state = HttpResponseState::Closed;
|
||||
qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap();
|
||||
let children = json_path_children(&store, &id, "$.user").unwrap();
|
||||
assert_eq!(children.children.len(), 2);
|
||||
assert!(!serde_json::to_string(&children).unwrap().contains("SECRET"));
|
||||
assert!(json_path_children(&store, "rs_missing", "$").is_err());
|
||||
let path = response.body_path.unwrap();
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.unwrap()
|
||||
.set_len((yaak_jsonpath::MAX_BODY_BYTES + 1) as u64)
|
||||
.unwrap();
|
||||
assert!(json_path_children(&store, &id, "$").unwrap_err().contains("10 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_discovery_reads_beyond_the_single_chunk_limit() {
|
||||
let bytes = serde_json::to_vec(&serde_json::json!({"padding": "a".repeat(MAX_CHUNK_BYTES as usize), "user": {"id": 123}})).unwrap();
|
||||
let (qm, _tmp, id) = seed(Some(&bytes));
|
||||
let mut response = qm.connect().get_http_response(&id).unwrap();
|
||||
response.state = HttpResponseState::Closed;
|
||||
qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap();
|
||||
let result = json_path_children(&FileResponseBodyStore::new(&qm), &id, "$.user").unwrap();
|
||||
assert_eq!(result.children[0].label, "id");
|
||||
assert_eq!(result.children[0].kind, "number");
|
||||
let mut definition = preview_definition();
|
||||
definition.checks.remove(0);
|
||||
definition.checks[0].expected = "123".into();
|
||||
assert_eq!(preview_assertions(&qm, &id, &definition).unwrap().results[0].outcome, "passed");
|
||||
}
|
||||
|
||||
fn preview_definition() -> yaak_assertions::HttpAssertions {
|
||||
use yaak_assertions::HttpAssertion;
|
||||
yaak_assertions::HttpAssertions {
|
||||
checks: vec![
|
||||
HttpAssertion {
|
||||
id: "status".into(),
|
||||
target: "status".into(),
|
||||
expected: "200".into(),
|
||||
expected_type: "number".into(),
|
||||
..Default::default()
|
||||
},
|
||||
HttpAssertion {
|
||||
id: "body".into(),
|
||||
selector: "$.user.id".into(),
|
||||
expected: "7".into(),
|
||||
expected_type: "number".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_uses_draft_checks_without_changing_saved_models_or_results() {
|
||||
let (qm, _tmp, id) = seed(Some(br#"{"user":{"id":7}}"#));
|
||||
let mut response = qm.connect().get_http_response(&id).unwrap();
|
||||
response.state = HttpResponseState::Closed;
|
||||
response.status = 200;
|
||||
qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap();
|
||||
let mut original = preview_definition();
|
||||
original.checks[1].expected = "99".into();
|
||||
response.assertion_results = Some(preview_assertions(&qm, &id, &original).unwrap());
|
||||
qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap();
|
||||
let before_response =
|
||||
serde_json::to_value(qm.connect().get_http_response(&id).unwrap()).unwrap();
|
||||
let before_request =
|
||||
serde_json::to_value(qm.connect().get_http_request("rq_test").unwrap()).unwrap();
|
||||
|
||||
let preview = preview_assertions(&qm, &id, &preview_definition()).unwrap();
|
||||
assert!(preview.results.iter().all(|r| r.outcome == "passed"));
|
||||
assert_eq!(preview.definition, preview_definition());
|
||||
assert_eq!(before_response["assertionResults"]["results"][1]["outcome"], "failed");
|
||||
assert_eq!(
|
||||
serde_json::to_value(qm.connect().get_http_response(&id).unwrap()).unwrap(),
|
||||
before_response
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(qm.connect().get_http_request("rq_test").unwrap()).unwrap(),
|
||||
before_request
|
||||
);
|
||||
assert_eq!(std::fs::read(response.body_path.unwrap()).unwrap(), br#"{"user":{"id":7}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_rejects_pending_or_missing_responses_and_never_passes_failed_sends() {
|
||||
let (qm, _tmp, id) = seed(Some(br#"{"user":{"id":7}}"#));
|
||||
let definition = preview_definition();
|
||||
assert!(preview_assertions(&qm, &id, &definition).unwrap_err().contains("finish"));
|
||||
assert!(preview_assertions(&qm, "rs_missing", &definition).is_err());
|
||||
let mut response = qm.connect().get_http_response(&id).unwrap();
|
||||
response.state = HttpResponseState::Closed;
|
||||
response.status = 200;
|
||||
response.error = Some("Request canceled".into());
|
||||
qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap();
|
||||
assert!(
|
||||
preview_assertions(&qm, &id, &definition)
|
||||
.unwrap()
|
||||
.results
|
||||
.iter()
|
||||
.all(|r| r.outcome == "error")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_evaluates_status_when_the_saved_body_is_too_large_or_missing() {
|
||||
let (qm, _tmp, id) = seed(Some(b""));
|
||||
let mut response = qm.connect().get_http_response(&id).unwrap();
|
||||
response.state = HttpResponseState::Closed;
|
||||
response.status = 200;
|
||||
qm.with_tx(|tx| tx.update_http_response_if_id(&response, &UpdateSource::Sync)).unwrap();
|
||||
let path = response.body_path.unwrap();
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap()
|
||||
.set_len((yaak_assertions::MAX_BODY_BYTES + 1) as u64)
|
||||
.unwrap();
|
||||
let preview = preview_assertions(&qm, &id, &preview_definition()).unwrap();
|
||||
assert_eq!(preview.results[0].outcome, "passed");
|
||||
assert_eq!(preview.results[1].outcome, "error");
|
||||
assert!(preview.results[1].reason.contains("10 MiB"));
|
||||
std::fs::remove_file(path).unwrap();
|
||||
let preview = preview_assertions(&qm, &id, &preview_definition()).unwrap();
|
||||
assert_eq!(preview.results[0].outcome, "passed");
|
||||
assert_eq!(preview.results[1].outcome, "error");
|
||||
assert!(preview.results[1].reason.contains("unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_reports_stored_size_and_content_type() {
|
||||
let (qm, _tmp, id) = seed(Some(b"hello world"));
|
||||
|
||||
+1
-212
@@ -45,8 +45,6 @@ const MAX_AUTH_BODY_BYTES: usize = 10 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SendHttpRequestError {
|
||||
#[error("Request canceled")]
|
||||
Canceled,
|
||||
#[error("Failed to load request: {0}")]
|
||||
LoadRequest(#[source] yaak_models::error::Error),
|
||||
|
||||
@@ -630,12 +628,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
response.url = sendable_request.url.clone();
|
||||
response.state = HttpResponseState::Initialized;
|
||||
response.error = None;
|
||||
response.assertion_results =
|
||||
(!request.assertions.is_empty()).then(|| yaak_assertions::AssertionReport {
|
||||
definition: request.assertions.clone(),
|
||||
results: Vec::new(),
|
||||
error: None,
|
||||
});
|
||||
response.content_length = None;
|
||||
response.content_length_compressed = None;
|
||||
response.body_path = None;
|
||||
@@ -843,8 +835,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
ResponseBody::Returned(Vec::new())
|
||||
};
|
||||
let mut body_read_error = None;
|
||||
let mut assertion_body = request.assertions.needs_body().then(Vec::new);
|
||||
let mut assertion_body_too_large = false;
|
||||
let mut written_bytes: usize = 0;
|
||||
let mut last_progress_update = started_at;
|
||||
let mut cancelled_rx = params.cancelled_rx.clone();
|
||||
@@ -852,7 +842,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
loop {
|
||||
let read_result = if let Some(cancelled_rx) = cancelled_rx.as_mut() {
|
||||
if *cancelled_rx.borrow() {
|
||||
body_read_error = Some(SendHttpRequestError::Canceled);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -870,12 +859,7 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
};
|
||||
|
||||
let Some(read_result) = read_result else {
|
||||
if cancelled_rx.as_ref().is_some_and(|rx| *rx.borrow()) {
|
||||
body_read_error = Some(SendHttpRequestError::Canceled);
|
||||
break;
|
||||
}
|
||||
cancelled_rx = None;
|
||||
continue;
|
||||
break;
|
||||
};
|
||||
|
||||
match read_result {
|
||||
@@ -888,14 +872,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
SendHttpRequestError::WriteResponseBody { path: path.clone(), source }
|
||||
})?;
|
||||
}
|
||||
if let Some(body) = assertion_body.as_mut() {
|
||||
if body.len() + chunk.len() > yaak_assertions::MAX_BODY_BYTES {
|
||||
assertion_body = None;
|
||||
assertion_body_too_large = true;
|
||||
} else {
|
||||
body.extend_from_slice(chunk);
|
||||
}
|
||||
}
|
||||
if let Some(tx) = params.emit_response_body_chunks_to.as_ref() {
|
||||
let _ = tx.send(chunk.to_vec());
|
||||
} else if let ResponseBody::Returned(body) = &mut response_body {
|
||||
@@ -1043,36 +1019,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
warn!("Failed to join response event task: {}", join_err);
|
||||
}
|
||||
|
||||
if !request.assertions.is_empty() {
|
||||
let headers =
|
||||
response.headers.iter().map(|h| (h.name.clone(), h.value.clone())).collect::<Vec<_>>();
|
||||
response.assertion_results = Some(yaak_assertions::evaluate(
|
||||
&request.assertions,
|
||||
yaak_assertions::Response {
|
||||
status: response.status,
|
||||
headers: &headers,
|
||||
body: if assertion_body_too_large {
|
||||
yaak_assertions::Body::TooLarge
|
||||
} else {
|
||||
yaak_assertions::Body::Bytes(assertion_body.as_deref().unwrap_or_default())
|
||||
},
|
||||
completion: if response.error.is_some() {
|
||||
yaak_assertions::Completion::Error
|
||||
} else {
|
||||
yaak_assertions::Completion::Complete
|
||||
},
|
||||
},
|
||||
));
|
||||
if let Some(store) = store {
|
||||
response = store
|
||||
.query_manager
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_response(&response, &store.update_source, store.blob_manager)
|
||||
})
|
||||
.map_err(SendHttpRequestError::PersistResponse)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SendHttpRequestResult {
|
||||
rendered_request,
|
||||
response,
|
||||
@@ -1266,23 +1212,11 @@ fn persist_response_error(
|
||||
fallback_url: String,
|
||||
) -> Result<HttpResponse> {
|
||||
let elapsed = duration_to_i32(started_at.elapsed());
|
||||
let assertion_results = response.assertion_results.as_ref().map(|report| {
|
||||
yaak_assertions::evaluate(
|
||||
&report.definition,
|
||||
yaak_assertions::Response {
|
||||
status: response.status,
|
||||
headers: &[],
|
||||
body: yaak_assertions::Body::Unavailable,
|
||||
completion: yaak_assertions::Completion::Error,
|
||||
},
|
||||
)
|
||||
});
|
||||
store
|
||||
.query_manager
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_http_response(
|
||||
&HttpResponse {
|
||||
assertion_results,
|
||||
state: HttpResponseState::Closed,
|
||||
elapsed,
|
||||
elapsed_headers: if response.elapsed_headers == 0 {
|
||||
@@ -1391,151 +1325,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assertion_inputs() -> HttpSendInputs {
|
||||
HttpSendInputs {
|
||||
request: ResolvedHttpRequest::assume_resolved(
|
||||
HttpRequest {
|
||||
id: "rq_assertions".into(),
|
||||
workspace_id: "wk_assertions".into(),
|
||||
url: "http://localhost/test".into(),
|
||||
assertions: yaak_assertions::HttpAssertions {
|
||||
version: 1,
|
||||
checks: vec![yaak_assertions::HttpAssertion {
|
||||
id: "check".into(),
|
||||
selector: "$.id".into(),
|
||||
expected: "123".into(),
|
||||
expected_type: "number".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
String::new(),
|
||||
),
|
||||
environment_chain: Vec::new(),
|
||||
runtime_config: HttpSendRuntimeConfig {
|
||||
settings: ResolvedHttpRequestSettings::default(),
|
||||
proxy: HttpConnectionProxySetting::System,
|
||||
dns_overrides: Vec::new(),
|
||||
client_certificates: Vec::new(),
|
||||
},
|
||||
cookie_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assertions_use_the_same_body_for_stored_and_streamed_responses() {
|
||||
for stream in [false, true] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (queries, blobs, _rx) =
|
||||
yaak_models::init_standalone(&dir.path().join("db"), &dir.path().join("blobs"))
|
||||
.unwrap();
|
||||
let inputs = assertion_inputs();
|
||||
queries
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_workspace(
|
||||
&Workspace { id: "wk_assertions".into(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)?;
|
||||
tx.upsert_http_request(inputs.request.request(), &UpdateSource::Sync)
|
||||
})
|
||||
.unwrap();
|
||||
let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel();
|
||||
// A closed, false cancellation channel must not truncate the body.
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
drop(cancel_tx);
|
||||
let result = send_http_request(SendHttpRequestParams {
|
||||
inputs,
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: Some(ResponseStorage {
|
||||
query_manager: &queries,
|
||||
blob_manager: &blobs,
|
||||
update_source: UpdateSource::Sync,
|
||||
response_dir: dir.path(),
|
||||
}),
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: stream.then_some(chunk_tx),
|
||||
cancelled_rx: Some(cancel_rx),
|
||||
existing_response: None,
|
||||
prepare_sendable_request: None,
|
||||
executor: &StubExecutor { body: br#"{"id":123}"# },
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let report = result.response.assertion_results.as_ref().unwrap();
|
||||
assert_eq!(report.results[0].outcome, "passed");
|
||||
assert_eq!(
|
||||
queries
|
||||
.connect()
|
||||
.get_http_response(&result.response.id)
|
||||
.unwrap()
|
||||
.assertion_results
|
||||
.unwrap()
|
||||
.results[0]
|
||||
.outcome,
|
||||
"passed"
|
||||
);
|
||||
if stream {
|
||||
assert_eq!(chunk_rx.recv().await.unwrap(), br#"{"id":123}"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CancelAfterHeaders(watch::Sender<bool>);
|
||||
#[async_trait]
|
||||
impl SendRequestExecutor for CancelAfterHeaders {
|
||||
async fn send(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
events: mpsc::Sender<SenderHttpResponseEvent>,
|
||||
cookies: CookieBehavior,
|
||||
) -> yaak_http::error::Result<yaak_http::sender::HttpResponse> {
|
||||
let response =
|
||||
StubExecutor { body: br#"{"id":123}"# }.send(request, events, cookies).await?;
|
||||
self.0.send(true).unwrap();
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn canceled_body_never_leaves_passing_assertions_in_history() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (queries, blobs, _rx) =
|
||||
yaak_models::init_standalone(&dir.path().join("db"), &dir.path().join("blobs"))
|
||||
.unwrap();
|
||||
let inputs = assertion_inputs();
|
||||
queries
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_workspace(
|
||||
&Workspace { id: "wk_assertions".into(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)?;
|
||||
tx.upsert_http_request(inputs.request.request(), &UpdateSource::Sync)
|
||||
})
|
||||
.unwrap();
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let result = send_http_request(SendHttpRequestParams {
|
||||
inputs,
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: Some(ResponseStorage {
|
||||
query_manager: &queries,
|
||||
blob_manager: &blobs,
|
||||
update_source: UpdateSource::Sync,
|
||||
response_dir: dir.path(),
|
||||
}),
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
cancelled_rx: Some(cancel_rx),
|
||||
existing_response: None,
|
||||
prepare_sendable_request: None,
|
||||
executor: &CancelAfterHeaders(cancel_tx),
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(SendHttpRequestError::Canceled)));
|
||||
let responses = queries.connect().list_http_responses("wk_assertions", None).unwrap();
|
||||
assert!(responses[0].error.is_some());
|
||||
assert_eq!(responses[0].assertion_results.as_ref().unwrap().results[0].outcome, "error");
|
||||
}
|
||||
|
||||
/// The hosted sender runs with no query manager, blob manager, or response directory. Nothing
|
||||
/// here touches storage, so the caller's only view of the response is what gets streamed out.
|
||||
#[tokio::test]
|
||||
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user