fix: handle configuration schema bodies and editor load failures
This commit is contained in:
@@ -0,0 +1,231 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import ConfigSourceEditor from "./ConfigSourceEditor.svelte";
|
||||||
|
|
||||||
|
const toolchain = vi.hoisted(() => ({
|
||||||
|
construct: vi.fn(),
|
||||||
|
setSnapshot: vi.fn(),
|
||||||
|
analyze: vi.fn(),
|
||||||
|
complete: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("./toolchain.ts", () => ({
|
||||||
|
ConfigSourceToolchain: class {
|
||||||
|
constructor() {
|
||||||
|
toolchain.construct();
|
||||||
|
}
|
||||||
|
setSnapshot = toolchain.setSnapshot;
|
||||||
|
analyze = toolchain.analyze;
|
||||||
|
complete = toolchain.complete;
|
||||||
|
close = toolchain.close;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const schemaSource = "schema body\n".repeat(6000);
|
||||||
|
const tree = {
|
||||||
|
snapshot: {
|
||||||
|
revision: 7,
|
||||||
|
digest: "sha256:tree",
|
||||||
|
entries: {
|
||||||
|
"main.dcdl": {
|
||||||
|
path: "main.dcdl",
|
||||||
|
content_type: "decodal",
|
||||||
|
content: "WorkspaceConfigSchema({})\n",
|
||||||
|
content_digest: "sha256:main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contract: {
|
||||||
|
contract_version: 1,
|
||||||
|
decodal_version: "0.4.0",
|
||||||
|
schema_version: 1,
|
||||||
|
entrypoints: ["main.dcdl"],
|
||||||
|
import_policy_version: 1,
|
||||||
|
schema_bundle: {
|
||||||
|
contributions: [{
|
||||||
|
provider_id: "builtin",
|
||||||
|
namespace: "workspace",
|
||||||
|
version: "1",
|
||||||
|
source: schemaSource,
|
||||||
|
source_digest: "sha256:contribution",
|
||||||
|
}],
|
||||||
|
source: schemaSource,
|
||||||
|
fingerprint: "sha256:schema",
|
||||||
|
},
|
||||||
|
fingerprint: "sha256:toolchain",
|
||||||
|
},
|
||||||
|
projection_digest: "sha256:projection",
|
||||||
|
};
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
const response = () => new Response(JSON.stringify(tree));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
fetcher.mockImplementation(async () => response());
|
||||||
|
toolchain.setSnapshot.mockResolvedValue(undefined);
|
||||||
|
toolchain.analyze.mockResolvedValue([]);
|
||||||
|
toolchain.complete.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderEditor(workspaceId = "w") {
|
||||||
|
return render(ConfigSourceEditor, { workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoEditor(view: ReturnType<typeof renderEditor>) {
|
||||||
|
expect(view.queryByText("Select or create a source")).toBeNull();
|
||||||
|
expect(view.queryByRole("button", { name: "Create local source" }))
|
||||||
|
.toBeNull();
|
||||||
|
expect(view.queryByRole("button", { name: "Commit" })).toBeNull();
|
||||||
|
expect(view.container.querySelector(".cm-editor")).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectReady(view: ReturnType<typeof renderEditor>) {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByRole("button", { name: "main.dcdl entrypoint" })).not
|
||||||
|
.toBeNull()
|
||||||
|
);
|
||||||
|
expect(view.container.querySelector('.cm-content[contenteditable="true"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(view.container.textContent).toContain("WorkspaceConfigSchema({})");
|
||||||
|
expect(view.queryByRole("alert")).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loading is not an empty source tree; editing waits for schema installation", async () => {
|
||||||
|
let finishFetch!: (response: Response) => void;
|
||||||
|
let finishSetup!: () => void;
|
||||||
|
fetcher.mockReturnValueOnce(
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
finishFetch = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
toolchain.setSnapshot.mockReturnValueOnce(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishSetup = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = render(ConfigSourceEditor, { workspaceId: "w/one" });
|
||||||
|
expect(view.getByRole("status").textContent).toContain("Loading source tree");
|
||||||
|
expectNoEditor(view);
|
||||||
|
finishFetch(response());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toolchain.setSnapshot).toHaveBeenCalledWith(
|
||||||
|
tree.snapshot,
|
||||||
|
tree.contract.schema_bundle,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expectNoEditor(view);
|
||||||
|
finishSetup();
|
||||||
|
await expectReady(view);
|
||||||
|
expect(fetcher).toHaveBeenCalledWith(
|
||||||
|
"/api/w/w%2Fone/config/source-tree",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["http", "invalid"])(
|
||||||
|
"%s failure has a manual reload, not creation controls or automatic retries",
|
||||||
|
async (failure) => {
|
||||||
|
fetcher.mockResolvedValueOnce(
|
||||||
|
failure === "http"
|
||||||
|
? new Response("Backend unavailable", { status: 503 })
|
||||||
|
: new Response(JSON.stringify({ ...tree, unexpected: true })),
|
||||||
|
);
|
||||||
|
const view = renderEditor();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByRole("alert").textContent).toContain(
|
||||||
|
"Unable to load workspace configuration",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(view.getByRole("alert").textContent).toContain(
|
||||||
|
failure === "http" ? "Backend unavailable" : "invalid response",
|
||||||
|
);
|
||||||
|
expectNoEditor(view);
|
||||||
|
expect(toolchain.setSnapshot).not.toHaveBeenCalled();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
expect(fetcher).toHaveBeenCalledOnce();
|
||||||
|
await fireEvent.click(
|
||||||
|
view.getByRole("button", { name: "Reload source tree" }),
|
||||||
|
);
|
||||||
|
await expectReady(view);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(["construct", "setSnapshot"] as const)(
|
||||||
|
"toolchain %s failure is caught and manual reload initializes a fresh toolchain",
|
||||||
|
async (phase) => {
|
||||||
|
if (phase === "construct") {
|
||||||
|
toolchain.construct.mockImplementationOnce(() => {
|
||||||
|
throw new Error("Worker unavailable");
|
||||||
|
});
|
||||||
|
} else {toolchain.setSnapshot.mockRejectedValueOnce(
|
||||||
|
new Error("Schema setup failed"),
|
||||||
|
);}
|
||||||
|
const view = renderEditor();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByRole("alert").textContent).toContain(
|
||||||
|
phase === "construct" ? "Worker unavailable" : "Schema setup failed",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expectNoEditor(view);
|
||||||
|
await fireEvent.click(
|
||||||
|
view.getByRole("button", { name: "Reload source tree" }),
|
||||||
|
);
|
||||||
|
await expectReady(view);
|
||||||
|
expect(toolchain.construct).toHaveBeenCalledTimes(2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("a successfully loaded empty tree still offers source creation", async () => {
|
||||||
|
fetcher.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({
|
||||||
|
...tree,
|
||||||
|
snapshot: { ...tree.snapshot, entries: {} },
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const view = renderEditor();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(view.getByText("Select or create a source")).not.toBeNull()
|
||||||
|
);
|
||||||
|
expect(view.getByRole("button", { name: "Create local source" })).not
|
||||||
|
.toBeNull();
|
||||||
|
expect(view.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmount during schema setup closes the toolchain", async () => {
|
||||||
|
let failSetup!: (error: Error) => void;
|
||||||
|
toolchain.setSnapshot.mockReturnValueOnce(
|
||||||
|
new Promise<void>((_, reject) => {
|
||||||
|
failSetup = reject;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
toolchain.close.mockImplementationOnce(() =>
|
||||||
|
failSetup(new Error("toolchain closed"))
|
||||||
|
);
|
||||||
|
const view = renderEditor();
|
||||||
|
await waitFor(() => expect(toolchain.setSnapshot).toHaveBeenCalledOnce());
|
||||||
|
view.unmount();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(toolchain.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmount during fetch does not start a toolchain", async () => {
|
||||||
|
let finishFetch!: (response: Response) => void;
|
||||||
|
fetcher.mockReturnValueOnce(
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
finishFetch = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = renderEditor();
|
||||||
|
view.unmount();
|
||||||
|
finishFetch(response());
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(toolchain.construct).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
let newPath = $state("module.dcdl");
|
let newPath = $state("module.dcdl");
|
||||||
let diagnostics = $state<ConfigDiagnostic[]>([]);
|
let diagnostics = $state<ConfigDiagnostic[]>([]);
|
||||||
let status = $state("Loading source tree…");
|
let status = $state("Loading source tree…");
|
||||||
|
let loadState = $state<"loading" | "error" | "ready">("loading");
|
||||||
|
let mounted = false;
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let workingChanges = $state<ConfigTreeChange[]>([]);
|
let workingChanges = $state<ConfigTreeChange[]>([]);
|
||||||
let baseRevision = $state(0);
|
let baseRevision = $state(0);
|
||||||
@@ -42,9 +44,12 @@
|
|||||||
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
toolchain = new ConfigSourceToolchain();
|
mounted = true;
|
||||||
void reload();
|
void reload();
|
||||||
return () => toolchain?.close();
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
toolchain?.close();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -70,9 +75,20 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
|
loadState = "loading";
|
||||||
|
status = "Loading source tree…";
|
||||||
analysisReady = false;
|
analysisReady = false;
|
||||||
|
analysisGeneration += 1;
|
||||||
try {
|
try {
|
||||||
treeState = await fetchConfigTree(workspaceId);
|
const remote = await fetchConfigTree(workspaceId);
|
||||||
|
if (!mounted) return;
|
||||||
|
toolchain?.close();
|
||||||
|
toolchain = null;
|
||||||
|
toolchain = new ConfigSourceToolchain();
|
||||||
|
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
||||||
|
if (!mounted) return;
|
||||||
|
// Publish the new baseline only after both fetch and toolchain setup succeed.
|
||||||
|
treeState = remote;
|
||||||
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
|
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
|
||||||
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||||
}
|
}
|
||||||
@@ -80,15 +96,19 @@
|
|||||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||||
baseRevision = treeState.snapshot.revision;
|
baseRevision = treeState.snapshot.revision;
|
||||||
baseDigest = treeState.snapshot.digest;
|
baseDigest = treeState.snapshot.digest;
|
||||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
|
||||||
analysisReady = true;
|
analysisReady = true;
|
||||||
workingChanges = [];
|
workingChanges = [];
|
||||||
renamePath = selectedPath;
|
renamePath = selectedPath;
|
||||||
diagnostics = [];
|
diagnostics = [];
|
||||||
conflict = false;
|
conflict = false;
|
||||||
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||||
|
loadState = "ready";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
toolchain?.close();
|
||||||
|
toolchain = null;
|
||||||
status = String(error);
|
status = String(error);
|
||||||
|
loadState = "error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,8 +233,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function discardAndReload() {
|
async function discardAndReload() {
|
||||||
workingChanges = [];
|
|
||||||
source = "";
|
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +305,18 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="config-source-shell" aria-label="Workspace configuration source tree">
|
<section class="config-source-shell" aria-label="Workspace configuration source tree" aria-busy={loadState === "loading"}>
|
||||||
|
{#if loadState === "loading"}
|
||||||
|
<div class="config-source-load" role="status">Loading source tree…</div>
|
||||||
|
{:else if loadState === "error"}
|
||||||
|
<div class="config-source-load">
|
||||||
|
<div role="alert">
|
||||||
|
<strong>Unable to load workspace configuration</strong>
|
||||||
|
<p>{status}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick={reload}>Reload source tree</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<aside class="config-source-tree">
|
<aside class="config-source-tree">
|
||||||
<div class="config-source-tree__header">
|
<div class="config-source-tree__header">
|
||||||
<strong>Source tree</strong>
|
<strong>Source tree</strong>
|
||||||
@@ -354,4 +383,12 @@
|
|||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.config-source-load {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import type {
|
|||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
|
// Schema bodies are documents, not short metadata. Keep them within the
|
||||||
|
// response budget; readJson also bounds the combined bundle and contributions.
|
||||||
|
const MAX_SCHEMA_SOURCE_BYTES = MAX_RESPONSE_BYTES;
|
||||||
const MAX_ERROR_BYTES = 4 * 1024;
|
const MAX_ERROR_BYTES = 4 * 1024;
|
||||||
const MAX_ENTRY_COUNT = 256;
|
const MAX_ENTRY_COUNT = 256;
|
||||||
const MAX_ENTRY_BYTES = 256 * 1024;
|
const MAX_ENTRY_BYTES = 256 * 1024;
|
||||||
@@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
|
|||||||
provider_id: boundedString(item.provider_id),
|
provider_id: boundedString(item.provider_id),
|
||||||
namespace: boundedString(item.namespace),
|
namespace: boundedString(item.namespace),
|
||||||
version: boundedString(item.version),
|
version: boundedString(item.version),
|
||||||
source: boundedString(item.source),
|
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
||||||
...(projection === undefined ? {} : { projection_validator: projection }),
|
...(projection === undefined ? {} : { projection_validator: projection }),
|
||||||
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
|
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
|
||||||
};
|
};
|
||||||
@@ -203,7 +206,7 @@ function parseSchemaBundle(value: unknown): WorkspaceConfigSchemaBundle {
|
|||||||
const item = record(value, ["contributions", "source", "fingerprint"]);
|
const item = record(value, ["contributions", "source", "fingerprint"]);
|
||||||
return {
|
return {
|
||||||
contributions: boundedArray(item.contributions).map(parseContribution),
|
contributions: boundedArray(item.contributions).map(parseContribution),
|
||||||
source: boundedString(item.source),
|
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
||||||
fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES),
|
fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn
|
|||||||
entrypoints: [],
|
entrypoints: [],
|
||||||
}, fetcher);
|
}, fetcher);
|
||||||
|
|
||||||
assertEquals(calls.map((call) => call.url), [
|
assertEquals<unknown>(calls.map((call) => call.url), [
|
||||||
"/api/w/w%2Fone/config/source-tree",
|
"/api/w/w%2Fone/config/source-tree",
|
||||||
"/api/w/w%2Fone/config/source-tree/revisions/7",
|
"/api/w/w%2Fone/config/source-tree/revisions/7",
|
||||||
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
||||||
"/api/w/w%2Fone/config/source-tree/commit",
|
"/api/w/w%2Fone/config/source-tree/commit",
|
||||||
]);
|
]);
|
||||||
assertEquals(calls[3].init?.method, "POST");
|
assertEquals<unknown>(calls[3].init?.method, "POST");
|
||||||
assert(
|
assert(
|
||||||
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function withSchema(source: unknown, contributionSource: unknown = "builtin") {
|
||||||
|
return {
|
||||||
|
...tree,
|
||||||
|
contract: {
|
||||||
|
...tree.contract,
|
||||||
|
schema_bundle: {
|
||||||
|
...tree.contract.schema_bundle,
|
||||||
|
source,
|
||||||
|
contributions: [{
|
||||||
|
provider_id: "builtin",
|
||||||
|
namespace: "workspace",
|
||||||
|
version: "1",
|
||||||
|
source: contributionSource,
|
||||||
|
source_digest: "sha256:contribution",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ["bundle", "contribution"] as const) {
|
||||||
|
Deno.test(`config source API accepts ${field} schema bodies larger than metadata`, async () => {
|
||||||
|
const source = "schema body\n".repeat(6000);
|
||||||
|
const body = field === "bundle"
|
||||||
|
? withSchema(source)
|
||||||
|
: withSchema("builtin", source);
|
||||||
|
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch;
|
||||||
|
assertEquals<unknown>(await fetchConfigTree("w", fetcher), body);
|
||||||
|
assertEquals<unknown>(
|
||||||
|
await commitConfigTree("w", {
|
||||||
|
base_revision: 7,
|
||||||
|
base_digest: snapshot.digest,
|
||||||
|
changes: [],
|
||||||
|
entrypoints: ["main.dcdl"],
|
||||||
|
}, fetcher),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test(`config source API bounds ${field} schema bodies in UTF-8 bytes and checks types`, () => {
|
||||||
|
const body = (source: unknown) =>
|
||||||
|
field === "bundle" ? withSchema(source) : withSchema("builtin", source);
|
||||||
|
const limit = 8 * 1024 * 1024;
|
||||||
|
const atLimit = "é".repeat(limit / 2);
|
||||||
|
assertEquals<unknown>(
|
||||||
|
parseWorkspaceConfigTreeResponse(body(atLimit)),
|
||||||
|
body(atLimit),
|
||||||
|
);
|
||||||
|
for (const invalid of [atLimit + "x", null, 42, {}]) {
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceConfigTreeResponse(body(invalid)),
|
||||||
|
ConfigSourceApiError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("config source API retains metadata and total response limits", async () => {
|
||||||
|
const invalid = withSchema("builtin");
|
||||||
|
invalid.contract.schema_bundle.contributions[0].provider_id = "x".repeat(
|
||||||
|
4097,
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceConfigTreeResponse(invalid),
|
||||||
|
ConfigSourceApiError,
|
||||||
|
);
|
||||||
|
for (
|
||||||
|
const headers of [
|
||||||
|
new Headers(),
|
||||||
|
new Headers({ "content-length": String(8 * 1024 * 1024 + 1) }),
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
const fetcher = (() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))),
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
)) as typeof fetch;
|
||||||
|
await assertRejects(
|
||||||
|
() => fetchConfigTree("w", fetcher),
|
||||||
|
ConfigSourceApiError,
|
||||||
|
"too large",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("config source API rejects unknown response fields", async () => {
|
Deno.test("config source API rejects unknown response fields", async () => {
|
||||||
const fetcher =
|
const fetcher =
|
||||||
(() =>
|
(() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user