POC: Explore request assertions and CLI testing

This commit is contained in:
Gregory Schier committed 2026-09-25 08:32:24 -07:00
1 parent 263a215f7a
commit c71256d800
65 files changed
+4187 -1983

No files matched your search

Generated
+87
View File
@@ -3781,6 +3781,15 @@ 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"
@@ -7906,6 +7915,56 @@ 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.101",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
@@ -11237,9 +11296,11 @@ dependencies = [
"tempfile",
"thiserror 2.0.20",
"tokio",
"yaak-assertions",
"yaak-core",
"yaak-crypto",
"yaak-http",
"yaak-jsonpath",
"yaak-models",
"yaak-plugins",
"yaak-templates",
@@ -11342,6 +11403,17 @@ 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"
@@ -11374,6 +11446,7 @@ dependencies = [
"webbrowser",
"yaak",
"yaak-api",
"yaak-assertions",
"yaak-core",
"yaak-crypto",
"yaak-http",
@@ -11543,6 +11616,16 @@ dependencies = [
"zstd",
]
[[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"
@@ -11603,6 +11686,7 @@ dependencies = [
"thiserror 2.0.20",
"ts-rs",
"urlencoding",
"yaak-assertions",
"yaak-core",
"yaak-database",
"yaak-templates",
@@ -11708,6 +11792,7 @@ dependencies = [
"ts-rs",
"yaak-git",
"yaak-grpc",
"yaak-jsonpath",
"yaak-models",
"yaak-plugins",
"yaak-sse",
@@ -11811,6 +11896,8 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yaak-assertions",
"yaak-jsonpath",
"yaak-lifecycle",
"yaak-models",
"yaak-templates",
+4
View File
@@ -2,6 +2,8 @@
resolver = "2"
members = [
"crates/yaak",
"crates/yaak-assertions",
"crates/yaak-jsonpath",
"crates/yaak-commands",
# Common/foundation crates
"crates/common/yaak-database",
@@ -82,6 +84,8 @@ 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" }
@@ -0,0 +1,79 @@
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>
);
}
@@ -0,0 +1,81 @@
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>
);
}
@@ -0,0 +1,216 @@
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,11 +42,12 @@ 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 { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { 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";
@@ -72,6 +73,7 @@ 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
@@ -171,6 +173,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
() => [
{
value: TAB_BODY,
menuLabel: "Body",
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
options: {
value: activeRequest.bodyType,
@@ -264,6 +267,16 @@ 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,
@@ -310,11 +323,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch);
await setActiveTab({
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
tabsRef.current?.setActiveTab(TAB_PARAMS);
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
@@ -368,8 +377,12 @@ 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 } from "react";
import { lazy, Suspense, useMemo, useRef } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
@@ -23,11 +23,12 @@ import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { PillButton } from "./core/PillButton";
import { SizeTag } from "./core/SizeTag";
import type { TabItem } from "./core/Tabs/Tabs";
import type { TabItem, TabsRef } 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";
@@ -56,6 +57,7 @@ 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";
@@ -67,6 +69,7 @@ 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);
@@ -83,9 +86,53 @@ 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",
@@ -162,6 +209,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
hasAssertionResults,
assertionFailures,
assertionPasses,
assertionsHaveErrors,
cookieCounts.sent,
cookieCounts.received,
copyResponse.mutate,
@@ -210,6 +261,17 @@ 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>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
@@ -282,13 +344,18 @@ 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>
@@ -0,0 +1,76 @@
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>
);
}
@@ -0,0 +1,93 @@
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();
});
});
@@ -0,0 +1,119 @@
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;
};
}
+300 -67
View File
@@ -10,7 +10,7 @@ import {
useSensors,
} from "@dnd-kit/core";
import classNames from "classnames";
import type { ReactNode, Ref } from "react";
import type { CSSProperties, ReactNode, Ref } from "react";
import {
forwardRef,
memo,
@@ -27,11 +27,19 @@ 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 =
export type TabItem = {
/** Stable label for the visibility menu, including tabs with dropdown labels. */
menuLabel?: string;
hiddenByDefault?: boolean;
} & (
| {
value: string;
label: string;
@@ -44,7 +52,8 @@ export type TabItem =
options: Omit<RadioDropdownProps, "children">;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
}
);
interface TabsStorage {
order: string[];
@@ -72,6 +81,8 @@ 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(
@@ -87,6 +98,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
layout = "vertical",
storageKey,
activeTabKey,
visibilityStorageKey,
}: Props,
forwardedRef: Ref<TabsRef>,
) {
@@ -108,10 +120,33 @@ 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 value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
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;
// Helper to normalize storage (handle migration from old format)
const normalizeStorage = useCallback(
@@ -123,6 +158,9 @@ 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) => {
@@ -135,7 +173,15 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
}
onChangeValueProp?.(newValue);
},
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
[
storageKey,
activeTabKey,
setStorage,
onChangeValueProp,
normalizeStorage,
visibilityStorageKey,
setVisibility,
],
);
// Expose imperative methods via ref
@@ -199,6 +245,18 @@ 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(() => {
@@ -290,69 +348,168 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
const tabButtons = useMemo(() => {
const items: ReactNode[] = [];
tabs.forEach((t, i) => {
if ("hidden" in t && t.hidden) {
if (!fitsInStrip(t)) {
return;
}
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>,
);
}
const next = tabs[i + 1];
const fitted = fittedTabs?.find((tab) => tab.value === t.value);
items.push(
<TabButton
key={t.value}
tab={t}
isActive={isActive}
isActive={t.value === value}
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]);
}, [
tabs,
value,
addBorders,
layout,
reorderable,
isDragging,
onChangeValue,
hoveredIndex,
fitsInStrip,
fittedTabs,
wrapTabs,
]);
const tabList = (
const tabStrip = (
<div
ref={viewportRef}
role="tablist"
aria-label={label}
className={classNames(
tabListClassName,
!visibilityStorageKey && 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" && "overflow-x-auto overflow-y-visible ",
layout === "vertical" &&
(responsive ? "relative overflow-hidden py-1" : "overflow-x-auto overflow-y-visible"),
// Give space for button focus states within overflow boundary.
!addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
!responsive && !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}
{hoveredIndex === tabs.length && (
<div className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}>
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
</div>
</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",
)}
>
<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 (
@@ -361,9 +518,13 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
className={classNames(
className,
"tabs-container",
"h-full grid",
"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",
layout === "horizontal" && "grid-rows-1 grid-cols-[auto_minmax(0,1fr)]",
layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
!responsive && layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
)}
>
{reorderable ? (
@@ -409,6 +570,11 @@ interface TabButtonProps {
isDragging: boolean;
onChangeValue?: (value: string) => void;
overlay?: boolean;
width?: number;
spacingCompression?: number;
wrap?: boolean;
dropBefore?: boolean;
dropAfter?: boolean;
}
function TabButton({
@@ -420,35 +586,26 @@ function TabButton({
isDragging,
onChangeValue,
overlay = false,
width,
spacingCompression = 0,
wrap = false,
dropBefore = false,
dropAfter = false,
}: TabButtonProps) {
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 constrained = width !== undefined;
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) => {
@@ -457,7 +614,8 @@ function TabButton({
},
className: classNames(
"flex items-center rounded-sm whitespace-nowrap",
"px-2! ml-px",
"ml-px",
!constrained && "px-2!",
"outline-hidden",
"ring-none",
"focus-visible-or-class:outline-2",
@@ -471,6 +629,9 @@ 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",
),
};
@@ -487,6 +648,9 @@ 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">
@@ -511,22 +675,91 @@ function TabButton({
);
}
return (
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
<Button
title={(constrained || wrap) && "label" in tab ? tab.label : undefined}
leftSlot={tab.leftSlot}
rightSlot={tab.rightSlot}
{...btnProps}
>
{"label" in tab && tab.label ? tab.label : tab.value}
</Button>
);
})();
// Apply drag handlers to wrapper, not button
const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};
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],
);
return (
<div
ref={handleSetWrapperRef}
className={classNames("relative", layout === "vertical" && "mr-2")}
{...wrapperProps}
>
{buttonContent}
<div ref={setRef} className={className} style={style} {...attributes} {...listeners}>
{children}
</div>
);
}
@@ -0,0 +1,113 @@
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),
);
}
}
}
});
});
@@ -0,0 +1,38 @@
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),
}));
}
@@ -0,0 +1,55 @@
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 };
}
+1
View File
@@ -9,6 +9,7 @@ name = "yaak"
path = "src/main.rs"
[dependencies]
yaak-assertions = { workspace = true }
arboard = "3"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
+21
View File
@@ -71,6 +71,9 @@ 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),
@@ -186,6 +189,24 @@ 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
+2
View File
@@ -10,3 +10,5 @@ pub mod response;
pub mod send;
pub mod template_function;
pub mod workspace;
pub mod test;
+442
View File
@@ -0,0 +1,442 @@
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
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_&lt;&amp;&quot;\""));
assert!(xml.contains("<skipped "));
}
}
+26
View File
@@ -129,6 +129,32 @@ 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);
@@ -0,0 +1,164 @@
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"));
}
+9
View File
@@ -0,0 +1,9 @@
// 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>, };
+26 -108
View File
@@ -1,135 +1,53 @@
// 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>;
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>, 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 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,6 +127,21 @@ 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.
@@ -834,6 +834,40 @@ 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?)
}
+1
View File
@@ -6,6 +6,7 @@ authors = ["Gregory Schier"]
publish = false
[dependencies]
yaak-jsonpath = { workspace = true }
serde = { workspace = true, features = ["derive"] }
ts-rs = { workspace = true }
yaak-git = { workspace = true }
+14
View File
@@ -0,0 +1,14 @@
// 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>, };
+13
View File
@@ -0,0 +1,13 @@
// 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, };
+63 -462
View File
@@ -1,522 +1,123 @@
// 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>;
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>, 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 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;
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, 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 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, };
+9 -1
View File
@@ -1,8 +1,10 @@
// 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";
@@ -145,6 +147,8 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, };
export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
export type CmdHttpResponseJsonChildrenReq = { responseId: string, parent: string, };
export type CmdImportDataReq = { filePath: string, destination: ImportDestination, };
export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, };
@@ -175,6 +179,8 @@ 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, };
@@ -211,6 +217,8 @@ 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>;
@@ -256,6 +264,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, };
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
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_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], 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, GitBranchInfo], cmd_git_workLine truncated
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_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], 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: [CmdGitDeleteLine truncated
export type WatchResult = { unlistenEvent: string, };
@@ -0,0 +1,3 @@
// 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;
+26
View File
@@ -41,6 +41,29 @@ 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")]
@@ -923,6 +946,9 @@ 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) -> (),
+12
View File
@@ -0,0 +1,12 @@
[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
View File
@@ -0,0 +1,14 @@
// 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>, };
@@ -0,0 +1,3 @@
// 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;
+540
View File
@@ -0,0 +1,540 @@
//! 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
View File
@@ -0,0 +1,9 @@
// 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>, };
+37 -162
View File
@@ -1,179 +1,54 @@
// 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>;
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>, 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 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, };
+11
View File
@@ -0,0 +1,11 @@
[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
View File
@@ -0,0 +1,13 @@
// 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, };
+226
View File
@@ -0,0 +1,226 @@
//! 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());
}
}
+1
View File
@@ -5,6 +5,7 @@ edition = "2024"
publish = false
[dependencies]
yaak-assertions = { workspace = true }
chrono = { version = "0.4.38", features = ["serde"] }
yaak-database = { workspace = true }
hex = { workspace = true }
+14
View File
@@ -0,0 +1,14 @@
// 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>, };
+81 -506
View File
@@ -1,309 +1,74 @@
// 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>;
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>, 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 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;
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, 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 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";
@@ -312,269 +77,79 @@ 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, };
@@ -0,0 +1,3 @@
// 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
View File
@@ -1,6 +1,7 @@
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";
@@ -0,0 +1,2 @@
ALTER TABLE http_requests ADD COLUMN assertions TEXT NOT NULL DEFAULT '{"version":1,"checks":[]}';
ALTER TABLE http_responses ADD COLUMN assertion_results TEXT;
+32
View File
@@ -18,6 +18,7 @@ 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};
@@ -1334,6 +1335,7 @@ 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,
@@ -1370,6 +1372,8 @@ 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,
@@ -1424,6 +1428,7 @@ 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()),
@@ -1446,6 +1451,7 @@ impl UpsertModelInfo for HttpRequest {
FolderId,
Method,
Headers,
HttpRequestIden::Assertions,
Body,
BodyType,
Authentication,
@@ -1486,6 +1492,15 @@ 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")?,
@@ -1956,6 +1971,7 @@ 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>,
@@ -2005,6 +2021,10 @@ 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()),
@@ -2027,6 +2047,7 @@ impl UpsertModelInfo for HttpResponse {
HttpResponseIden::ElapsedHeaders,
HttpResponseIden::ElapsedDns,
HttpResponseIden::Error,
HttpResponseIden::AssertionResults,
HttpResponseIden::Headers,
HttpResponseIden::RemoteAddr,
HttpResponseIden::RequestContentLength,
@@ -2053,6 +2074,17 @@ 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
View File
@@ -0,0 +1,14 @@
// 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>, };
+62 -455
View File
@@ -1,514 +1,121 @@
// 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>;
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>, 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 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;
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, 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 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, };
+9
View File
@@ -0,0 +1,9 @@
// 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>, };
+38 -174
View File
@@ -1,192 +1,56 @@
// 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>;
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>, 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 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, };
+51
View File
@@ -309,3 +309,54 @@ 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);
}
}
+2
View File
@@ -22,6 +22,8 @@ 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 }
+21 -17
View File
@@ -120,6 +120,10 @@ 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);
@@ -512,7 +516,7 @@ export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
const a = state0.a;
state0.a = 0;
try {
return wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(a, state0.b, arg0, arg1);
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);
} finally {
state0.a = a;
}
@@ -697,23 +701,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: 1131, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23);
// 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_);
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__convert__closures_____invoke__h54297d6cc284a2e5);
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_);
return ret;
}
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
// 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);
// 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_);
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__convert__closures_____invoke__h5977a2970909c8b8);
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_);
return ret;
}
export function __wbindgen_cast_0000000000000005(arg0) {
@@ -750,30 +754,30 @@ export function __wbindgen_init_externref_table() {
table.set(offset + 2, true);
table.set(offset + 3, false);
}
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_______true_(arg0, arg1) {
wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_(arg0, arg1);
}
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___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__h2c4f0a20127a8a23(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23(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);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562(arg0, arg1, arg2);
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);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(arg0, arg1, arg2, arg3);
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);
}
Binary file not shown.
+5 -5
View File
@@ -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__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 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 __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;
+104
View File
@@ -252,6 +252,47 @@ 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,
@@ -308,6 +349,69 @@ 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
+2
View File
@@ -5,6 +5,8 @@ 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 }
+3
View File
@@ -6,3 +6,6 @@ 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;
+240
View File
@@ -109,6 +109,109 @@ 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::*;
@@ -177,6 +280,143 @@ 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"));
+212 -1
View File
@@ -45,6 +45,8 @@ 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),
@@ -628,6 +630,12 @@ 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;
@@ -835,6 +843,8 @@ 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();
@@ -842,6 +852,7 @@ 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;
}
@@ -859,7 +870,12 @@ pub async fn send_http_request<T: TemplateCallback>(
};
let Some(read_result) = read_result else {
break;
if cancelled_rx.as_ref().is_some_and(|rx| *rx.borrow()) {
body_read_error = Some(SendHttpRequestError::Canceled);
break;
}
cancelled_rx = None;
continue;
};
match read_result {
@@ -872,6 +888,14 @@ 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 {
@@ -1019,6 +1043,36 @@ 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,
@@ -1212,11 +1266,23 @@ 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 {
@@ -1325,6 +1391,151 @@ 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]
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { commandSupport, runCommand } from "./commands";
import type { WorkerConnection } from "./connection";
import { sendHttpRequest } from "./send";
vi.mock("./send", () => ({ sendHttpRequest: vi.fn() }));
afterEach(() => vi.clearAllMocks());
describe("browser assertion commands", () => {
it.each([
["cmd_validate_http_assertions", { assertions: { version: 1, checks: [] } }],
[
"cmd_preview_http_assertions",
{ responseId: "rs_pinned_history", assertions: { version: 1, checks: [] } },
],
["cmd_http_response_json_children", { responseId: "rs_pinned_history", parent: "$.user" }],
])("routes %s to the local worker without a send or model write", async (command, payload) => {
const report = { from: "local worker" };
const rpc = vi.fn().mockResolvedValue(report);
const db = { rpc } as unknown as WorkerConnection;
expect(commandSupport().implemented).toContain(command);
expect(await runCommand(command, payload, db)).toBe(report);
expect(rpc).toHaveBeenCalledExactlyOnceWith(command, payload);
expect(sendHttpRequest).not.toHaveBeenCalled();
});
it("does not resend when a saved response is no longer available", async () => {
const rpc = vi.fn().mockRejectedValue(new Error("Response not found"));
const db = { rpc } as unknown as WorkerConnection;
await expect(
runCommand(
"cmd_preview_http_assertions",
{ responseId: "rs_removed", assertions: { version: 1, checks: [] } },
db,
),
).rejects.toThrow("Response not found");
expect(rpc).toHaveBeenCalledTimes(1);
expect(sendHttpRequest).not.toHaveBeenCalled();
});
});
+4
View File
@@ -68,6 +68,10 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
cmd_delete_all_http_responses: (payload, db) => db.rpc("cmd_delete_all_http_responses", payload),
cmd_delete_send_history: (payload, db) => db.rpc("cmd_delete_send_history", payload),
cmd_validate_http_assertions: (payload, db) => db.rpc("cmd_validate_http_assertions", payload),
cmd_preview_http_assertions: (payload, db) => db.rpc("cmd_preview_http_assertions", payload),
cmd_http_response_json_children: (payload, db) =>
db.rpc("cmd_http_response_json_children", payload),
/* ------------------------------- sending ------------------------------- */
+126
View File
@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HttpAssertions, HttpResponse } from "@yaakapp-internal/models";
import type { WorkerConnection } from "./connection";
import { sendHttpRequest } from "./send";
vi.mock("./server", () => ({
serverIdentity: async () => "test server",
serverSendUrl: () => "https://test.invalid/send",
readFrames: async function* () {
yield {
type: "response",
status: 200,
headers: [],
requestHeaders: [],
url: "https://example.com",
};
yield { type: "body", data: btoa('{"id":123}') };
yield { type: "done", contentLength: 10, contentLengthCompressed: 10, elapsed: 1 };
},
}));
const definition: HttpAssertions = {
version: 1,
checks: [
{
id: "check",
enabled: true,
target: "json",
selector: "$.id",
operator: "equals",
expected: "SECRET_EXPECTED",
expectedType: "text",
},
],
};
function worker() {
let cancel: (() => void) | undefined;
let storedBody: Uint8Array | undefined;
const evaluations: {
definition: HttpAssertions;
response: Partial<HttpResponse>;
canceled: boolean;
}[] = [];
const writes: Partial<HttpResponse>[] = [];
const request = {
id: "rq_test",
model: "http_request",
workspaceId: "wk_test",
assertions: structuredClone(definition),
};
const db = {
rpc: async (cmd: string, payload: Record<string, unknown>) => {
if (cmd === "web_get_http_request") return structuredClone(request);
if (cmd === "models_upsert") {
writes.push(structuredClone(payload.model as Partial<HttpResponse>));
return "rs_test";
}
if (cmd === "evaluate_http_assertions") {
const input = payload as (typeof evaluations)[number];
if (!input.response.error) expect(new TextDecoder().decode(storedBody)).toBe('{"id":123}');
evaluations.push(structuredClone(input));
return {
definition: input.definition,
results: [
{
assertionId: "check",
outcome: input.response.error ? "error" : "passed",
reason: "test evaluator",
actual: null,
},
],
error: null,
};
}
return null;
},
prepareHttpSend: async () => ({ request, settings: {}, cookieJar: null, settingEvents: [] }),
blobPut: async (_id: string, bytes: Uint8Array) => {
storedBody = bytes;
},
listen: (_event: string, callback: () => void) => {
cancel = callback;
return () => {
cancel = undefined;
};
},
} as unknown as WorkerConnection;
return { db, evaluations, writes, cancel: () => cancel?.() };
}
afterEach(() => vi.unstubAllGlobals());
describe("browser assertion evaluation", () => {
it("evaluates locally after saving the body and keeps assertion values out of the proxy request", async () => {
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
expect(init.body).not.toContain("SECRET_EXPECTED");
return new Response("stream");
});
vi.stubGlobal("fetch", fetch);
const { db, evaluations, writes } = worker();
const response = await sendHttpRequest(db, "rq_test", null, null);
expect(evaluations).toHaveLength(1);
expect(evaluations[0]?.definition).toEqual(definition);
expect(evaluations[0]?.canceled).toBe(false);
expect(response.assertionResults?.results[0]?.outcome).toBe("passed");
expect(writes.at(-1)?.state).toBe("closed");
expect(writes.at(-1)?.assertionResults).toEqual(response.assertionResults);
});
it("marks a canceled response as incomplete before asking the shared evaluator", async () => {
const { db, cancel, evaluations } = worker();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
cancel();
throw new Error("aborted");
}),
);
const response = await sendHttpRequest(db, "rq_test", null, null);
expect(evaluations).toHaveLength(1);
expect(evaluations[0]?.canceled).toBe(true);
expect(evaluations[0]?.response.error).toBe("Request canceled");
expect(response.assertionResults?.results[0]?.outcome).toBe("error");
});
});
+37 -17
View File
@@ -24,6 +24,8 @@
// Types only: the models package imports this one at runtime, and a type import
// is erased, so there is no cycle.
import type {
AssertionReport,
HttpAssertions,
Cookie,
CookieJar,
HttpRequest,
@@ -70,8 +72,16 @@ export async function sendHttpRequest(
// The response row exists before anything can go wrong, as on the desktop, so
// a failure to render or to reach the server lands in the response pane as
// that response's error rather than as a toast that names no request.
const workspaceId = await workspaceIdOfRequest(db, requestId);
const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId });
const request = await db.rpc<HttpRequest>("web_get_http_request", { requestId });
const response = new ResponseWriter(
db,
{
model: "http_response",
requestId,
workspaceId: request.workspaceId,
},
request.assertions ?? { version: 1, checks: [] },
);
await response.create();
const cancel = new AbortController();
@@ -81,7 +91,7 @@ export async function sendHttpRequest(
await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal);
} catch (err) {
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
await response.finish({ error: message });
await response.finish({ error: message }, cancel.signal.aborted);
} finally {
unlistenCancel();
}
@@ -111,7 +121,8 @@ async function runSend(
timeline.push(prepared.settingEvents);
const body: SendRequest = {
request: prepared.request,
// Assertions run locally against the completed response.
request: { ...prepared.request, assertions: { version: 1, checks: [] } },
settings: prepared.settings,
cookies: prepared.cookieJar?.cookies ?? null,
};
@@ -234,6 +245,7 @@ class ResponseWriter {
constructor(
private readonly db: WorkerConnection,
initial: ResponseRow,
private readonly assertions: HttpAssertions,
) {
this.state = initial;
}
@@ -266,8 +278,17 @@ class ResponseWriter {
await this.db.rpc("models_upsert", { model: this.state });
}
async finish(patch: ResponsePatch): Promise<void> {
await this.patch({ ...patch, state: "closed" });
async finish(patch: ResponsePatch, canceled = false): Promise<void> {
const response = { ...this.state, ...patch, state: "closed" as const };
const assertionResults =
this.assertions.version !== 1 || this.assertions.checks.length > 0
? await this.db.rpc<AssertionReport>("evaluate_http_assertions", {
definition: this.assertions,
response,
canceled,
})
: null;
await this.patch({ ...patch, state: "closed", assertionResults });
}
}
@@ -315,17 +336,16 @@ class TimelineWriter {
* yaak-models), so an edit made while the send was in flight survives rather
* than being written over by the send's stale snapshot.
*/
async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise<void> {
await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies });
}
/**
* The request's workspace, needed to create the response row before the worker
* has resolved the request (which is where a render refusal would land).
*/
async function workspaceIdOfRequest(db: WorkerConnection, requestId: string): Promise<string> {
const req = await db.rpc<{ workspaceId: string }>("web_get_http_request", { requestId });
return req.workspaceId;
async function persistCookies(
db: WorkerConnection,
jar: CookieJar,
cookies: Cookie[],
): Promise<void> {
await db.rpc("web_persist_send_cookies", {
cookieJarId: jar.id,
before: jar.cookies,
after: cookies,
});
}
function errorMessage(err: unknown): string {