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 diagnostics = $state<ConfigDiagnostic[]>([]);
|
||||
let status = $state("Loading source tree…");
|
||||
let loadState = $state<"loading" | "error" | "ready">("loading");
|
||||
let mounted = false;
|
||||
let busy = $state(false);
|
||||
let workingChanges = $state<ConfigTreeChange[]>([]);
|
||||
let baseRevision = $state(0);
|
||||
@@ -42,9 +44,12 @@
|
||||
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||
|
||||
onMount(() => {
|
||||
toolchain = new ConfigSourceToolchain();
|
||||
mounted = true;
|
||||
void reload();
|
||||
return () => toolchain?.close();
|
||||
return () => {
|
||||
mounted = false;
|
||||
toolchain?.close();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -70,9 +75,20 @@
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
loadState = "loading";
|
||||
status = "Loading source tree…";
|
||||
analysisReady = false;
|
||||
analysisGeneration += 1;
|
||||
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]) {
|
||||
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||
}
|
||||
@@ -80,15 +96,19 @@
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
analysisReady = true;
|
||||
workingChanges = [];
|
||||
renamePath = selectedPath;
|
||||
diagnostics = [];
|
||||
conflict = false;
|
||||
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||
loadState = "ready";
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
toolchain?.close();
|
||||
toolchain = null;
|
||||
status = String(error);
|
||||
loadState = "error";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +233,6 @@
|
||||
}
|
||||
|
||||
async function discardAndReload() {
|
||||
workingChanges = [];
|
||||
source = "";
|
||||
await reload();
|
||||
}
|
||||
|
||||
@@ -287,7 +305,18 @@
|
||||
}
|
||||
</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">
|
||||
<div class="config-source-tree__header">
|
||||
<strong>Source tree</strong>
|
||||
@@ -354,4 +383,12 @@
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.config-source-load {
|
||||
grid-column: 1 / -1;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
} from "./types.ts";
|
||||
|
||||
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_ENTRY_COUNT = 256;
|
||||
const MAX_ENTRY_BYTES = 256 * 1024;
|
||||
@@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
|
||||
provider_id: boundedString(item.provider_id),
|
||||
namespace: boundedString(item.namespace),
|
||||
version: boundedString(item.version),
|
||||
source: boundedString(item.source),
|
||||
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
||||
...(projection === undefined ? {} : { projection_validator: projection }),
|
||||
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"]);
|
||||
return {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn
|
||||
entrypoints: [],
|
||||
}, 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/revisions/7",
|
||||
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
||||
"/api/w/w%2Fone/config/source-tree/commit",
|
||||
]);
|
||||
assertEquals(calls[3].init?.method, "POST");
|
||||
assertEquals<unknown>(calls[3].init?.method, "POST");
|
||||
assert(
|
||||
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 () => {
|
||||
const fetcher =
|
||||
(() =>
|
||||
|
||||
Reference in New Issue
Block a user