fix: handle configuration schema bodies and editor load failures

This commit is contained in:
2026-10-05 14:05:45 +09:00
parent fec85dd38d
commit b489a59057
4 changed files with 369 additions and 11 deletions
@@ -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),
};
}
+89 -2
View File
@@ -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 =
(() =>